@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,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
+ }