@unboundcx/video-sdk-client 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,789 @@
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
+
7
+ /**
8
+ * Manages mediasoup Device and Transports
9
+ *
10
+ * Events:
11
+ * - 'device:loaded' - Device loaded with router capabilities
12
+ * - 'transport:created' - Transport created
13
+ * - 'transport:connected' - Transport connected
14
+ * - 'transport:closed' - Transport closed
15
+ * - 'producer:created' - Producer created
16
+ * - 'consumer:created' - Consumer created
17
+ */
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
+ }
789
+ }