@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.
- package/AudioMixer.js +235 -0
- package/README.md +400 -0
- package/VideoMeetingClient.js +1210 -0
- package/VideoProcessor.js +375 -0
- package/index.js +42 -0
- package/managers/ConnectionManager.js +243 -0
- package/managers/LocalMediaManager.js +1051 -0
- package/managers/MediasoupManager.js +789 -0
- package/managers/RemoteMediaManager.js +972 -0
- package/managers/StatsCollector.js +710 -0
- package/package.json +56 -0
- package/utils/EventEmitter.js +103 -0
- package/utils/Logger.js +114 -0
- package/utils/errors.js +136 -0
|
@@ -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
|
+
}
|