@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,1210 @@
|
|
|
1
|
+
import { EventEmitter } from './utils/EventEmitter.js';
|
|
2
|
+
import { Logger } from './utils/Logger.js';
|
|
3
|
+
import { ConnectionManager } from './managers/ConnectionManager.js';
|
|
4
|
+
import { MediasoupManager } from './managers/MediasoupManager.js';
|
|
5
|
+
import { LocalMediaManager } from './managers/LocalMediaManager.js';
|
|
6
|
+
import { RemoteMediaManager } from './managers/RemoteMediaManager.js';
|
|
7
|
+
import { StateError, RoomError } from './utils/errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main SDK client for video meeting functionality
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const client = new VideoMeetingClient({
|
|
14
|
+
* serverUrl: 'wss://video.example.com',
|
|
15
|
+
* debug: true
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* await client.connect(authToken);
|
|
19
|
+
* await client.joinRoom(roomId);
|
|
20
|
+
* await client.publishCamera();
|
|
21
|
+
*
|
|
22
|
+
* client.on('stream:added', ({ participantId, stream, type }) => {
|
|
23
|
+
* // Handle remote stream
|
|
24
|
+
* });
|
|
25
|
+
*/
|
|
26
|
+
export class VideoMeetingClient extends EventEmitter {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {string} [options.serverUrl] - WebSocket server URL (optional if using joinFromApiResponse)
|
|
30
|
+
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
31
|
+
*/
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
super();
|
|
34
|
+
|
|
35
|
+
this.logger = new Logger('SDK:VideoMeetingClient', options.debug);
|
|
36
|
+
this.state = 'disconnected'; // disconnected, connecting, connected, waiting-room, in-meeting
|
|
37
|
+
this.currentRoomId = null;
|
|
38
|
+
this.joinData = null; // Store video.join() response data
|
|
39
|
+
this.debug = options.debug;
|
|
40
|
+
this.isGuest = false;
|
|
41
|
+
this.inWaitingRoom = false;
|
|
42
|
+
|
|
43
|
+
// Managers will be initialized when we have connection info
|
|
44
|
+
this.connection = null;
|
|
45
|
+
this.mediasoup = null;
|
|
46
|
+
this.localMedia = null;
|
|
47
|
+
this.remoteMedia = null;
|
|
48
|
+
|
|
49
|
+
// If serverUrl provided, initialize managers now (old behavior)
|
|
50
|
+
if (options.serverUrl) {
|
|
51
|
+
this._initializeManagers(options.serverUrl);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.logger.info('VideoMeetingClient initialized');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Initialize managers with server URL
|
|
59
|
+
* @private
|
|
60
|
+
*/
|
|
61
|
+
_initializeManagers(serverUrl) {
|
|
62
|
+
this.connection = new ConnectionManager({
|
|
63
|
+
serverUrl,
|
|
64
|
+
debug: this.debug,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.mediasoup = new MediasoupManager({
|
|
68
|
+
connection: this.connection,
|
|
69
|
+
debug: this.debug,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.localMedia = new LocalMediaManager({
|
|
73
|
+
mediasoup: this.mediasoup,
|
|
74
|
+
debug: this.debug,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.remoteMedia = new RemoteMediaManager({
|
|
78
|
+
mediasoup: this.mediasoup,
|
|
79
|
+
connection: this.connection,
|
|
80
|
+
videoClient: this, // Pass reference to access joinData
|
|
81
|
+
debug: this.debug,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Proxy manager events to SDK events
|
|
85
|
+
this._setupEventProxies();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Setup event proxies from managers to SDK
|
|
90
|
+
* @private
|
|
91
|
+
*/
|
|
92
|
+
_setupEventProxies() {
|
|
93
|
+
// Connection events
|
|
94
|
+
this.connection.on('connected', () => {
|
|
95
|
+
this._setState('connected');
|
|
96
|
+
this.emit('connected');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.connection.on('disconnected', (data) => {
|
|
100
|
+
this._setState('disconnected');
|
|
101
|
+
this.emit('disconnected', data);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.connection.on('error', (error) => {
|
|
105
|
+
this.emit('error', error);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Local media events
|
|
109
|
+
this.localMedia.on('stream:added', (data) => {
|
|
110
|
+
this.emit('local-stream:added', data);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
this.localMedia.on('stream:removed', (data) => {
|
|
114
|
+
this.emit('local-stream:removed', data);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.localMedia.on('device:changed', (data) => {
|
|
118
|
+
this.emit('device:changed', data);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Remote media events
|
|
122
|
+
this.remoteMedia.on('participant:added', (data) => {
|
|
123
|
+
this.emit('participant:joined', data);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.remoteMedia.on('participant:removed', (data) => {
|
|
127
|
+
this.emit('participant:left', data);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this.remoteMedia.on('participant:updated', (data) => {
|
|
131
|
+
this.emit('participant:updated', data);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.remoteMedia.on('stream:added', (data) => {
|
|
135
|
+
this.emit('stream:added', data);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
this.remoteMedia.on('stream:removed', (data) => {
|
|
139
|
+
this.emit('stream:removed', data);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Stream consuming events (for retry logic and error handling)
|
|
143
|
+
this.remoteMedia.on('stream:consuming', (data) => {
|
|
144
|
+
this.emit('stream:consuming', data);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
this.remoteMedia.on('stream:consume-failed', (data) => {
|
|
148
|
+
this.emit('stream:consume-failed', data);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Volume control events
|
|
152
|
+
this.remoteMedia.on('participant:volume-changed', (data) => {
|
|
153
|
+
this.emit('participant:volume-changed', data);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Setup room/participant event listeners
|
|
159
|
+
* @private
|
|
160
|
+
*/
|
|
161
|
+
_setupRoomEventListeners() {
|
|
162
|
+
// Room events
|
|
163
|
+
this.connection.onServerEvent('room.closed', (data) => {
|
|
164
|
+
this.logger.info('Room closed', data);
|
|
165
|
+
this._setState('disconnected');
|
|
166
|
+
this.emit('room:closed', data);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
this.connection.onServerEvent('room.update', (data) => {
|
|
170
|
+
this.logger.info('Room updated', data);
|
|
171
|
+
this.emit('room:updated', data);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Participant events
|
|
175
|
+
this.connection.onServerEvent('participant.all', (data) => {
|
|
176
|
+
this.logger.info('All participants', data);
|
|
177
|
+
this.emit('participants:list', data);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
this.connection.onServerEvent('participant.leave', (data) => {
|
|
181
|
+
this.logger.info('Participant left', data);
|
|
182
|
+
this.emit('participant:left', data);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
this.connection.onServerEvent('participant.remove', (data) => {
|
|
186
|
+
this.logger.info('Participant removed', data);
|
|
187
|
+
this.emit('participant:removed', data);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
this.connection.onServerEvent('participant.update', (data) => {
|
|
191
|
+
this.logger.info('Participant updated', data);
|
|
192
|
+
this.emit('participant:updated', data);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Producer close event (for quality adaptation / reconnect)
|
|
196
|
+
this.connection.onServerEvent('participant.producer.close', async (data) => {
|
|
197
|
+
this.logger.info('Producer close requested', data);
|
|
198
|
+
await this._handleProducerCloseRequest(data);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Waiting room events
|
|
202
|
+
this.connection.onServerEvent('room.waitingRoom.admit', (data) => {
|
|
203
|
+
this.logger.info('Admitted from waiting room', data);
|
|
204
|
+
this.inWaitingRoom = false;
|
|
205
|
+
this._setState('in-meeting');
|
|
206
|
+
this.emit('waitingRoom:admitted', data);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Keepalive events
|
|
210
|
+
this.connection.onServerEvent('keepalive.send', (data) => {
|
|
211
|
+
this.emit('keepalive:received', data);
|
|
212
|
+
this.connection.emit('keepalive.ack', data);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
this.connection.onServerEvent('keepalive.alert', (data) => {
|
|
216
|
+
this.emit('keepalive:alert', data);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Connect to the server
|
|
222
|
+
* @param {Object|string} auth - Authentication token or auth object
|
|
223
|
+
* @returns {Promise<void>}
|
|
224
|
+
*/
|
|
225
|
+
async connect(auth = {}) {
|
|
226
|
+
if (this.state !== 'disconnected') {
|
|
227
|
+
throw new StateError(
|
|
228
|
+
'Cannot connect: already connected or connecting',
|
|
229
|
+
this.state,
|
|
230
|
+
'disconnected'
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.logger.info('Connecting to server...');
|
|
235
|
+
this._setState('connecting');
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Normalize auth to object format
|
|
239
|
+
const authObj = typeof auth === 'string' ? { token: auth } : auth;
|
|
240
|
+
|
|
241
|
+
// Connect to server
|
|
242
|
+
await this.connection.connect(authObj);
|
|
243
|
+
|
|
244
|
+
// Load mediasoup device
|
|
245
|
+
await this.mediasoup.loadDevice();
|
|
246
|
+
|
|
247
|
+
this.logger.info('Connected successfully');
|
|
248
|
+
this._setState('connected');
|
|
249
|
+
|
|
250
|
+
} catch (error) {
|
|
251
|
+
this.logger.error('Connection failed:', error);
|
|
252
|
+
this._setState('disconnected');
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Disconnect from server
|
|
259
|
+
* @returns {Promise<void>}
|
|
260
|
+
*/
|
|
261
|
+
async disconnect() {
|
|
262
|
+
this.logger.info('Disconnecting...');
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// Leave room if in one
|
|
266
|
+
if (this.state === 'in-room') {
|
|
267
|
+
await this.leaveRoom();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Clean up managers
|
|
271
|
+
await this.localMedia.cleanup();
|
|
272
|
+
await this.remoteMedia.cleanup();
|
|
273
|
+
await this.mediasoup.cleanup();
|
|
274
|
+
|
|
275
|
+
// Disconnect socket
|
|
276
|
+
await this.connection.disconnect();
|
|
277
|
+
|
|
278
|
+
this._setState('disconnected');
|
|
279
|
+
this.logger.info('Disconnected successfully');
|
|
280
|
+
|
|
281
|
+
} catch (error) {
|
|
282
|
+
this.logger.error('Disconnect error:', error);
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Join meeting directly from API response (api.video.joinRoom)
|
|
289
|
+
* This method extracts server info, connects, and determines if user needs to wait
|
|
290
|
+
*
|
|
291
|
+
* @param {Object} joinResponse - Response from api.video.joinRoom(room, password, email)
|
|
292
|
+
* @param {Object} options - Additional options
|
|
293
|
+
* @param {boolean} options.enterWaitingRoom - For guests, enter waiting room (default: auto-detect)
|
|
294
|
+
* @returns {Promise<Object>} Join data
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* const joinResponse = await api.video.joinRoom('room-123', 'password', 'user@example.com');
|
|
298
|
+
* await client.joinFromApiResponse(joinResponse);
|
|
299
|
+
*
|
|
300
|
+
* // For guests in waiting room:
|
|
301
|
+
* client.on('waitingRoom:entered', () => showWaitingRoomUI());
|
|
302
|
+
* client.on('waitingRoom:admitted', () => showMeetingUI());
|
|
303
|
+
*/
|
|
304
|
+
async joinFromApiResponse(joinResponse, options = {}) {
|
|
305
|
+
this.logger.info('Joining from API response');
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
// Extract data from API response
|
|
309
|
+
const { server, authorization, videoRoom, participant } = joinResponse;
|
|
310
|
+
|
|
311
|
+
if (!server?.url || !server?.socketPort) {
|
|
312
|
+
throw new Error('Invalid join response: missing server info');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Store join data for later use
|
|
316
|
+
this.joinData = {
|
|
317
|
+
videoRoom,
|
|
318
|
+
participant,
|
|
319
|
+
server,
|
|
320
|
+
authorization
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Detect if this is a guest (no host privileges)
|
|
324
|
+
this.isGuest = !participant.isHost && !participant.isModerator;
|
|
325
|
+
|
|
326
|
+
// Build server URL - use https:// (not wss://) to allow Socket.io to handle upgrade
|
|
327
|
+
// This ensures cookies are sent during the HTTP handshake for authentication
|
|
328
|
+
const serverUrl = `https://${server.url}:${server.socketPort}`;
|
|
329
|
+
|
|
330
|
+
this.logger.info('Connecting to video server:', serverUrl);
|
|
331
|
+
|
|
332
|
+
// Initialize managers if not already done
|
|
333
|
+
if (!this.connection) {
|
|
334
|
+
this._initializeManagers(serverUrl);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Connect with authorization (matches old videoCreateSocket.js format)
|
|
338
|
+
// NOTE: Using cookie-based auth, so only send namespace, roomId, participantId
|
|
339
|
+
// The HTTP cookie set by api.video.joinRoom() handles authentication
|
|
340
|
+
this._setState('connecting');
|
|
341
|
+
|
|
342
|
+
const authData = {
|
|
343
|
+
namespace: authorization.namespace,
|
|
344
|
+
roomId: videoRoom.id,
|
|
345
|
+
participantId: participant.id
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
this.logger.info('Connecting with auth (cookie-based):', authData);
|
|
349
|
+
await this.connection.connect(authData);
|
|
350
|
+
|
|
351
|
+
this._setState('connected');
|
|
352
|
+
|
|
353
|
+
// Setup remote media listeners and room event listeners
|
|
354
|
+
this.logger.info('Setting up event listeners');
|
|
355
|
+
this.remoteMedia._setupServerListeners();
|
|
356
|
+
this._setupRoomEventListeners();
|
|
357
|
+
|
|
358
|
+
// ALWAYS emit room.waitingRoom first to initialize room on video server
|
|
359
|
+
// The server requires this event to set up the room before any joins
|
|
360
|
+
this.logger.info('Emitting room.waitingRoom to initialize room on server');
|
|
361
|
+
this.connection.emit('room.waitingRoom', {});
|
|
362
|
+
|
|
363
|
+
// Enter waiting-room state for everyone (hosts and guests)
|
|
364
|
+
// This allows users to set up their devices while room initializes
|
|
365
|
+
this.inWaitingRoom = true;
|
|
366
|
+
this._setState('waiting-room');
|
|
367
|
+
|
|
368
|
+
this.logger.info('Entering waiting room - waiting for room to be ready');
|
|
369
|
+
|
|
370
|
+
this.emit('waitingRoom:entered', {
|
|
371
|
+
roomId: videoRoom.id,
|
|
372
|
+
participant,
|
|
373
|
+
isHost: participant.isHost,
|
|
374
|
+
canJoinImmediately: !this.isGuest // Hosts can join once ready, guests need admission
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Listen for media.routerCapabilities to know when room is ready
|
|
378
|
+
this.connection.onServerEvent('media.routerCapabilities', (data) => {
|
|
379
|
+
this.logger.info('Room is ready - received media.routerCapabilities');
|
|
380
|
+
this.emit('waitingRoom:ready', {
|
|
381
|
+
roomId: videoRoom.id,
|
|
382
|
+
participant
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Return - user must explicitly call joinMeeting() when ready
|
|
387
|
+
// For hosts: after they click "Join Meeting" button (once room is ready)
|
|
388
|
+
// For guests: after host admits them (room.waitingRoom.admit event)
|
|
389
|
+
return {
|
|
390
|
+
...this.joinData,
|
|
391
|
+
inWaitingRoom: true
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
} catch (error) {
|
|
395
|
+
this.logger.error('Failed to join from API response:', error);
|
|
396
|
+
this._setState('disconnected');
|
|
397
|
+
throw new RoomError(`Failed to join meeting: ${error.message}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Public method to join meeting from waiting room
|
|
403
|
+
* Call this after user clicks "Join Meeting" button
|
|
404
|
+
*/
|
|
405
|
+
async joinMeeting() {
|
|
406
|
+
if (this.state !== 'waiting-room') {
|
|
407
|
+
throw new StateError('Must be in waiting room to join meeting');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
await this._joinMeeting();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Internal method to join meeting (after connection established)
|
|
415
|
+
* @private
|
|
416
|
+
*/
|
|
417
|
+
async _joinMeeting() {
|
|
418
|
+
this.logger.info('Joining meeting:', this.joinData.videoRoom.friendlyName);
|
|
419
|
+
this._setState('joining');
|
|
420
|
+
|
|
421
|
+
// Setup transport listener BEFORE emitting room.join (but handler will wait for device)
|
|
422
|
+
this._setupMediaEventListeners();
|
|
423
|
+
|
|
424
|
+
// Send room.join event to video server (emit, not request - no callback)
|
|
425
|
+
this.logger.info('Emitting room.join event');
|
|
426
|
+
this.connection.emit('room.join', {});
|
|
427
|
+
|
|
428
|
+
// Load mediasoup device (will wait for media.routerCapabilities event)
|
|
429
|
+
this.logger.info('Waiting for media.routerCapabilities...');
|
|
430
|
+
await this.mediasoup.loadDevice();
|
|
431
|
+
this.logger.info('Device loaded successfully');
|
|
432
|
+
|
|
433
|
+
// Wait for media.transports event (listener will create transports now that device is loaded)
|
|
434
|
+
this.logger.info('Waiting for media.transports...');
|
|
435
|
+
await this._waitForTransports();
|
|
436
|
+
|
|
437
|
+
// Process any producers that arrived before transports were ready
|
|
438
|
+
this.logger.info('Processing pending producers...');
|
|
439
|
+
await this.remoteMedia.processPendingProducers();
|
|
440
|
+
|
|
441
|
+
this.currentRoomId = this.joinData.videoRoom.id;
|
|
442
|
+
this._setState('in-meeting');
|
|
443
|
+
|
|
444
|
+
this.logger.info('Successfully joined meeting');
|
|
445
|
+
this.emit('meeting:joined', {
|
|
446
|
+
roomId: this.joinData.videoRoom.id,
|
|
447
|
+
joinData: this.joinData
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Join a meeting room (legacy method - requires manual connect first)
|
|
453
|
+
* @param {string} roomId - Room ID to join
|
|
454
|
+
* @param {Object} options - Join options
|
|
455
|
+
* @returns {Promise<Object>} Room data
|
|
456
|
+
*/
|
|
457
|
+
async joinRoom(roomId, options = {}) {
|
|
458
|
+
if (this.state !== 'connected') {
|
|
459
|
+
throw new StateError(
|
|
460
|
+
'Cannot join room: not connected to server',
|
|
461
|
+
this.state,
|
|
462
|
+
'connected'
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
this.logger.info('Joining room:', roomId);
|
|
467
|
+
this._setState('joining');
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
// Send join request to server
|
|
471
|
+
const response = await this.connection.request('room.join', {
|
|
472
|
+
roomId,
|
|
473
|
+
...options,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
this.currentRoomId = roomId;
|
|
477
|
+
this._setState('in-room');
|
|
478
|
+
|
|
479
|
+
this.logger.info('Joined room successfully');
|
|
480
|
+
this.emit('room:joined', { roomId, data: response });
|
|
481
|
+
|
|
482
|
+
return response;
|
|
483
|
+
|
|
484
|
+
} catch (error) {
|
|
485
|
+
this.logger.error('Failed to join room:', error);
|
|
486
|
+
this._setState('connected');
|
|
487
|
+
throw new RoomError(`Failed to join room: ${error.message}`, roomId);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Leave the current room
|
|
493
|
+
* @returns {Promise<void>}
|
|
494
|
+
*/
|
|
495
|
+
async leaveRoom() {
|
|
496
|
+
if (this.state !== 'in-room') {
|
|
497
|
+
throw new StateError(
|
|
498
|
+
'Cannot leave room: not in a room',
|
|
499
|
+
this.state,
|
|
500
|
+
'in-room'
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
this.logger.info('Leaving room:', this.currentRoomId);
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
// Stop all local media
|
|
508
|
+
await this.localMedia.cleanup();
|
|
509
|
+
|
|
510
|
+
// Notify server
|
|
511
|
+
await this.connection.request('room.leave', {
|
|
512
|
+
roomId: this.currentRoomId,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Clean up remote media
|
|
516
|
+
await this.remoteMedia.cleanup();
|
|
517
|
+
|
|
518
|
+
const roomId = this.currentRoomId;
|
|
519
|
+
this.currentRoomId = null;
|
|
520
|
+
this._setState('connected');
|
|
521
|
+
|
|
522
|
+
this.logger.info('Left room successfully');
|
|
523
|
+
this.emit('room:left', { roomId });
|
|
524
|
+
|
|
525
|
+
} catch (error) {
|
|
526
|
+
this.logger.error('Failed to leave room:', error);
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ========== Local Media Methods ==========
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Publish camera stream
|
|
535
|
+
* @param {Object} options - Camera options
|
|
536
|
+
* @param {string} options.deviceId - Camera device ID
|
|
537
|
+
* @param {string} options.resolution - Resolution (480p, 720p, 1080p)
|
|
538
|
+
* @param {number} options.frameRate - Frame rate
|
|
539
|
+
* @returns {Promise<MediaStream>}
|
|
540
|
+
*/
|
|
541
|
+
async publishCamera(options = {}) {
|
|
542
|
+
this._ensureInRoom();
|
|
543
|
+
return await this.localMedia.publishCamera(options);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Publish microphone stream
|
|
548
|
+
* @param {Object} options - Microphone options
|
|
549
|
+
* @param {string} options.deviceId - Microphone device ID
|
|
550
|
+
* @returns {Promise<MediaStream>}
|
|
551
|
+
*/
|
|
552
|
+
async publishMicrophone(options = {}) {
|
|
553
|
+
this._ensureInRoom();
|
|
554
|
+
return await this.localMedia.publishMicrophone(options);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Publish screen share
|
|
559
|
+
* @param {Object} options - Screen share options
|
|
560
|
+
* @param {boolean} options.audio - Include system audio
|
|
561
|
+
* @returns {Promise<MediaStream>}
|
|
562
|
+
*/
|
|
563
|
+
async publishScreenShare(options = {}) {
|
|
564
|
+
this._ensureInRoom();
|
|
565
|
+
return await this.localMedia.publishScreenShare(options);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Stop camera
|
|
570
|
+
* @returns {Promise<void>}
|
|
571
|
+
*/
|
|
572
|
+
async stopCamera() {
|
|
573
|
+
return await this.localMedia.stopCamera();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Stop microphone
|
|
578
|
+
* @returns {Promise<void>}
|
|
579
|
+
*/
|
|
580
|
+
async stopMicrophone() {
|
|
581
|
+
return await this.localMedia.stopMicrophone();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Stop screen share
|
|
586
|
+
* @returns {Promise<void>}
|
|
587
|
+
*/
|
|
588
|
+
async stopScreenShare() {
|
|
589
|
+
return await this.localMedia.stopScreenShare();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Enable screenshare audio (add audio to existing screenshare)
|
|
594
|
+
* @returns {Promise<void>}
|
|
595
|
+
*/
|
|
596
|
+
async enableScreenShareAudio() {
|
|
597
|
+
return await this.localMedia.enableScreenShareAudio();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Disable screenshare audio (remove audio from existing screenshare)
|
|
602
|
+
* @returns {Promise<void>}
|
|
603
|
+
*/
|
|
604
|
+
async disableScreenShareAudio() {
|
|
605
|
+
return await this.localMedia.disableScreenShareAudio();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Mute screenshare audio
|
|
610
|
+
* @returns {Promise<void>}
|
|
611
|
+
*/
|
|
612
|
+
async muteScreenShareAudio() {
|
|
613
|
+
return await this.localMedia.muteScreenShareAudio();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Unmute screenshare audio
|
|
618
|
+
* @returns {Promise<void>}
|
|
619
|
+
*/
|
|
620
|
+
async unmuteScreenShareAudio() {
|
|
621
|
+
return await this.localMedia.unmuteScreenShareAudio();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Toggle screenshare audio mute state
|
|
626
|
+
* @returns {Promise<boolean>} New mute state
|
|
627
|
+
*/
|
|
628
|
+
async toggleScreenShareAudioMute() {
|
|
629
|
+
return await this.localMedia.toggleScreenShareAudioMute();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Change camera device
|
|
634
|
+
* @param {string} deviceId - New camera device ID
|
|
635
|
+
* @returns {Promise<MediaStream>}
|
|
636
|
+
*/
|
|
637
|
+
async changeCamera(deviceId) {
|
|
638
|
+
return await this.localMedia.changeCamera(deviceId);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Change microphone device
|
|
643
|
+
* @param {string} deviceId - New microphone device ID
|
|
644
|
+
* @returns {Promise<MediaStream>}
|
|
645
|
+
*/
|
|
646
|
+
async changeMicrophone(deviceId) {
|
|
647
|
+
return await this.localMedia.changeMicrophone(deviceId);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Update camera background effect
|
|
652
|
+
* @param {Object} options - Background options
|
|
653
|
+
* @param {string} options.type - 'none' | 'blur' | 'image'
|
|
654
|
+
* @param {number} options.blurLevel - Blur level in pixels (default: 8)
|
|
655
|
+
* @param {string} options.imageUrl - Background image URL (for type: 'image')
|
|
656
|
+
* @returns {Promise<MediaStream>}
|
|
657
|
+
*
|
|
658
|
+
* @example
|
|
659
|
+
* // Apply blur
|
|
660
|
+
* await client.updateCameraBackground({ type: 'blur', blurLevel: 8 });
|
|
661
|
+
*
|
|
662
|
+
* // Apply virtual background
|
|
663
|
+
* await client.updateCameraBackground({
|
|
664
|
+
* type: 'image',
|
|
665
|
+
* imageUrl: '/images/backgrounds/office.jpg'
|
|
666
|
+
* });
|
|
667
|
+
*
|
|
668
|
+
* // Remove background effect
|
|
669
|
+
* await client.updateCameraBackground({ type: 'none' });
|
|
670
|
+
*/
|
|
671
|
+
async updateCameraBackground(options) {
|
|
672
|
+
this._ensureInRoom();
|
|
673
|
+
return await this.localMedia.updateCameraBackground(options);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Mute camera
|
|
678
|
+
* @returns {Promise<void>}
|
|
679
|
+
*/
|
|
680
|
+
async muteCamera() {
|
|
681
|
+
return await this.localMedia.muteCamera();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Unmute camera
|
|
686
|
+
* @returns {Promise<void>}
|
|
687
|
+
*/
|
|
688
|
+
async unmuteCamera() {
|
|
689
|
+
return await this.localMedia.unmuteCamera();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Mute microphone
|
|
694
|
+
* @returns {Promise<void>}
|
|
695
|
+
*/
|
|
696
|
+
async muteMicrophone() {
|
|
697
|
+
return await this.localMedia.muteMicrophone();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Unmute microphone
|
|
702
|
+
* @returns {Promise<void>}
|
|
703
|
+
*/
|
|
704
|
+
async unmuteMicrophone() {
|
|
705
|
+
return await this.localMedia.unmuteMicrophone();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ========== Device Management ==========
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Get available media devices
|
|
712
|
+
* @returns {Promise<Object>} Object with cameras, microphones, speakers
|
|
713
|
+
*/
|
|
714
|
+
async getDevices() {
|
|
715
|
+
return await this.localMedia.getDevices();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Get local stream
|
|
720
|
+
* @param {string} type - Stream type (camera, microphone, screenShare)
|
|
721
|
+
* @returns {MediaStream|null}
|
|
722
|
+
*/
|
|
723
|
+
getLocalStream(type) {
|
|
724
|
+
return this.localMedia.getStream(type);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Track a video element for automatic quality adjustment based on size
|
|
729
|
+
* @param {string} participantId - Participant ID
|
|
730
|
+
* @param {HTMLVideoElement} videoElement - Video element to track
|
|
731
|
+
*/
|
|
732
|
+
trackVideoElement(participantId, videoElement) {
|
|
733
|
+
if (!this.remoteMedia) {
|
|
734
|
+
this.logger.warn('RemoteMedia not initialized');
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
this.remoteMedia.trackVideoElement(participantId, videoElement);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Stop tracking a video element
|
|
742
|
+
* @param {string} participantId - Participant ID
|
|
743
|
+
*/
|
|
744
|
+
untrackVideoElement(participantId) {
|
|
745
|
+
if (!this.remoteMedia) {
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
this.remoteMedia.untrackVideoElement(participantId);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Locally mute a remote participant's stream (only for local user, doesn't affect others)
|
|
753
|
+
* @param {string} participantId - Participant ID
|
|
754
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
755
|
+
* @returns {boolean} Success
|
|
756
|
+
*/
|
|
757
|
+
localMuteRemoteStream(participantId, type = 'audio') {
|
|
758
|
+
if (!this.remoteMedia) {
|
|
759
|
+
this.logger.warn('RemoteMedia not initialized');
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
return this.remoteMedia.localMuteRemoteStream(participantId, type);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Locally unmute a remote participant's stream
|
|
767
|
+
* @param {string} participantId - Participant ID
|
|
768
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
769
|
+
* @returns {boolean} Success
|
|
770
|
+
*/
|
|
771
|
+
localUnmuteRemoteStream(participantId, type = 'audio') {
|
|
772
|
+
if (!this.remoteMedia) {
|
|
773
|
+
this.logger.warn('RemoteMedia not initialized');
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
return this.remoteMedia.localUnmuteRemoteStream(participantId, type);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Toggle local mute state for a remote participant's stream
|
|
781
|
+
* @param {string} participantId - Participant ID
|
|
782
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
783
|
+
* @returns {boolean} New mute state
|
|
784
|
+
*/
|
|
785
|
+
toggleLocalMuteRemoteStream(participantId, type = 'audio') {
|
|
786
|
+
if (!this.remoteMedia) {
|
|
787
|
+
this.logger.warn('RemoteMedia not initialized');
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
return this.remoteMedia.toggleLocalMuteRemoteStream(participantId, type);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Check if a remote stream is locally muted
|
|
795
|
+
* @param {string} participantId - Participant ID
|
|
796
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
797
|
+
* @returns {boolean} Mute state
|
|
798
|
+
*/
|
|
799
|
+
isRemoteStreamLocallyMuted(participantId, type = 'audio') {
|
|
800
|
+
if (!this.remoteMedia) {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
return this.remoteMedia.isLocallyMuted(participantId, type);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Set callback for stats updates (for local UI display)
|
|
808
|
+
* @param {Function} callback - Callback function that receives stats data
|
|
809
|
+
*/
|
|
810
|
+
setStatsCallback(callback) {
|
|
811
|
+
if (this.mediasoup && this.mediasoup.statsCollector) {
|
|
812
|
+
this.mediasoup.statsCollector.setStatsCallback(callback);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ========== Remote Participant Methods ==========
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Get participant by ID
|
|
820
|
+
* @param {string} participantId - Participant ID
|
|
821
|
+
* @returns {Object|null}
|
|
822
|
+
*/
|
|
823
|
+
getParticipant(participantId) {
|
|
824
|
+
return this.remoteMedia.getParticipant(participantId);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Get all participants
|
|
829
|
+
* @returns {Array<Object>}
|
|
830
|
+
*/
|
|
831
|
+
getAllParticipants() {
|
|
832
|
+
return this.remoteMedia.getAllParticipants();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Get remote stream
|
|
837
|
+
* @param {string} participantId - Participant ID
|
|
838
|
+
* @param {string} type - Stream type (video, audio, screenShare)
|
|
839
|
+
* @returns {MediaStream|null}
|
|
840
|
+
*/
|
|
841
|
+
getRemoteStream(participantId, type) {
|
|
842
|
+
return this.remoteMedia.getStream(participantId, type);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Get all streams for a participant
|
|
847
|
+
* @param {string} participantId - Participant ID
|
|
848
|
+
* @returns {Object|null}
|
|
849
|
+
*/
|
|
850
|
+
getRemoteStreams(participantId) {
|
|
851
|
+
return this.remoteMedia.getStreams(participantId);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ========== Logging Control ==========
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Set verbose logging for specific categories
|
|
858
|
+
* @param {string} category - Category name (stats, keepalive, performance) or 'all'
|
|
859
|
+
* @param {boolean} enabled - Enable or disable this category
|
|
860
|
+
*/
|
|
861
|
+
setVerboseLogging(category, enabled) {
|
|
862
|
+
if (category === 'all') {
|
|
863
|
+
Logger.setVerbose(enabled);
|
|
864
|
+
} else {
|
|
865
|
+
Logger.setFilter(category, enabled);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ========== State Getters ==========
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Get current state
|
|
873
|
+
* @returns {string}
|
|
874
|
+
*/
|
|
875
|
+
getState() {
|
|
876
|
+
return this.state;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Check if connected to server
|
|
881
|
+
* @returns {boolean}
|
|
882
|
+
*/
|
|
883
|
+
isConnected() {
|
|
884
|
+
return this.state !== 'disconnected' && this.connection.connected;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Check if in a room
|
|
889
|
+
* @returns {boolean}
|
|
890
|
+
*/
|
|
891
|
+
isInRoom() {
|
|
892
|
+
return this.state === 'in-room';
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Get current room ID
|
|
897
|
+
* @returns {string|null}
|
|
898
|
+
*/
|
|
899
|
+
getRoomId() {
|
|
900
|
+
return this.currentRoomId;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Check if camera is active
|
|
905
|
+
* @returns {boolean}
|
|
906
|
+
*/
|
|
907
|
+
isCameraActive() {
|
|
908
|
+
return this.localMedia.isCameraActive;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Check if microphone is active
|
|
913
|
+
* @returns {boolean}
|
|
914
|
+
*/
|
|
915
|
+
isMicrophoneActive() {
|
|
916
|
+
return this.localMedia.isMicrophoneActive;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Check if screen share is active
|
|
921
|
+
* @returns {boolean}
|
|
922
|
+
*/
|
|
923
|
+
isScreenShareActive() {
|
|
924
|
+
return this.localMedia.isScreenShareActive;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Check if camera is muted
|
|
929
|
+
* @returns {boolean}
|
|
930
|
+
*/
|
|
931
|
+
isCameraMuted() {
|
|
932
|
+
return this.localMedia.isCameraMuted;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Check if microphone is muted
|
|
937
|
+
* @returns {boolean}
|
|
938
|
+
*/
|
|
939
|
+
isMicrophoneMuted() {
|
|
940
|
+
return this.localMedia.isMicrophoneMuted;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Get participant count
|
|
945
|
+
* @returns {number}
|
|
946
|
+
*/
|
|
947
|
+
getParticipantCount() {
|
|
948
|
+
return this.remoteMedia.participantCount;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Get video room info from join data
|
|
953
|
+
* @returns {Object|null}
|
|
954
|
+
*/
|
|
955
|
+
getVideoRoom() {
|
|
956
|
+
return this.joinData?.videoRoom || null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Get current participant info from join data
|
|
961
|
+
* @returns {Object|null}
|
|
962
|
+
*/
|
|
963
|
+
getCurrentParticipant() {
|
|
964
|
+
return this.joinData?.participant || null;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Get server info from join data
|
|
969
|
+
* @returns {Object|null}
|
|
970
|
+
*/
|
|
971
|
+
getServerInfo() {
|
|
972
|
+
return this.joinData?.server || null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Get full join data (videoRoom, participant, server, authorization)
|
|
977
|
+
* @returns {Object|null}
|
|
978
|
+
*/
|
|
979
|
+
getJoinData() {
|
|
980
|
+
return this.joinData;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ========== Private Helpers ==========
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Set SDK state and emit event
|
|
987
|
+
* @private
|
|
988
|
+
*/
|
|
989
|
+
_setState(newState) {
|
|
990
|
+
const oldState = this.state;
|
|
991
|
+
this.state = newState;
|
|
992
|
+
this.logger.info(`State changed: ${oldState} -> ${newState}`);
|
|
993
|
+
this.emit('state:changed', { from: oldState, to: newState });
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Ensure we're in a room, throw if not
|
|
998
|
+
* @private
|
|
999
|
+
*/
|
|
1000
|
+
_ensureInRoom() {
|
|
1001
|
+
if (this.state !== 'in-room' && this.state !== 'in-meeting') {
|
|
1002
|
+
throw new StateError(
|
|
1003
|
+
'Not in a room. Call joinRoom() first.',
|
|
1004
|
+
this.state,
|
|
1005
|
+
'in-room or in-meeting'
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Setup listeners for media events from server
|
|
1012
|
+
* @private
|
|
1013
|
+
*/
|
|
1014
|
+
_setupMediaEventListeners() {
|
|
1015
|
+
this.logger.info('Setting up media event listeners');
|
|
1016
|
+
|
|
1017
|
+
// Listen for media.transports event
|
|
1018
|
+
this._transportsPromise = new Promise((resolve) => {
|
|
1019
|
+
this._transportsResolve = resolve;
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
this.connection.onServerEvent('media.transports', async (data) => {
|
|
1023
|
+
this.logger.info('Received media.transports', {
|
|
1024
|
+
hasSend: !!data?.sendTransportOptions,
|
|
1025
|
+
hasRecv: !!data?.recvTransportOptions
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
const { sendTransportOptions, recvTransportOptions } = data;
|
|
1029
|
+
|
|
1030
|
+
// Store transport data for later if device not loaded yet
|
|
1031
|
+
this._pendingTransportData = data;
|
|
1032
|
+
|
|
1033
|
+
// Wait for device to be loaded before creating transports
|
|
1034
|
+
if (!this.mediasoup.device.loaded) {
|
|
1035
|
+
this.logger.warn('Device not loaded yet, storing transport data and waiting...');
|
|
1036
|
+
|
|
1037
|
+
// Poll for device to be loaded (with timeout)
|
|
1038
|
+
let attempts = 0;
|
|
1039
|
+
while (!this.mediasoup.device.loaded && attempts < 50) {
|
|
1040
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1041
|
+
attempts++;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (!this.mediasoup.device.loaded) {
|
|
1045
|
+
this.logger.error('Device still not loaded after 5 seconds');
|
|
1046
|
+
if (this._transportsResolve) {
|
|
1047
|
+
this._transportsResolve();
|
|
1048
|
+
}
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
this.logger.info('Device now loaded, proceeding with transport creation');
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Create transports
|
|
1055
|
+
try {
|
|
1056
|
+
if (sendTransportOptions) {
|
|
1057
|
+
this.mediasoup.sendTransport = this.mediasoup.device.createSendTransport(sendTransportOptions);
|
|
1058
|
+
this.mediasoup._setupSendTransportListeners(); // Not async - just sets up event listeners
|
|
1059
|
+
this.logger.info('Send transport created');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (recvTransportOptions) {
|
|
1063
|
+
this.mediasoup.recvTransport = this.mediasoup.device.createRecvTransport(recvTransportOptions);
|
|
1064
|
+
this.mediasoup._setupRecvTransportListeners(); // Not async - just sets up event listeners
|
|
1065
|
+
this.logger.info('Receive transport created');
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Resolve the promise
|
|
1069
|
+
if (this._transportsResolve) {
|
|
1070
|
+
this._transportsResolve();
|
|
1071
|
+
}
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
this.logger.error('Failed to create transports:', error);
|
|
1074
|
+
if (this._transportsResolve) {
|
|
1075
|
+
this._transportsResolve();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Wait for media.transports event from server
|
|
1083
|
+
* @private
|
|
1084
|
+
*/
|
|
1085
|
+
async _waitForTransports() {
|
|
1086
|
+
const timeout = setTimeout(() => {
|
|
1087
|
+
throw new Error('Timeout waiting for media.transports');
|
|
1088
|
+
}, 10000);
|
|
1089
|
+
|
|
1090
|
+
await this._transportsPromise;
|
|
1091
|
+
clearTimeout(timeout);
|
|
1092
|
+
this.logger.info('Transports ready');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Handle server request to close and reconnect producer
|
|
1097
|
+
* This happens during quality adaptation
|
|
1098
|
+
* @private
|
|
1099
|
+
*/
|
|
1100
|
+
async _handleProducerCloseRequest(data) {
|
|
1101
|
+
const { producer } = data;
|
|
1102
|
+
const { type, reconnect } = producer;
|
|
1103
|
+
|
|
1104
|
+
this.logger.info('Handling producer close request:', { type, reconnect });
|
|
1105
|
+
|
|
1106
|
+
if (!type) {
|
|
1107
|
+
this.logger.error('Producer close request missing type');
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Get the current stream for this producer before closing
|
|
1112
|
+
const currentStream = this.localMedia.streams[type === 'video' ? 'camera' : type];
|
|
1113
|
+
const currentProducer = this.localMedia.producers[type];
|
|
1114
|
+
|
|
1115
|
+
if (!currentProducer) {
|
|
1116
|
+
this.logger.warn('No producer found to close:', type);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Close the producer on mediasoup
|
|
1121
|
+
await this.mediasoup.closeProducer(type);
|
|
1122
|
+
|
|
1123
|
+
// If reconnect flag is true, republish with the same stream
|
|
1124
|
+
if (reconnect && currentStream) {
|
|
1125
|
+
this.logger.info('Reconnecting producer:', type);
|
|
1126
|
+
|
|
1127
|
+
try {
|
|
1128
|
+
// Clone the stream to avoid issues with the old one
|
|
1129
|
+
const clonedStream = currentStream.clone();
|
|
1130
|
+
|
|
1131
|
+
// Republish based on type
|
|
1132
|
+
if (type === 'video') {
|
|
1133
|
+
// Stop the old stream tracks
|
|
1134
|
+
currentStream.getTracks().forEach(track => track.stop());
|
|
1135
|
+
|
|
1136
|
+
// Start camera with the cloned stream
|
|
1137
|
+
await this.localMedia.startCamera({
|
|
1138
|
+
existingStream: clonedStream,
|
|
1139
|
+
// Preserve current background if any
|
|
1140
|
+
background: this.localMedia.currentBackgroundOptions
|
|
1141
|
+
});
|
|
1142
|
+
} else if (type === 'audio') {
|
|
1143
|
+
// Stop the old stream tracks
|
|
1144
|
+
currentStream.getTracks().forEach(track => track.stop());
|
|
1145
|
+
|
|
1146
|
+
// Start microphone with the cloned stream
|
|
1147
|
+
await this.localMedia.startMicrophone({
|
|
1148
|
+
existingStream: clonedStream
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
this.logger.info('Producer reconnected successfully:', type);
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
this.logger.error('Failed to reconnect producer:', type, error);
|
|
1155
|
+
// Emit error event so UI can handle it
|
|
1156
|
+
this.emit('producer:reconnect:failed', { type, error });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Set volume for a specific participant (0.0 to 1.0)
|
|
1163
|
+
* Works seamlessly with both audio elements (< 30 participants) and audio mixer (30+ participants)
|
|
1164
|
+
* @param {string} participantId - Participant ID
|
|
1165
|
+
* @param {number} volume - Volume level (0.0 to 1.0)
|
|
1166
|
+
* @returns {boolean} Success status
|
|
1167
|
+
*/
|
|
1168
|
+
setParticipantVolume(participantId, volume) {
|
|
1169
|
+
return this.remoteMedia.setParticipantVolume(participantId, volume);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Get volume for a specific participant
|
|
1174
|
+
* @param {string} participantId - Participant ID
|
|
1175
|
+
* @returns {number} Volume level (0.0 to 1.0)
|
|
1176
|
+
*/
|
|
1177
|
+
getParticipantVolume(participantId) {
|
|
1178
|
+
return this.remoteMedia.getParticipantVolume(participantId);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Check if audio mixer is currently enabled
|
|
1183
|
+
* Audio mixer is automatically enabled when participant count >= 30
|
|
1184
|
+
* @returns {boolean}
|
|
1185
|
+
*/
|
|
1186
|
+
isAudioMixerEnabled() {
|
|
1187
|
+
return this.remoteMedia.isAudioMixerEnabled();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Retry consuming a failed producer stream
|
|
1192
|
+
* Use this when a stream:consume-failed event is received
|
|
1193
|
+
* @param {string} producerId - Producer ID to retry
|
|
1194
|
+
* @param {string} participantId - Participant ID
|
|
1195
|
+
* @returns {Promise<boolean>} Success status
|
|
1196
|
+
*
|
|
1197
|
+
* @example
|
|
1198
|
+
* client.on('stream:consume-failed', async ({ producerId, participantId }) => {
|
|
1199
|
+
* console.warn('Stream failed to load, retrying...');
|
|
1200
|
+
* await client.retryConsumeStream(producerId, participantId);
|
|
1201
|
+
* });
|
|
1202
|
+
*/
|
|
1203
|
+
async retryConsumeStream(producerId, participantId) {
|
|
1204
|
+
if (!this.remoteMedia) {
|
|
1205
|
+
this.logger.warn('RemoteMedia not initialized');
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
return await this.remoteMedia.retryConsumeProducer(producerId, participantId);
|
|
1209
|
+
}
|
|
1210
|
+
}
|