@unboundcx/video-sdk-client 1.1.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.
@@ -1,8 +1,8 @@
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';
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,774 +16,1060 @@ import { StatsCollector } from './StatsCollector.js';
16
16
  * - 'consumer:created' - Consumer created
17
17
  */
18
18
  export class MediasoupManager extends EventEmitter {
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
-
41
- /**
42
- * Load device with router RTP capabilities
43
- * This sets up a listener for media.routerCapabilities event from server
44
- * @returns {Promise<void>}
45
- */
46
- async loadDevice() {
47
- if (this.device.loaded) {
48
- this.logger.warn('Device already loaded');
49
- return;
50
- }
51
-
52
- this.logger.info('Waiting for media.routerCapabilities from server...');
53
-
54
- return new Promise((resolve, reject) => {
55
- const timeout = setTimeout(() => {
56
- reject(new MediasoupError('Timeout waiting for router capabilities'));
57
- }, 10000);
58
-
59
- // Listen for media.routerCapabilities event from server
60
- this.connection.onServerEvent('media.routerCapabilities', async (data) => {
61
- try {
62
- clearTimeout(timeout);
63
-
64
- this.logger.info('Received media.routerCapabilities', {
65
- hasCapabilities: !!data?.routerRtpCapabilities,
66
- codecCount: data?.routerRtpCapabilities?.codecs?.length || 0
67
- });
68
-
69
- const { routerRtpCapabilities } = data;
70
-
71
- if (!routerRtpCapabilities) {
72
- throw new MediasoupError('No router capabilities in server response');
73
- }
74
-
75
- // Debug: Log router codecs
76
- this.logger.info('Router Video Codecs:',
77
- routerRtpCapabilities.codecs
78
- .filter(c => c.kind === 'video')
79
- .map(c => `${c.mimeType} (params: ${JSON.stringify(c.parameters || {})})`)
80
- );
81
-
82
- // Load device
83
- await this.device.load({ routerRtpCapabilities });
84
-
85
- this.logger.info('Device loaded successfully', {
86
- codecs: this.device.rtpCapabilities.codecs.length,
87
- canProduce: {
88
- video: this.device.canProduce('video'),
89
- audio: this.device.canProduce('audio')
90
- }
91
- });
92
-
93
- // Debug: Log device codecs
94
- this.logger.info('Device Video Codecs:',
95
- this.device.rtpCapabilities.codecs
96
- .filter(c => c.kind === 'video')
97
- .map(c => c.mimeType)
98
- );
99
-
100
- this.emit('device:loaded', {
101
- rtpCapabilities: this.device.rtpCapabilities
102
- });
103
-
104
- resolve();
105
-
106
- } catch (error) {
107
- this.logger.error('Failed to load device:', error);
108
- reject(new MediasoupError('Failed to load device', 'loadDevice', error));
109
- }
110
- });
111
- });
112
- }
113
-
114
- /**
115
- * Create send transport for publishing media
116
- * @returns {Promise<Transport>}
117
- */
118
- async createSendTransport() {
119
- if (!this.device.loaded) {
120
- throw new StateError('Device not loaded', 'not-loaded', 'loaded');
121
- }
122
-
123
- if (this.sendTransport) {
124
- this.logger.warn('Send transport already exists');
125
- return this.sendTransport;
126
- }
127
-
128
- this.logger.info('Creating send transport...');
129
-
130
- try {
131
- // Request transport options from server
132
- const transportOptions = await this.connection.request('media.createSendTransport');
133
-
134
- // Create transport
135
- this.sendTransport = this.device.createSendTransport(transportOptions);
136
-
137
- // Setup transport event handlers
138
- this._setupSendTransportListeners(this.sendTransport);
139
-
140
- this.emit('transport:created', { direction: 'send', id: this.sendTransport.id });
141
-
142
- return this.sendTransport;
143
-
144
- } catch (error) {
145
- this.logger.error('Failed to create send transport:', error);
146
- throw new MediasoupError('Failed to create send transport', 'createSendTransport', error);
147
- }
148
- }
149
-
150
- /**
151
- * Create receive transport for consuming media
152
- * @returns {Promise<Transport>}
153
- */
154
- async createRecvTransport() {
155
- if (!this.device.loaded) {
156
- throw new StateError('Device not loaded', 'not-loaded', 'loaded');
157
- }
158
-
159
- if (this.recvTransport) {
160
- this.logger.warn('Receive transport already exists');
161
- return this.recvTransport;
162
- }
163
-
164
- this.logger.info('Creating receive transport...');
165
-
166
- try {
167
- // Request transport options from server
168
- const transportOptions = await this.connection.request('media.createRecvTransport');
169
-
170
- // Create transport
171
- this.recvTransport = this.device.createRecvTransport(transportOptions);
172
-
173
- // Setup transport event handlers
174
- this._setupRecvTransportListeners(this.recvTransport);
175
-
176
- this.emit('transport:created', { direction: 'recv', id: this.recvTransport.id });
177
-
178
- return this.recvTransport;
179
-
180
- } catch (error) {
181
- this.logger.error('Failed to create receive transport:', error);
182
- throw new MediasoupError('Failed to create receive transport', 'createRecvTransport', error);
183
- }
184
- }
185
-
186
- /**
187
- * Setup send transport event listeners (matches your server's emit/on pattern)
188
- * @private
189
- */
190
- _setupSendTransportListeners() {
191
- const transport = this.sendTransport;
192
- const socket = this.connection.socket; // Cache socket reference like old system
193
-
194
- // Connect event - DTLS parameters need to be sent to server
195
- transport.on('connect', ({ dtlsParameters }, callback, errback) => {
196
- this.logger.log('Send transport connecting...');
197
-
198
- // Emit to server
199
- socket.emit('media.connectSendTransport', {
200
- transportId: transport.id,
201
- dtlsParameters
202
- });
203
-
204
- // Listen for success/error responses
205
- socket.once('media.connectSendTransport.success', () => {
206
- socket.off('media.connectSendTransport.error');
207
- this.logger.info('Send transport connected');
208
- this.emit('transport:connected', { direction: 'send', id: transport.id });
209
-
210
- // Start stats collection for send transport
211
- this.statsCollector.startSendStats(
212
- transport,
213
- socket,
214
- this.virtualBackgroundStore
215
- );
216
-
217
- callback();
218
- });
219
-
220
- socket.once('media.connectSendTransport.error', (error) => {
221
- socket.off('media.connectSendTransport.success');
222
- this.logger.error('Failed to connect send transport:', error);
223
- errback(error);
224
- });
225
- });
226
-
227
- // Produce event - New track needs to be registered with server
228
- transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback) => {
229
- this.logger.log('Producing', kind);
230
-
231
- // Emit produce request to server
232
- socket.emit('media.produce', {
233
- transportId: transport.id,
234
- rtpParameters,
235
- producerType: appData?.type || kind, // Fixed: use appData.type (matches LocalMediaManager)
236
- });
237
-
238
- // Listen for success/error responses - use socket directly
239
- socket.once('media.produce.success', ({ producerId }) => {
240
- socket.off('media.produce.error');
241
- this.logger.info('Producer created:', producerId);
242
- callback({ id: producerId });
243
- });
244
-
245
- socket.once('media.produce.error', (error) => {
246
- socket.off('media.produce.success');
247
- this.logger.error('Failed to create producer:', error);
248
- errback(error);
249
- });
250
- });
251
-
252
- // Connection state change
253
- transport.on('connectionstatechange', (state) => {
254
- this.logger.info('Send transport state:', state);
255
-
256
- if (state === 'failed' || state === 'closed') {
257
- this.emit('transport:closed', { direction: 'send', id: transport.id, state });
258
- }
259
- });
260
- }
261
-
262
- /**
263
- * Setup receive transport event listeners (matches your server's emit/on pattern)
264
- * @private
265
- */
266
- _setupRecvTransportListeners() {
267
- const transport = this.recvTransport;
268
-
269
- // Connect event
270
- transport.on('connect', ({ dtlsParameters }, callback, errback) => {
271
- this.logger.log('Receive transport connecting...');
272
-
273
- // Emit to server
274
- this.connection.emit('media.connectRecvTransport', {
275
- transportId: transport.id,
276
- dtlsParameters
277
- });
278
-
279
- // Listen for success/error responses
280
- this.connection.socket.once('media.connectRecvTransport.success', () => {
281
- this.connection.socket.off('media.connectRecvTransport.error');
282
- this.logger.info('Receive transport connected');
283
- this.emit('transport:connected', { direction: 'recv', id: transport.id });
284
-
285
- // Start stats collection for recv transport
286
- this.statsCollector.startRecvStats(
287
- transport,
288
- this.connection.socket,
289
- this.virtualBackgroundStore
290
- );
291
-
292
- callback();
293
- });
294
-
295
- this.connection.socket.once('media.connectRecvTransport.error', (error) => {
296
- this.connection.socket.off('media.connectRecvTransport.success');
297
- this.logger.error('Failed to connect receive transport:', error);
298
- errback(error);
299
- });
300
- });
301
-
302
- // Connection state change
303
- transport.on('connectionstatechange', (state) => {
304
- this.logger.info('Receive transport state:', state);
305
-
306
- if (state === 'failed' || state === 'closed') {
307
- this.emit('transport:closed', { direction: 'recv', id: transport.id, state });
308
- }
309
- });
310
- }
311
-
312
- /**
313
- * Produce media (send to server)
314
- * @param {MediaStreamTrack} track - Media track to produce
315
- * @param {Object} options - Producer options
316
- * @returns {Promise<Producer>}
317
- */
318
- async produce(track, options = {}) {
319
-
320
- if (!this.sendTransport) {
321
- await this.createSendTransport();
322
- }
323
-
324
- this.logger.info('Producing track:', track.kind, track.id);
325
- this.logger.info('Produce options received:', {
326
- simulcast: options.simulcast,
327
- hasSimulcastOption: 'simulcast' in options,
328
- willEnableSimulcast: track.kind === 'video' && options.simulcast !== false
329
- });
330
-
331
- try {
332
- // Match old working system exactly - minimal options
333
- const produceOptions = {
334
- track
335
- };
336
-
337
- // Only add appData if provided (match old system pattern)
338
- if (options.appData) {
339
- produceOptions.appData = options.appData;
340
- }
341
-
342
- // Add simulcast encodings for video tracks (enabled by default, can be disabled)
343
- if (track.kind === 'video' && options.simulcast !== false) {
344
- // Get track settings to determine base resolution
345
- const settings = track.getSettings();
346
- const baseWidth = settings.width || 1920;
347
- const baseHeight = settings.height || 1080;
348
-
349
- // Check if user has set a max resolution preference
350
- const maxResolution = options.maxResolution || '1080p'; // Default to 1080p
351
-
352
- // Calculate target resolution based on user preference
353
- let targetWidth = baseWidth;
354
- let targetHeight = baseHeight;
355
- let targetBitrate = 3500000;
356
-
357
- if (maxResolution === '720p' && baseWidth > 1280) {
358
- // User prefers 720p max - scale down from 1080p
359
- targetWidth = 1280;
360
- targetHeight = 720;
361
- targetBitrate = 2000000;
362
- }
363
-
364
- // Adaptive simulcast configuration based on target resolution
365
- if (baseWidth >= 1280) {
366
- // High resolution camera (720p+) - Use 3-layer simulcast
367
- produceOptions.encodings = [
368
- { rid: 'l', maxBitrate: 200000, scaleResolutionDownBy: baseWidth / (targetWidth / 4) },
369
- { rid: 'm', maxBitrate: 700000, scaleResolutionDownBy: baseWidth / (targetWidth / 2) },
370
- { rid: 'h', maxBitrate: targetBitrate, scaleResolutionDownBy: baseWidth / targetWidth }
371
- ];
372
- this.logger.info('VIDEO_QUALITY :: 3-layer simulcast for high-res camera', {
373
- baseResolution: `${baseWidth}×${baseHeight}`,
374
- maxResolution: maxResolution,
375
- targetResolution: `${targetWidth}×${targetHeight}`,
376
- layers: [
377
- `l: ${Math.round(targetWidth/4)}×${Math.round(targetHeight/4)} @ 200kbps`,
378
- `m: ${Math.round(targetWidth/2)}×${Math.round(targetHeight/2)} @ 700kbps`,
379
- `h: ${targetWidth}×${targetHeight} @ ${(targetBitrate/1000000).toFixed(1)}Mbps`
380
- ]
381
- });
382
- } else if (baseWidth >= 640) {
383
- // Medium resolution camera (640×360 to 1280×720) - Use 2-layer
384
- // Don't scale down too much on already low-res cameras
385
- produceOptions.encodings = [
386
- { rid: 'l', maxBitrate: 200000, scaleResolutionDownBy: 2.0 },
387
- { rid: 'h', maxBitrate: 1200000, scaleResolutionDownBy: 1.0 }
388
- ];
389
- this.logger.info('VIDEO_QUALITY :: 2-layer simulcast for medium-res camera', {
390
- baseResolution: `${baseWidth}×${baseHeight}`,
391
- layers: [
392
- `l: ${Math.round(baseWidth/2)}×${Math.round(baseHeight/2)} @ 200kbps`,
393
- `h: ${baseWidth}×${baseHeight} @ 1.2Mbps`
394
- ]
395
- });
396
- } else {
397
- // Very low resolution camera (<640) - Single layer, higher bitrate
398
- produceOptions.encodings = [
399
- { rid: 'h', maxBitrate: 800000, scaleResolutionDownBy: 1.0 }
400
- ];
401
- this.logger.info('VIDEO_QUALITY :: Single layer for low-res camera', {
402
- baseResolution: `${baseWidth}×${baseHeight}`,
403
- bitrate: '800kbps'
404
- });
405
- }
406
- }
407
-
408
- this.logger.info('Calling transport.produce with options:', {
409
- trackKind: track.kind,
410
- trackId: track.id,
411
- hasEncodings: !!produceOptions.encodings,
412
- encodingsCount: produceOptions.encodings?.length,
413
- encodings: produceOptions.encodings
414
- });
415
-
416
- // Log track settings before producing
417
- if (track.kind === 'video') {
418
- const settings = track.getSettings();
419
- this.logger.info('Video track settings before produce:', {
420
- width: settings.width,
421
- height: settings.height,
422
- frameRate: settings.frameRate,
423
- facingMode: settings.facingMode
424
- });
425
- }
426
-
427
- this.logger.info('About to call sendTransport.produce() - this will trigger the produce event');
428
-
429
- const producer = await this.sendTransport.produce(produceOptions);
430
- this.logger.info('sendTransport.produce() succeeded - producer created:', producer.id);
431
-
432
- // Store producer
433
- const type = options.appData?.type || track.kind;
434
- this.producers.set(type, producer);
435
-
436
- this.emit('producer:created', { producer, type });
437
-
438
- // Handle producer events
439
- producer.on('transportclose', () => {
440
- this.logger.warn('Producer transport closed:', producer.id);
441
- this.producers.delete(type);
442
- });
443
-
444
- producer.on('trackended', () => {
445
- this.logger.warn('Producer track ended:', producer.id);
446
- });
447
-
448
- return producer;
449
-
450
- } catch (error) {
451
- this.logger.error('Failed to produce:', error);
452
- throw new MediasoupError('Failed to produce media', 'produce', error);
453
- }
454
- }
455
-
456
- /**
457
- * Consume media (receive from server)
458
- * @param {string} producerId - Producer ID to consume
459
- * @param {string} participantId - Participant ID
460
- * @param {Object} options - Consume options
461
- * @param {number} options.retryAttempt - Current retry attempt (for internal use)
462
- * @returns {Promise<Consumer>}
463
- */
464
- async consume(producerId, participantId, options = {}) {
465
- if (!this.recvTransport) {
466
- await this.createRecvTransport();
467
- }
468
-
469
- const retryAttempt = options.retryAttempt || 0;
470
- const maxRetries = 3;
471
-
472
- this.logger.info('Consuming producer:', producerId, retryAttempt > 0 ? `(attempt ${retryAttempt + 1}/${maxRetries + 1})` : '');
473
-
474
- try {
475
- // Wait for media.consumer.created event from server
476
- const consumerParams = await new Promise((resolve, reject) => {
477
- const timeout = setTimeout(() => {
478
- this.connection.socket.off('media.consumer.created', responseHandler);
479
- reject(new Error('Timeout waiting for media.consumer.created'));
480
- }, 5000);
481
-
482
- const responseHandler = (data) => {
483
- // Check if this response is for our producer request
484
- if (data.producer?.id === producerId) {
485
- clearTimeout(timeout);
486
- this.connection.socket.off('media.consumer.created', responseHandler);
487
-
488
- // Extract consumer params needed by mediasoup
489
- const params = {
490
- id: data.consumer.id,
491
- kind: data.consumer.kind,
492
- rtpParameters: data.consumer.rtpParameters,
493
- producerId: producerId
494
- };
495
- resolve(params);
496
- }
497
- };
498
-
499
- this.connection.socket.on('media.consumer.created', responseHandler);
500
-
501
- // Emit request to server with expected structure
502
- this.connection.socket.emit('media.consumer.create', {
503
- producer: { id: producerId },
504
- consumer: { rtpCapabilities: this.device.rtpCapabilities },
505
- participant: { id: participantId }
506
- });
507
- });
508
-
509
- // Create consumer - this is where duplicate msid errors occur
510
- const consumer = await this.recvTransport.consume(consumerParams);
511
-
512
- // Store consumer
513
- this.consumers.set(consumer.id, consumer);
514
-
515
- // Register track for stats collection
516
- if (consumer.track) {
517
- this.statsCollector.registerTrack(
518
- consumer.track.id,
519
- participantId,
520
- consumer.kind
521
- );
522
- }
523
-
524
- this.emit('consumer:created', { consumer, producerId, participantId });
525
-
526
- // Handle consumer events
527
- consumer.on('transportclose', () => {
528
- this.logger.warn('Consumer transport closed:', consumer.id);
529
- this.consumers.delete(consumer.id);
530
-
531
- // Unregister track from stats
532
- if (consumer.track) {
533
- this.statsCollector.unregisterTrack(consumer.track.id);
534
- }
535
- });
536
-
537
- consumer.on('trackended', () => {
538
- this.logger.warn('Consumer track ended:', consumer.id);
539
- });
540
-
541
- // Consumer starts unpaused on server (paused: false)
542
- // No need to resume
543
-
544
- return consumer;
545
-
546
- } catch (error) {
547
- // Check if this is a duplicate msid error or other SDP-related error
548
- const isDuplicateMsidError = error.message?.includes('Duplicate a=msid') ||
549
- error.message?.includes('setRemoteDescription') ||
550
- error.name === 'OperationError';
551
-
552
- // Retry logic for duplicate msid errors
553
- if (isDuplicateMsidError && retryAttempt < maxRetries) {
554
- const backoffDelay = Math.min(1000 * Math.pow(2, retryAttempt), 5000); // Exponential backoff: 1s, 2s, 4s
555
- this.logger.warn(`MediasoupManager :: Consume Failed :: Duplicate msid or SDP error detected, retrying in ${backoffDelay}ms (attempt ${retryAttempt + 1}/${maxRetries})`);
556
-
557
- // Wait before retrying
558
- await new Promise(resolve => setTimeout(resolve, backoffDelay));
559
-
560
- // Retry with incremented attempt counter
561
- return this.consume(producerId, participantId, { retryAttempt: retryAttempt + 1 });
562
- }
563
-
564
- this.logger.error('Failed to consume:', error);
565
- throw new MediasoupError('Failed to consume media', 'consume', error);
566
- }
567
- }
568
-
569
- /**
570
- * Close a producer
571
- * @param {string} type - Producer type (video, audio, screenshare)
572
- */
573
- async closeProducer(type) {
574
- const producer = this.producers.get(type);
575
- if (!producer) {
576
- this.logger.warn('Producer not found:', type);
577
- return;
578
- }
579
-
580
- this.logger.info('Closing producer:', type);
581
-
582
- try {
583
- producer.close();
584
- this.producers.delete(type);
585
-
586
- // Notify server with correct event name and format
587
- await this.connection.request('media.produce.close', {
588
- producer: {
589
- id: producer.id,
590
- producerType: type
591
- }
592
- });
593
-
594
- } catch (error) {
595
- this.logger.error('Failed to close producer:', error);
596
- }
597
- }
598
-
599
- /**
600
- * Replace track in an existing producer
601
- * @param {string} type - Producer type
602
- * @param {MediaStreamTrack} newTrack - New track to replace with
603
- */
604
- async replaceTrack(type, newTrack) {
605
- const producer = this.producers.get(type);
606
- if (!producer) {
607
- this.logger.warn('Producer not found:', type);
608
- throw new Error(`Producer not found: ${type}`);
609
- }
610
-
611
- this.logger.info('Replacing track for producer:', type);
612
-
613
- try {
614
- await producer.replaceTrack({ track: newTrack });
615
- this.logger.info('Track replaced successfully for producer:', type);
616
- } catch (error) {
617
- this.logger.error('Failed to replace track:', error);
618
- throw error;
619
- }
620
- }
621
-
622
- /**
623
- * Pause a producer (mute)
624
- * @param {string} type - Producer type
625
- */
626
- async pauseProducer(type) {
627
- const producer = this.producers.get(type);
628
- if (!producer) {
629
- this.logger.warn('Producer not found:', type);
630
- return;
631
- }
632
-
633
- this.logger.info('Pausing producer:', type);
634
-
635
- try {
636
- await producer.pause();
637
-
638
- // Notify server
639
- await this.connection.request('media.producer.pause', {
640
- producer: {
641
- id: producer.id,
642
- producerType: type
643
- }
644
- });
645
-
646
- this.logger.info('Producer paused:', type);
647
- } catch (error) {
648
- this.logger.error('Failed to pause producer:', error);
649
- throw error;
650
- }
651
- }
652
-
653
- /**
654
- * Resume a producer (unmute)
655
- * @param {string} type - Producer type
656
- */
657
- async resumeProducer(type) {
658
- const producer = this.producers.get(type);
659
- if (!producer) {
660
- this.logger.warn('Producer not found:', type);
661
- return;
662
- }
663
-
664
- this.logger.info('Resuming producer:', type);
665
-
666
- try {
667
- await producer.resume();
668
-
669
- // Notify server
670
- await this.connection.request('media.producer.resume', {
671
- producer: {
672
- id: producer.id,
673
- producerType: type
674
- }
675
- });
676
-
677
- this.logger.info('Producer resumed:', type);
678
- } catch (error) {
679
- this.logger.error('Failed to resume producer:', error);
680
- throw error;
681
- }
682
- }
683
-
684
- /**
685
- * Close a consumer
686
- * @param {string} consumerId - Consumer ID
687
- */
688
- async closeConsumer(consumerId) {
689
- const consumer = this.consumers.get(consumerId);
690
- if (!consumer) {
691
- this.logger.warn('Consumer not found:', consumerId);
692
- return;
693
- }
694
-
695
- this.logger.info('Closing consumer:', consumerId);
696
-
697
- try {
698
- consumer.close();
699
- this.consumers.delete(consumerId);
700
-
701
- } catch (error) {
702
- this.logger.error('Failed to close consumer:', error);
703
- }
704
- }
705
-
706
- /**
707
- * Clean up all resources
708
- */
709
- async cleanup() {
710
- this.logger.info('Cleaning up mediasoup resources...');
711
-
712
- // Stop stats collection
713
- this.statsCollector.stopAll();
714
-
715
- // Close all producers
716
- for (const [type, producer] of this.producers) {
717
- try {
718
- producer.close();
719
- } catch (error) {
720
- this.logger.error('Error closing producer:', type, error);
721
- }
722
- }
723
- this.producers.clear();
724
-
725
- // Close all consumers
726
- for (const [id, consumer] of this.consumers) {
727
- try {
728
- consumer.close();
729
- } catch (error) {
730
- this.logger.error('Error closing consumer:', id, error);
731
- }
732
- }
733
- this.consumers.clear();
734
-
735
- // Close transports
736
- if (this.sendTransport) {
737
- this.sendTransport.close();
738
- this.sendTransport = null;
739
- }
740
-
741
- if (this.recvTransport) {
742
- this.recvTransport.close();
743
- this.recvTransport = null;
744
- }
745
-
746
- this.logger.info('Cleanup complete');
747
- }
748
-
749
- /**
750
- * Set virtual background store getter for stats collection
751
- * @param {Function} getter - Function that returns virtual background state
752
- */
753
- setVirtualBackgroundStore(getter) {
754
- this.virtualBackgroundStore = getter;
755
- }
756
-
757
- /**
758
- * Get producer by type
759
- * @param {string} type - Producer type
760
- * @returns {Producer|null}
761
- */
762
- getProducer(type) {
763
- return this.producers.get(type) || null;
764
- }
765
-
766
- /**
767
- * Check if device is loaded
768
- * @returns {boolean}
769
- */
770
- get isDeviceLoaded() {
771
- return this.device.loaded;
772
- }
773
-
774
- /**
775
- * Check if device can produce video
776
- * @returns {boolean}
777
- */
778
- get canProduceVideo() {
779
- return this.device.loaded && this.device.canProduce('video');
780
- }
781
-
782
- /**
783
- * Check if device can produce audio
784
- * @returns {boolean}
785
- */
786
- get canProduceAudio() {
787
- return this.device.loaded && this.device.canProduce('audio');
788
- }
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
+ }
789
1075
  }