@unboundcx/video-sdk-client 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,12 @@
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';
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 { QualityMonitor } from "./managers/QualityMonitor.js";
8
+ import { ConnectionHealthMonitor } from "./managers/ConnectionHealthMonitor.js";
9
+ import { StateError, RoomError } from "./utils/errors.js";
8
10
 
9
11
  /**
10
12
  * Main SDK client for video meeting functionality
@@ -24,1187 +26,1639 @@ import { StateError, RoomError } from './utils/errors.js';
24
26
  * });
25
27
  */
26
28
  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
- }
29
+ /**
30
+ * @param {Object} options
31
+ * @param {string} [options.serverUrl] - WebSocket server URL (optional if using joinFromApiResponse)
32
+ * @param {boolean} [options.debug=false] - Enable debug logging
33
+ * @param {Object} [options.sdk] - UnboundSDK instance, injected by
34
+ * `sdk.video.createMeetingClient()`. Required for transparent reassignment
35
+ * recovery (calls `sdk.video.joinRoom()`) and for `endSession()` to invoke
36
+ * `sdk.video.endSession()`. Without it, consumers must handle both paths
37
+ * themselves.
38
+ */
39
+ constructor(options = {}) {
40
+ super();
41
+
42
+ this.logger = new Logger("SDK:VideoMeetingClient", options.debug);
43
+ this.state = "disconnected"; // disconnected, connecting, connected, waiting-room, in-meeting
44
+ this.currentRoomId = null;
45
+ this.joinData = null; // Store video.join() response data
46
+ this.debug = options.debug;
47
+ this.isGuest = false;
48
+ this.inWaitingRoom = false;
49
+ this.sdk = options.sdk || null;
50
+
51
+ // Reassignment recovery state. Attempt window is 60s; cap at 3 attempts.
52
+ // Backoff schedule: 500ms, 2s, 5s. Counter resets once a connection has
53
+ // stayed up for >60s (see _onReassignmentStableConnect).
54
+ this._reassignmentAttempts = []; // timestamps (Date.now()) of recent attempts
55
+ this._reassignmentInFlight = false;
56
+ this._reassignmentStableTimer = null;
57
+ this._lastJoinArgs = null; // args used by consumer to call sdk.video.joinRoom()
58
+
59
+ // Managers will be initialized when we have connection info
60
+ this.connection = null;
61
+ this.mediasoup = null;
62
+ this.localMedia = null;
63
+ this.remoteMedia = null;
64
+
65
+ // If serverUrl provided, initialize managers now (old behavior)
66
+ if (options.serverUrl) {
67
+ this._initializeManagers(options.serverUrl);
68
+ }
69
+
70
+ this.logger.info("VideoMeetingClient initialized", { hasSdk: !!this.sdk });
71
+ }
72
+
73
+ /**
74
+ * Initialize managers with server URL
75
+ * @private
76
+ * @param {string} serverUrl
77
+ * @param {string} [namespace] - Socket.IO namespace path, e.g. `/video`
78
+ */
79
+ _initializeManagers(serverUrl, namespace = null) {
80
+ this.connection = new ConnectionManager({
81
+ serverUrl,
82
+ namespace,
83
+ debug: this.debug,
84
+ });
85
+
86
+ this.mediasoup = new MediasoupManager({
87
+ connection: this.connection,
88
+ debug: this.debug,
89
+ });
90
+
91
+ this.localMedia = new LocalMediaManager({
92
+ mediasoup: this.mediasoup,
93
+ debug: this.debug,
94
+ });
95
+
96
+ this.remoteMedia = new RemoteMediaManager({
97
+ mediasoup: this.mediasoup,
98
+ connection: this.connection,
99
+ videoClient: this, // Pass reference to access joinData
100
+ debug: this.debug,
101
+ });
102
+
103
+ // Adaptive quality: subscribes to StatsCollector samples, runs
104
+ // state machines, silently caps simulcast layers under pressure,
105
+ // and emits user-visible events (`quality` SDK event) for the
106
+ // host app to render toasts / tile badges. See QualityMonitor.js.
107
+ this.qualityMonitor = new QualityMonitor({
108
+ statsCollector: this.mediasoup.statsCollector,
109
+ mediasoupManager: this.mediasoup,
110
+ onQualityEvent: (evt) => this.emit("quality", evt),
111
+ // Solo gate: until we actually have a remote consumer flowing,
112
+ // no one is receiving our media and the encoder / BWE numbers
113
+ // don't reflect a real user-impacting problem. We count
114
+ // mediasoup consumers (people whose media we're receiving) as
115
+ // proxy for "a real meeting is in progress" — this excludes
116
+ // waiting-room participants and pre-join lobby state, which
117
+ // can otherwise appear in the participants list. Returning 0
118
+ // here makes the monitor skip evaluation entirely.
119
+ getRemotePeerCount: () => {
120
+ try {
121
+ return this.mediasoup?.consumers?.size || 0;
122
+ } catch {
123
+ return 0;
124
+ }
125
+ },
126
+ });
127
+ this.qualityMonitor.start();
128
+
129
+ // Connection health: unified `connection` event combining socket events,
130
+ // mediasoup transport state, and a heartbeat that catches silent failures
131
+ // on bad networks where the OS hasn't admitted the problem yet. The host
132
+ // app subscribes via `client.on('connection', ...)` to render a
133
+ // "Reconnecting…" banner. See ConnectionHealthMonitor.js.
134
+ this.connectionHealth = new ConnectionHealthMonitor({
135
+ connectionManager: this.connection,
136
+ mediasoupManager: this.mediasoup,
137
+ statsCollector: this.mediasoup.statsCollector,
138
+ onConnectionEvent: (evt) => this.emit("connection", evt),
139
+ debug: this.debug,
140
+ });
141
+ this.connectionHealth.start();
142
+
143
+ // Proxy manager events to SDK events
144
+ this._setupEventProxies();
145
+
146
+ // When mediasoup detects its transports are stale after a long disconnect,
147
+ // re-run the room.join flow to get fresh transport options + producer list
148
+ // from the server. This keeps media working across network blips >30s.
149
+ this.mediasoup.on("transports:recreated-needed", () => {
150
+ this._rejoinRoomForTransportRecreation().catch((err) => {
151
+ this.logger.error("Rejoin for transport recreation failed", err);
152
+ });
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Setup event proxies from managers to SDK
158
+ * @private
159
+ */
160
+ _setupEventProxies() {
161
+ // Connection events
162
+ this.connection.on("connected", () => {
163
+ this._setState("connected");
164
+ this.emit("connected");
165
+
166
+ // On Socket.IO reconnect (not initial connect), check whether the
167
+ // WebRTC transports died during the disconnect window. If the state
168
+ // has been bad for >10s, mediasoup will emit transports:recreated-needed.
169
+ if (
170
+ this.mediasoup &&
171
+ (this.mediasoup.sendTransport || this.mediasoup.recvTransport)
172
+ ) {
173
+ this.mediasoup.recreateStaleTransports().catch((err) => {
174
+ this.logger.error("recreateStaleTransports threw", err);
175
+ });
176
+ }
177
+ });
178
+
179
+ this.connection.on("disconnected", (data) => {
180
+ this._setState("disconnected");
181
+ this.emit("disconnected", data);
182
+ });
183
+
184
+ this.connection.on("error", (error) => {
185
+ this.emit("error", error);
186
+ });
187
+
188
+ // Server signaled the assigned pod is stale — re-fetch joinRoom and reconnect.
189
+ this.connection.on("reassignmentRequired", ({ code }) => {
190
+ this.logger.warn("Reassignment required", { code });
191
+ this._handleReassignment(code).catch((err) => {
192
+ this.logger.error("Reassignment handler threw", err);
193
+ });
194
+ });
195
+
196
+ // Local media events
197
+ this.localMedia.on("stream:added", (data) => {
198
+ this.emit("local-stream:added", data);
199
+ });
200
+
201
+ this.localMedia.on("stream:removed", (data) => {
202
+ this.emit("local-stream:removed", data);
203
+ });
204
+
205
+ this.localMedia.on("device:changed", (data) => {
206
+ this.emit("device:changed", data);
207
+ });
208
+
209
+ // Remote media events
210
+ this.remoteMedia.on("participant:added", (data) => {
211
+ this.emit("participant:joined", data);
212
+ });
213
+
214
+ this.remoteMedia.on("participant:removed", (data) => {
215
+ this.emit("participant:left", data);
216
+ });
217
+
218
+ this.remoteMedia.on("participant:updated", (data) => {
219
+ this.emit("participant:updated", data);
220
+ });
221
+
222
+ this.remoteMedia.on("stream:added", (data) => {
223
+ this.emit("stream:added", data);
224
+ });
225
+
226
+ this.remoteMedia.on("stream:removed", (data) => {
227
+ this.emit("stream:removed", data);
228
+ });
229
+
230
+ // Stream consuming events (for retry logic and error handling)
231
+ this.remoteMedia.on("stream:consuming", (data) => {
232
+ this.emit("stream:consuming", data);
233
+ });
234
+
235
+ this.remoteMedia.on("stream:consume-failed", (data) => {
236
+ this.emit("stream:consume-failed", data);
237
+ });
238
+
239
+ // Volume control events
240
+ this.remoteMedia.on("participant:volume-changed", (data) => {
241
+ this.emit("participant:volume-changed", data);
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Setup room/participant event listeners
247
+ * @private
248
+ */
249
+ _setupRoomEventListeners() {
250
+ // Room events
251
+ this.connection.onServerEvent("room.closed", (data) => {
252
+ this.logger.info("Room closed", data);
253
+ this._setState("disconnected");
254
+ this.emit("room:closed", data);
255
+ });
256
+
257
+ this.connection.onServerEvent("room.update", (data) => {
258
+ this.logger.info("Room updated", data);
259
+ this.emit("room:updated", data);
260
+ });
261
+
262
+ // Participant events
263
+ this.connection.onServerEvent("participant.all", (data) => {
264
+ this.logger.info("All participants", data);
265
+ this.emit("participants:list", data);
266
+ });
267
+
268
+ this.connection.onServerEvent("participant.leave", (data) => {
269
+ this.logger.info("Participant left", data);
270
+ this.emit("participant:left", data);
271
+ });
272
+
273
+ this.connection.onServerEvent("participant.remove", (data) => {
274
+ this.logger.info("Participant removed", data);
275
+ this.emit("participant:removed", data);
276
+ });
277
+
278
+ this.connection.onServerEvent("participant.update", (data) => {
279
+ this.logger.info("Participant updated", data);
280
+ this.emit("participant:updated", data);
281
+ });
282
+
283
+ // Server-side detected a peer's WebRTC transport closed (their network
284
+ // died). Surface as `participant:connection` so the host UI can render a
285
+ // "Reconnecting…" overlay on that peer's tile. Recovery is implicit —
286
+ // when the peer re-joins, normal participant.update + new producer
287
+ // events will arrive and the host clears the overlay.
288
+ this.connection.onServerEvent("participant.connection", (data) => {
289
+ this.logger.info("Participant connection state", data);
290
+ this.emit("participant:connection", data);
291
+ });
292
+
293
+ // Producer close event (for quality adaptation / reconnect)
294
+ this.connection.onServerEvent(
295
+ "participant.producer.close",
296
+ async (data) => {
297
+ this.logger.info("Producer close requested", data);
298
+ await this._handleProducerCloseRequest(data);
299
+ },
300
+ );
301
+
302
+ // Waiting room events
303
+ this.connection.onServerEvent("room.waitingRoom.admit", (data) => {
304
+ this.logger.info("Admitted from waiting room", data);
305
+ this.inWaitingRoom = false;
306
+ this._setState("in-meeting");
307
+ this.emit("waitingRoom:admitted", data);
308
+ });
309
+
310
+ // Keepalive events
311
+ this.connection.onServerEvent("keepalive.send", (data) => {
312
+ this.emit("keepalive:received", data);
313
+ this.connection.emit("keepalive.ack", data);
314
+ });
315
+
316
+ this.connection.onServerEvent("keepalive.alert", (data) => {
317
+ this.emit("keepalive:alert", data);
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Connect to the server
323
+ * @param {Object|string} auth - Authentication token or auth object
324
+ * @returns {Promise<void>}
325
+ */
326
+ async connect(auth = {}) {
327
+ if (this.state !== "disconnected") {
328
+ throw new StateError(
329
+ "Cannot connect: already connected or connecting",
330
+ this.state,
331
+ "disconnected",
332
+ );
333
+ }
334
+
335
+ this.logger.info("Connecting to server...");
336
+ this._setState("connecting");
337
+
338
+ try {
339
+ // Normalize auth to object format
340
+ const authObj = typeof auth === "string" ? { token: auth } : auth;
341
+
342
+ // Connect to server
343
+ await this.connection.connect(authObj);
344
+
345
+ // Load mediasoup device
346
+ await this.mediasoup.loadDevice();
347
+
348
+ this.logger.info("Connected successfully");
349
+ this._setState("connected");
350
+ } catch (error) {
351
+ this.logger.error("Connection failed:", error);
352
+ this._setState("disconnected");
353
+ throw error;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Disconnect from server
359
+ * @returns {Promise<void>}
360
+ */
361
+ /**
362
+ * Host calls this when the user clicks "Keep camera on" in the
363
+ * auto-disable grace toast. Cancels the pending disable and resets
364
+ * the sustained-critical timer so it does not immediately re-fire.
365
+ */
366
+ cancelAutoDisableCamera() {
367
+ if (this.qualityMonitor) this.qualityMonitor.cancelAutoDisableCamera();
368
+ }
369
+
370
+ /**
371
+ * Pause/resume a single remote peer's video. Stops local rendering AND
372
+ * asks the SFU to stop forwarding bytes — real downlink bandwidth
373
+ * savings. Screenshare is unaffected.
374
+ * @param {string} peerParticipantId
375
+ * @param {boolean} paused
376
+ * @returns {boolean} true if a matching consumer was found
377
+ */
378
+ setPeerVideoPaused(peerParticipantId, paused) {
379
+ if (!this.mediasoup) return false;
380
+ return this.mediasoup.setPeerVideoPaused(peerParticipantId, paused);
381
+ }
382
+
383
+ /**
384
+ * Pause/resume every remote peer's video. Used by the "pause all
385
+ * incoming video" control. Returns the count of consumers affected.
386
+ * @param {boolean} paused
387
+ */
388
+ setAllRemoteVideoPaused(paused) {
389
+ if (!this.mediasoup) return 0;
390
+ return this.mediasoup.setAllRemoteVideoPaused(paused);
391
+ }
392
+
393
+ /**
394
+ * Returns a structured snapshot of the QualityMonitor — current
395
+ * state, recent samples (last 10/direction), recent emitted events
396
+ * (last 25). Designed for the in-app debug modal so a tester can
397
+ * reproduce conditions without dropping to DevTools. Returns null
398
+ * if the monitor isn't initialized.
399
+ */
400
+ getQualityDebugSnapshot() {
401
+ const quality = this.qualityMonitor
402
+ ? this.qualityMonitor.getDebugSnapshot()
403
+ : null;
404
+ if (!quality) return null;
405
+ // Fold the connection-health monitor's snapshot into the same
406
+ // payload so the debug modal can show both in one place — saves
407
+ // wiring a second getter through every consumer and makes it
408
+ // obvious whether stale SDK code is running (missing keys here
409
+ // = older bundle).
410
+ quality.connectionHealth = this.connectionHealth
411
+ ? this.connectionHealth.getSnapshot()
412
+ : null;
413
+ return quality;
414
+ }
415
+
416
+ /**
417
+ * Current connection health snapshot (state, silence, transport state,
418
+ * thresholds). Used by the debug modal and by the host app to render
419
+ * the "Reconnecting…" banner.
420
+ */
421
+ getConnectionSnapshot() {
422
+ return this.connectionHealth ? this.connectionHealth.getSnapshot() : null;
423
+ }
424
+
425
+ async disconnect() {
426
+ this.logger.info("Disconnecting...");
427
+
428
+ try {
429
+ // Leave room if in one
430
+ if (this.state === "in-room") {
431
+ await this.leaveRoom();
432
+ }
433
+
434
+ // Stop quality monitor before tearing down stats sources.
435
+ if (this.qualityMonitor) this.qualityMonitor.stop();
436
+ if (this.connectionHealth) this.connectionHealth.stop();
437
+
438
+ // Clean up managers
439
+ await this.localMedia.cleanup();
440
+ await this.remoteMedia.cleanup();
441
+ await this.mediasoup.cleanup();
442
+
443
+ // Disconnect socket
444
+ await this.connection.disconnect();
445
+
446
+ this._setState("disconnected");
447
+ this.logger.info("Disconnected successfully");
448
+ } catch (error) {
449
+ this.logger.error("Disconnect error:", error);
450
+ throw error;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Join meeting directly from API response (api.video.joinRoom)
456
+ * This method extracts server info, connects, and determines if user needs to wait
457
+ *
458
+ * @param {Object} joinResponse - Response from api.video.joinRoom(room, password, email)
459
+ * @param {Object} options - Additional options
460
+ * @param {boolean} options.enterWaitingRoom - For guests, enter waiting room (default: auto-detect)
461
+ * @returns {Promise<Object>} Join data
462
+ *
463
+ * @example
464
+ * const joinResponse = await api.video.joinRoom('room-123', 'password', 'user@example.com');
465
+ * await client.joinFromApiResponse(joinResponse);
466
+ *
467
+ * // For guests in waiting room:
468
+ * client.on('waitingRoom:entered', () => showWaitingRoomUI());
469
+ * client.on('waitingRoom:admitted', () => showMeetingUI());
470
+ */
471
+ async joinFromApiResponse(joinResponse, options = {}) {
472
+ this.logger.info("Joining from API response");
473
+
474
+ // Store args used to obtain this response. If a reassignment is required
475
+ // later, we replay these against sdk.video.joinRoom() to get a fresh
476
+ // assignment without the consumer having to wire anything up.
477
+ if (options.joinRoomArgs !== undefined) {
478
+ this._lastJoinArgs = options.joinRoomArgs;
479
+ }
480
+
481
+ try {
482
+ const { videoRoom, participant } = joinResponse;
483
+
484
+ // Support both the new shape (connectionInfo.socket) and the legacy
485
+ // shape (top-level server + authorization) during the rollout.
486
+ // Legacy path is removed once every app1-api replica returns the new
487
+ // shape in staging/prod.
488
+ const connectionInfo = joinResponse.connectionInfo;
489
+ const socketInfo = connectionInfo?.socket;
490
+ const legacyServer = joinResponse.server;
491
+
492
+ let socketUrl;
493
+ let socketNamespace;
494
+ let authData;
495
+ let authorization;
496
+
497
+ if (socketInfo?.url) {
498
+ socketUrl = socketInfo.url;
499
+ socketNamespace = socketInfo.namespace || "/video";
500
+ authData = {
501
+ ...socketInfo.auth,
502
+ roomId: videoRoom.id,
503
+ participantId: participant.id,
504
+ };
505
+ authorization = connectionInfo.authorization || null;
506
+ } else if (legacyServer?.url && legacyServer?.socketPort) {
507
+ // Legacy per-pod-ingress path. Remove once centralized signaling
508
+ // is fully deployed.
509
+ socketUrl = `https://${legacyServer.url}:${legacyServer.socketPort}`;
510
+ socketNamespace = null;
511
+ authorization = joinResponse.authorization;
512
+ authData = {
513
+ accountNamespace: authorization?.namespace,
514
+ roomId: videoRoom.id,
515
+ participantId: participant.id,
516
+ };
517
+ } else {
518
+ throw new Error(
519
+ "Invalid join response: missing connectionInfo.socket and legacy server info",
520
+ );
521
+ }
522
+
523
+ // Store join data for later use (including reassignment replay)
524
+ this.joinData = {
525
+ videoRoom,
526
+ participant,
527
+ connectionInfo: connectionInfo || null,
528
+ server: legacyServer || null,
529
+ authorization,
530
+ };
531
+
532
+ // Detect if this is a guest (no host privileges)
533
+ this.isGuest = !participant.isHost && !participant.isModerator;
534
+
535
+ this.logger.info("Connecting to video server:", {
536
+ socketUrl,
537
+ socketNamespace,
538
+ });
539
+
540
+ // Initialize managers if not already done
541
+ if (!this.connection) {
542
+ this._initializeManagers(socketUrl, socketNamespace);
543
+ }
544
+
545
+ this._setState("connecting");
546
+
547
+ this.logger.info("Connecting with auth (cookie-based):", authData);
548
+ await this.connection.connect(authData);
549
+
550
+ this._setState("connected");
551
+
552
+ // Connection succeeded. If it stays up >60s, reset the reassignment
553
+ // attempt counter so the next reassignment cycle starts fresh.
554
+ this._armReassignmentStableTimer();
555
+
556
+ // Setup remote media listeners and room event listeners
557
+ this.logger.info("Setting up event listeners");
558
+ this.remoteMedia._setupServerListeners();
559
+ this._setupRoomEventListeners();
560
+
561
+ // ALWAYS emit room.waitingRoom first to initialize room on video server
562
+ // The server requires this event to set up the room before any joins
563
+ this.logger.info(
564
+ "Emitting room.waitingRoom to initialize room on server",
565
+ );
566
+ this.connection.emit("room.waitingRoom", {});
567
+
568
+ // Enter waiting-room state for everyone (hosts and guests)
569
+ // This allows users to set up their devices while room initializes
570
+ this.inWaitingRoom = true;
571
+ this._setState("waiting-room");
572
+
573
+ this.logger.info("Entering waiting room - waiting for room to be ready");
574
+
575
+ this.emit("waitingRoom:entered", {
576
+ roomId: videoRoom.id,
577
+ participant,
578
+ isHost: participant.isHost,
579
+ canJoinImmediately: !this.isGuest, // Hosts can join once ready, guests need admission
580
+ });
581
+
582
+ // Listen for media.routerCapabilities to know when room is ready
583
+ this.connection.onServerEvent("media.routerCapabilities", (data) => {
584
+ this.logger.info("Room is ready - received media.routerCapabilities");
585
+ this.emit("waitingRoom:ready", {
586
+ roomId: videoRoom.id,
587
+ participant,
588
+ });
589
+ });
590
+
591
+ // Return - user must explicitly call joinMeeting() when ready
592
+ // For hosts: after they click "Join Meeting" button (once room is ready)
593
+ // For guests: after host admits them (room.waitingRoom.admit event)
594
+ return {
595
+ ...this.joinData,
596
+ inWaitingRoom: true,
597
+ };
598
+ } catch (error) {
599
+ this.logger.error("Failed to join from API response:", error);
600
+ this._setState("disconnected");
601
+ throw new RoomError(`Failed to join meeting: ${error.message}`);
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Public method to join meeting from waiting room
607
+ * Call this after user clicks "Join Meeting" button
608
+ */
609
+ async joinMeeting() {
610
+ if (this.state !== "waiting-room") {
611
+ throw new StateError("Must be in waiting room to join meeting");
612
+ }
613
+
614
+ await this._joinMeeting();
615
+ }
616
+
617
+ /**
618
+ * Internal method to join meeting (after connection established)
619
+ * @private
620
+ */
621
+ async _joinMeeting() {
622
+ this.logger.info("Joining meeting:", this.joinData.videoRoom.friendlyName);
623
+ this._setState("joining");
624
+
625
+ // Setup transport listener BEFORE emitting room.join (but handler will wait for device)
626
+ this._setupMediaEventListeners();
627
+
628
+ // Send room.join event to video server (emit, not request - no callback)
629
+ this.logger.info("Emitting room.join event");
630
+ this.connection.emit("room.join", {});
631
+
632
+ // Load mediasoup device (will wait for media.routerCapabilities event)
633
+ this.logger.info("Waiting for media.routerCapabilities...");
634
+ await this.mediasoup.loadDevice();
635
+ this.logger.info("Device loaded successfully");
636
+
637
+ // Wait for media.transports event (listener will create transports now that device is loaded)
638
+ this.logger.info("Waiting for media.transports...");
639
+ await this._waitForTransports();
640
+
641
+ // Process any producers that arrived before transports were ready
642
+ this.logger.info("Processing pending producers...");
643
+ await this.remoteMedia.processPendingProducers();
644
+
645
+ this.currentRoomId = this.joinData.videoRoom.id;
646
+ this._setState("in-meeting");
647
+
648
+ this.logger.info("Successfully joined meeting");
649
+ this.emit("meeting:joined", {
650
+ roomId: this.joinData.videoRoom.id,
651
+ joinData: this.joinData,
652
+ });
653
+ }
654
+
655
+ /**
656
+ * Join a meeting room (legacy method - requires manual connect first)
657
+ * @param {string} roomId - Room ID to join
658
+ * @param {Object} options - Join options
659
+ * @returns {Promise<Object>} Room data
660
+ */
661
+ async joinRoom(roomId, options = {}) {
662
+ if (this.state !== "connected") {
663
+ throw new StateError(
664
+ "Cannot join room: not connected to server",
665
+ this.state,
666
+ "connected",
667
+ );
668
+ }
669
+
670
+ this.logger.info("Joining room:", roomId);
671
+ this._setState("joining");
672
+
673
+ try {
674
+ // Send join request to server
675
+ const response = await this.connection.request("room.join", {
676
+ roomId,
677
+ ...options,
678
+ });
679
+
680
+ this.currentRoomId = roomId;
681
+ this._setState("in-room");
682
+
683
+ this.logger.info("Joined room successfully");
684
+ this.emit("room:joined", { roomId, data: response });
685
+
686
+ return response;
687
+ } catch (error) {
688
+ this.logger.error("Failed to join room:", error);
689
+ this._setState("connected");
690
+ throw new RoomError(`Failed to join room: ${error.message}`, roomId);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Leave the current room
696
+ * @returns {Promise<void>}
697
+ */
698
+ async leaveRoom() {
699
+ if (this.state !== "in-room") {
700
+ throw new StateError(
701
+ "Cannot leave room: not in a room",
702
+ this.state,
703
+ "in-room",
704
+ );
705
+ }
706
+
707
+ this.logger.info("Leaving room:", this.currentRoomId);
708
+
709
+ try {
710
+ // Stop all local media
711
+ await this.localMedia.cleanup();
712
+
713
+ // Server-side teardown is driven by the socket disconnect handler
714
+ // (publishes participant.leave to the pod via NATS) and by the
715
+ // consumer's DELETE /video/:id/leave call. No room.leave socket
716
+ // event exists; do not send one.
717
+
718
+ // Clean up remote media
719
+ await this.remoteMedia.cleanup();
720
+
721
+ const roomId = this.currentRoomId;
722
+ this.currentRoomId = null;
723
+ this._setState("connected");
724
+
725
+ this.logger.info("Left room successfully");
726
+ this.emit("room:left", { roomId });
727
+ } catch (error) {
728
+ this.logger.error("Failed to leave room:", error);
729
+ throw error;
730
+ }
731
+ }
732
+
733
+ // ========== Local Media Methods ==========
734
+
735
+ /**
736
+ * Publish camera stream
737
+ * @param {Object} options - Camera options
738
+ * @param {string} options.deviceId - Camera device ID
739
+ * @param {string} options.resolution - Resolution (480p, 720p, 1080p)
740
+ * @param {number} options.frameRate - Frame rate
741
+ * @returns {Promise<MediaStream>}
742
+ */
743
+ async publishCamera(options = {}) {
744
+ this._ensureInRoom();
745
+ return await this.localMedia.publishCamera(options);
746
+ }
747
+
748
+ /**
749
+ * Publish microphone stream
750
+ * @param {Object} options - Microphone options
751
+ * @param {string} options.deviceId - Microphone device ID
752
+ * @returns {Promise<MediaStream>}
753
+ */
754
+ async publishMicrophone(options = {}) {
755
+ this._ensureInRoom();
756
+ return await this.localMedia.publishMicrophone(options);
757
+ }
758
+
759
+ /**
760
+ * Publish screen share
761
+ * @param {Object} options - Screen share options
762
+ * @param {boolean} options.audio - Include system audio
763
+ * @returns {Promise<MediaStream>}
764
+ */
765
+ async publishScreenShare(options = {}) {
766
+ this._ensureInRoom();
767
+ return await this.localMedia.publishScreenShare(options);
768
+ }
769
+
770
+ /**
771
+ * Stop camera
772
+ * @returns {Promise<void>}
773
+ */
774
+ async stopCamera() {
775
+ return await this.localMedia.stopCamera();
776
+ }
777
+
778
+ /**
779
+ * Stop microphone
780
+ * @returns {Promise<void>}
781
+ */
782
+ async stopMicrophone() {
783
+ return await this.localMedia.stopMicrophone();
784
+ }
785
+
786
+ /**
787
+ * Stop screen share
788
+ * @returns {Promise<void>}
789
+ */
790
+ async stopScreenShare() {
791
+ return await this.localMedia.stopScreenShare();
792
+ }
793
+
794
+ /**
795
+ * Enable screenshare audio (add audio to existing screenshare)
796
+ * @returns {Promise<void>}
797
+ */
798
+ async enableScreenShareAudio() {
799
+ return await this.localMedia.enableScreenShareAudio();
800
+ }
801
+
802
+ /**
803
+ * Disable screenshare audio (remove audio from existing screenshare)
804
+ * @returns {Promise<void>}
805
+ */
806
+ async disableScreenShareAudio() {
807
+ return await this.localMedia.disableScreenShareAudio();
808
+ }
809
+
810
+ /**
811
+ * Mute screenshare audio
812
+ * @returns {Promise<void>}
813
+ */
814
+ async muteScreenShareAudio() {
815
+ return await this.localMedia.muteScreenShareAudio();
816
+ }
817
+
818
+ /**
819
+ * Unmute screenshare audio
820
+ * @returns {Promise<void>}
821
+ */
822
+ async unmuteScreenShareAudio() {
823
+ return await this.localMedia.unmuteScreenShareAudio();
824
+ }
825
+
826
+ /**
827
+ * Toggle screenshare audio mute state
828
+ * @returns {Promise<boolean>} New mute state
829
+ */
830
+ async toggleScreenShareAudioMute() {
831
+ return await this.localMedia.toggleScreenShareAudioMute();
832
+ }
833
+
834
+ /**
835
+ * Change camera device
836
+ * @param {string} deviceId - New camera device ID
837
+ * @returns {Promise<MediaStream>}
838
+ */
839
+ async changeCamera(deviceId) {
840
+ return await this.localMedia.changeCamera(deviceId);
841
+ }
842
+
843
+ /**
844
+ * Change microphone device
845
+ * @param {string} deviceId - New microphone device ID
846
+ * @returns {Promise<MediaStream>}
847
+ */
848
+ async changeMicrophone(deviceId) {
849
+ return await this.localMedia.changeMicrophone(deviceId);
850
+ }
851
+
852
+ /**
853
+ * Update camera background effect
854
+ * @param {Object} options - Background options
855
+ * @param {string} options.type - 'none' | 'blur' | 'image'
856
+ * @param {number} options.blurLevel - Blur level in pixels (default: 8)
857
+ * @param {string} options.imageUrl - Background image URL (for type: 'image')
858
+ * @returns {Promise<MediaStream>}
859
+ *
860
+ * @example
861
+ * // Apply blur
862
+ * await client.updateCameraBackground({ type: 'blur', blurLevel: 8 });
863
+ *
864
+ * // Apply virtual background
865
+ * await client.updateCameraBackground({
866
+ * type: 'image',
867
+ * imageUrl: '/images/backgrounds/office.jpg'
868
+ * });
869
+ *
870
+ * // Remove background effect
871
+ * await client.updateCameraBackground({ type: 'none' });
872
+ */
873
+ async updateCameraBackground(options) {
874
+ this._ensureInRoom();
875
+ return await this.localMedia.updateCameraBackground(options);
876
+ }
877
+
878
+ /**
879
+ * Mute camera
880
+ * @returns {Promise<void>}
881
+ */
882
+ async muteCamera() {
883
+ return await this.localMedia.muteCamera();
884
+ }
885
+
886
+ /**
887
+ * Unmute camera
888
+ * @returns {Promise<void>}
889
+ */
890
+ async unmuteCamera() {
891
+ return await this.localMedia.unmuteCamera();
892
+ }
893
+
894
+ /**
895
+ * Mute microphone
896
+ * @returns {Promise<void>}
897
+ */
898
+ async muteMicrophone() {
899
+ return await this.localMedia.muteMicrophone();
900
+ }
901
+
902
+ /**
903
+ * Unmute microphone
904
+ * @returns {Promise<void>}
905
+ */
906
+ async unmuteMicrophone() {
907
+ return await this.localMedia.unmuteMicrophone();
908
+ }
909
+
910
+ // ========== Device Management ==========
911
+
912
+ /**
913
+ * Get available media devices
914
+ * @returns {Promise<Object>} Object with cameras, microphones, speakers
915
+ */
916
+ async getDevices() {
917
+ return await this.localMedia.getDevices();
918
+ }
919
+
920
+ /**
921
+ * Get local stream
922
+ * @param {string} type - Stream type (camera, microphone, screenShare)
923
+ * @returns {MediaStream|null}
924
+ */
925
+ getLocalStream(type) {
926
+ return this.localMedia.getStream(type);
927
+ }
928
+
929
+ /**
930
+ * Track a video element for automatic quality adjustment based on size
931
+ * @param {string} participantId - Participant ID
932
+ * @param {HTMLVideoElement} videoElement - Video element to track
933
+ */
934
+ trackVideoElement(participantId, videoElement) {
935
+ if (!this.remoteMedia) {
936
+ this.logger.warn("RemoteMedia not initialized");
937
+ return;
938
+ }
939
+ this.remoteMedia.trackVideoElement(participantId, videoElement);
940
+ }
941
+
942
+ /**
943
+ * Stop tracking a video element
944
+ * @param {string} participantId - Participant ID
945
+ */
946
+ untrackVideoElement(participantId) {
947
+ if (!this.remoteMedia) {
948
+ return;
949
+ }
950
+ this.remoteMedia.untrackVideoElement(participantId);
951
+ }
952
+
953
+ /**
954
+ * Locally mute a remote participant's stream (only for local user, doesn't affect others)
955
+ * @param {string} participantId - Participant ID
956
+ * @param {string} type - Stream type (audio, screenShareAudio)
957
+ * @returns {boolean} Success
958
+ */
959
+ localMuteRemoteStream(participantId, type = "audio") {
960
+ if (!this.remoteMedia) {
961
+ this.logger.warn("RemoteMedia not initialized");
962
+ return false;
963
+ }
964
+ return this.remoteMedia.localMuteRemoteStream(participantId, type);
965
+ }
966
+
967
+ /**
968
+ * Locally unmute a remote participant's stream
969
+ * @param {string} participantId - Participant ID
970
+ * @param {string} type - Stream type (audio, screenShareAudio)
971
+ * @returns {boolean} Success
972
+ */
973
+ localUnmuteRemoteStream(participantId, type = "audio") {
974
+ if (!this.remoteMedia) {
975
+ this.logger.warn("RemoteMedia not initialized");
976
+ return false;
977
+ }
978
+ return this.remoteMedia.localUnmuteRemoteStream(participantId, type);
979
+ }
980
+
981
+ /**
982
+ * Toggle local mute state for a remote participant's stream
983
+ * @param {string} participantId - Participant ID
984
+ * @param {string} type - Stream type (audio, screenShareAudio)
985
+ * @returns {boolean} New mute state
986
+ */
987
+ toggleLocalMuteRemoteStream(participantId, type = "audio") {
988
+ if (!this.remoteMedia) {
989
+ this.logger.warn("RemoteMedia not initialized");
990
+ return false;
991
+ }
992
+ return this.remoteMedia.toggleLocalMuteRemoteStream(participantId, type);
993
+ }
994
+
995
+ /**
996
+ * Check if a remote stream is locally muted
997
+ * @param {string} participantId - Participant ID
998
+ * @param {string} type - Stream type (audio, screenShareAudio)
999
+ * @returns {boolean} Mute state
1000
+ */
1001
+ isRemoteStreamLocallyMuted(participantId, type = "audio") {
1002
+ if (!this.remoteMedia) {
1003
+ return false;
1004
+ }
1005
+ return this.remoteMedia.isLocallyMuted(participantId, type);
1006
+ }
1007
+
1008
+ /**
1009
+ * Set callback for stats updates (for local UI display)
1010
+ * @param {Function} callback - Callback function that receives stats data
1011
+ */
1012
+ setStatsCallback(callback) {
1013
+ if (this.mediasoup && this.mediasoup.statsCollector) {
1014
+ this.mediasoup.statsCollector.setStatsCallback(callback);
1015
+ }
1016
+ }
1017
+
1018
+ // ========== Remote Participant Methods ==========
1019
+
1020
+ /**
1021
+ * Get participant by ID
1022
+ * @param {string} participantId - Participant ID
1023
+ * @returns {Object|null}
1024
+ */
1025
+ getParticipant(participantId) {
1026
+ return this.remoteMedia.getParticipant(participantId);
1027
+ }
1028
+
1029
+ /**
1030
+ * Get all participants
1031
+ * @returns {Array<Object>}
1032
+ */
1033
+ getAllParticipants() {
1034
+ return this.remoteMedia.getAllParticipants();
1035
+ }
1036
+
1037
+ /**
1038
+ * Get remote stream
1039
+ * @param {string} participantId - Participant ID
1040
+ * @param {string} type - Stream type (video, audio, screenShare)
1041
+ * @returns {MediaStream|null}
1042
+ */
1043
+ getRemoteStream(participantId, type) {
1044
+ return this.remoteMedia.getStream(participantId, type);
1045
+ }
1046
+
1047
+ /**
1048
+ * Get all streams for a participant
1049
+ * @param {string} participantId - Participant ID
1050
+ * @returns {Object|null}
1051
+ */
1052
+ getRemoteStreams(participantId) {
1053
+ return this.remoteMedia.getStreams(participantId);
1054
+ }
1055
+
1056
+ // ========== Logging Control ==========
1057
+
1058
+ /**
1059
+ * Set verbose logging for specific categories
1060
+ * @param {string} category - Category name (stats, keepalive, performance) or 'all'
1061
+ * @param {boolean} enabled - Enable or disable this category
1062
+ */
1063
+ setVerboseLogging(category, enabled) {
1064
+ if (category === "all") {
1065
+ Logger.setVerbose(enabled);
1066
+ } else {
1067
+ Logger.setFilter(category, enabled);
1068
+ }
1069
+ }
1070
+
1071
+ // ========== State Getters ==========
1072
+
1073
+ /**
1074
+ * Get current state
1075
+ * @returns {string}
1076
+ */
1077
+ getState() {
1078
+ return this.state;
1079
+ }
1080
+
1081
+ /**
1082
+ * Check if connected to server
1083
+ * @returns {boolean}
1084
+ */
1085
+ isConnected() {
1086
+ return this.state !== "disconnected" && this.connection.connected;
1087
+ }
1088
+
1089
+ /**
1090
+ * Check if in a room
1091
+ * @returns {boolean}
1092
+ */
1093
+ isInRoom() {
1094
+ return this.state === "in-room";
1095
+ }
1096
+
1097
+ /**
1098
+ * Get current room ID
1099
+ * @returns {string|null}
1100
+ */
1101
+ getRoomId() {
1102
+ return this.currentRoomId;
1103
+ }
1104
+
1105
+ /**
1106
+ * Check if camera is active
1107
+ * @returns {boolean}
1108
+ */
1109
+ isCameraActive() {
1110
+ return this.localMedia.isCameraActive;
1111
+ }
1112
+
1113
+ /**
1114
+ * Check if microphone is active
1115
+ * @returns {boolean}
1116
+ */
1117
+ isMicrophoneActive() {
1118
+ return this.localMedia.isMicrophoneActive;
1119
+ }
1120
+
1121
+ /**
1122
+ * Check if screen share is active
1123
+ * @returns {boolean}
1124
+ */
1125
+ isScreenShareActive() {
1126
+ return this.localMedia.isScreenShareActive;
1127
+ }
1128
+
1129
+ /**
1130
+ * Check if camera is muted
1131
+ * @returns {boolean}
1132
+ */
1133
+ isCameraMuted() {
1134
+ return this.localMedia.isCameraMuted;
1135
+ }
1136
+
1137
+ /**
1138
+ * Check if microphone is muted
1139
+ * @returns {boolean}
1140
+ */
1141
+ isMicrophoneMuted() {
1142
+ return this.localMedia.isMicrophoneMuted;
1143
+ }
1144
+
1145
+ /**
1146
+ * Get participant count
1147
+ * @returns {number}
1148
+ */
1149
+ getParticipantCount() {
1150
+ return this.remoteMedia.participantCount;
1151
+ }
1152
+
1153
+ /**
1154
+ * Get video room info from join data
1155
+ * @returns {Object|null}
1156
+ */
1157
+ getVideoRoom() {
1158
+ return this.joinData?.videoRoom || null;
1159
+ }
1160
+
1161
+ /**
1162
+ * Get current participant info from join data
1163
+ * @returns {Object|null}
1164
+ */
1165
+ getCurrentParticipant() {
1166
+ return this.joinData?.participant || null;
1167
+ }
1168
+
1169
+ /**
1170
+ * Get server info from join data
1171
+ * @returns {Object|null}
1172
+ */
1173
+ getServerInfo() {
1174
+ return this.joinData?.server || null;
1175
+ }
1176
+
1177
+ /**
1178
+ * Get full join data (videoRoom, participant, server, authorization)
1179
+ * @returns {Object|null}
1180
+ */
1181
+ getJoinData() {
1182
+ return this.joinData;
1183
+ }
1184
+
1185
+ // ========== Private Helpers ==========
1186
+
1187
+ /**
1188
+ * Set SDK state and emit event
1189
+ * @private
1190
+ */
1191
+ _setState(newState) {
1192
+ const oldState = this.state;
1193
+ this.state = newState;
1194
+ this.logger.info(`State changed: ${oldState} -> ${newState}`);
1195
+ this.emit("state:changed", { from: oldState, to: newState });
1196
+ }
1197
+
1198
+ /**
1199
+ * Ensure we're in a room, throw if not
1200
+ * @private
1201
+ */
1202
+ _ensureInRoom() {
1203
+ if (this.state !== "in-room" && this.state !== "in-meeting") {
1204
+ throw new StateError(
1205
+ "Not in a room. Call joinRoom() first.",
1206
+ this.state,
1207
+ "in-room or in-meeting",
1208
+ );
1209
+ }
1210
+ }
1211
+
1212
+ /**
1213
+ * Setup listeners for media events from server
1214
+ * @private
1215
+ */
1216
+ _setupMediaEventListeners() {
1217
+ this.logger.info("Setting up media event listeners");
1218
+
1219
+ // Listen for media.transports event
1220
+ this._transportsPromise = new Promise((resolve) => {
1221
+ this._transportsResolve = resolve;
1222
+ });
1223
+
1224
+ this.connection.onServerEvent("media.transports", async (data) => {
1225
+ this.logger.info("Received media.transports", {
1226
+ hasSend: !!data?.sendTransportOptions,
1227
+ hasRecv: !!data?.recvTransportOptions,
1228
+ });
1229
+
1230
+ const { sendTransportOptions, recvTransportOptions } = data;
1231
+
1232
+ // Store transport data for later if device not loaded yet
1233
+ this._pendingTransportData = data;
1234
+
1235
+ // Wait for device to be loaded before creating transports
1236
+ if (!this.mediasoup.device.loaded) {
1237
+ this.logger.warn(
1238
+ "Device not loaded yet, storing transport data and waiting...",
1239
+ );
1240
+
1241
+ // Poll for device to be loaded (with timeout)
1242
+ let attempts = 0;
1243
+ while (!this.mediasoup.device.loaded && attempts < 50) {
1244
+ await new Promise((resolve) => setTimeout(resolve, 100));
1245
+ attempts++;
1246
+ }
1247
+
1248
+ if (!this.mediasoup.device.loaded) {
1249
+ this.logger.error("Device still not loaded after 5 seconds");
1250
+ if (this._transportsResolve) {
1251
+ this._transportsResolve();
1252
+ }
1253
+ return;
1254
+ }
1255
+ this.logger.info(
1256
+ "Device now loaded, proceeding with transport creation",
1257
+ );
1258
+ }
1259
+
1260
+ // Create transports
1261
+ try {
1262
+ if (sendTransportOptions) {
1263
+ this.mediasoup.sendTransport =
1264
+ this.mediasoup.device.createSendTransport(sendTransportOptions);
1265
+ this.mediasoup._setupSendTransportListeners(); // Not async - just sets up event listeners
1266
+ this.logger.info("Send transport created");
1267
+ }
1268
+
1269
+ if (recvTransportOptions) {
1270
+ this.mediasoup.recvTransport =
1271
+ this.mediasoup.device.createRecvTransport(recvTransportOptions);
1272
+ this.mediasoup._setupRecvTransportListeners(); // Not async - just sets up event listeners
1273
+ this.logger.info("Receive transport created");
1274
+ }
1275
+
1276
+ // Resolve the promise
1277
+ if (this._transportsResolve) {
1278
+ this._transportsResolve();
1279
+ }
1280
+ } catch (error) {
1281
+ this.logger.error("Failed to create transports:", error);
1282
+ if (this._transportsResolve) {
1283
+ this._transportsResolve();
1284
+ }
1285
+ }
1286
+ });
1287
+ }
1288
+
1289
+ /**
1290
+ * Wait for media.transports event from server
1291
+ * @private
1292
+ */
1293
+ async _waitForTransports() {
1294
+ const timeout = setTimeout(() => {
1295
+ throw new Error("Timeout waiting for media.transports");
1296
+ }, 10000);
1297
+
1298
+ await this._transportsPromise;
1299
+ clearTimeout(timeout);
1300
+ this.logger.info("Transports ready");
1301
+ }
1302
+
1303
+ /**
1304
+ * Handle server request to close and reconnect producer
1305
+ * This happens during quality adaptation
1306
+ * @private
1307
+ */
1308
+ async _handleProducerCloseRequest(data) {
1309
+ const { producer } = data;
1310
+ const { type, reconnect } = producer;
1311
+
1312
+ this.logger.info("Handling producer close request:", { type, reconnect });
1313
+
1314
+ if (!type) {
1315
+ this.logger.error("Producer close request missing type");
1316
+ return;
1317
+ }
1318
+
1319
+ // Get the current stream for this producer before closing
1320
+ const currentStream =
1321
+ this.localMedia.streams[type === "video" ? "camera" : type];
1322
+ const currentProducer = this.localMedia.producers[type];
1323
+
1324
+ if (!currentProducer) {
1325
+ this.logger.warn("No producer found to close:", type);
1326
+ return;
1327
+ }
1328
+
1329
+ // Close the producer on mediasoup
1330
+ await this.mediasoup.closeProducer(type);
1331
+
1332
+ // If reconnect flag is true, republish with the same stream
1333
+ if (reconnect && currentStream) {
1334
+ this.logger.info("Reconnecting producer:", type);
1335
+
1336
+ try {
1337
+ // Clone the stream to avoid issues with the old one
1338
+ const clonedStream = currentStream.clone();
1339
+
1340
+ // Republish based on type
1341
+ if (type === "video") {
1342
+ // Stop the old stream tracks
1343
+ currentStream.getTracks().forEach((track) => track.stop());
1344
+
1345
+ // Start camera with the cloned stream
1346
+ await this.localMedia.startCamera({
1347
+ existingStream: clonedStream,
1348
+ // Preserve current background if any
1349
+ background: this.localMedia.currentBackgroundOptions,
1350
+ });
1351
+ } else if (type === "audio") {
1352
+ // Stop the old stream tracks
1353
+ currentStream.getTracks().forEach((track) => track.stop());
1354
+
1355
+ // Start microphone with the cloned stream
1356
+ await this.localMedia.startMicrophone({
1357
+ existingStream: clonedStream,
1358
+ });
1359
+ }
1360
+
1361
+ this.logger.info("Producer reconnected successfully:", type);
1362
+ } catch (error) {
1363
+ this.logger.error("Failed to reconnect producer:", type, error);
1364
+ // Emit error event so UI can handle it
1365
+ this.emit("producer:reconnect:failed", { type, error });
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ /**
1371
+ * Set volume for a specific participant (0.0 to 1.0)
1372
+ * Works seamlessly with both audio elements (< 30 participants) and audio mixer (30+ participants)
1373
+ * @param {string} participantId - Participant ID
1374
+ * @param {number} volume - Volume level (0.0 to 1.0)
1375
+ * @returns {boolean} Success status
1376
+ */
1377
+ setParticipantVolume(participantId, volume) {
1378
+ return this.remoteMedia.setParticipantVolume(participantId, volume);
1379
+ }
1380
+
1381
+ /**
1382
+ * Get volume for a specific participant
1383
+ * @param {string} participantId - Participant ID
1384
+ * @returns {number} Volume level (0.0 to 1.0)
1385
+ */
1386
+ getParticipantVolume(participantId) {
1387
+ return this.remoteMedia.getParticipantVolume(participantId);
1388
+ }
1389
+
1390
+ /**
1391
+ * Check if audio mixer is currently enabled
1392
+ * Audio mixer is automatically enabled when participant count >= 30
1393
+ * @returns {boolean}
1394
+ */
1395
+ isAudioMixerEnabled() {
1396
+ return this.remoteMedia.isAudioMixerEnabled();
1397
+ }
1398
+
1399
+ /**
1400
+ * Retry consuming a failed producer stream
1401
+ * Use this when a stream:consume-failed event is received
1402
+ * @param {string} producerId - Producer ID to retry
1403
+ * @param {string} participantId - Participant ID
1404
+ * @returns {Promise<boolean>} Success status
1405
+ *
1406
+ * @example
1407
+ * client.on('stream:consume-failed', async ({ producerId, participantId }) => {
1408
+ * console.warn('Stream failed to load, retrying...');
1409
+ * await client.retryConsumeStream(producerId, participantId);
1410
+ * });
1411
+ */
1412
+ async retryConsumeStream(producerId, participantId) {
1413
+ if (!this.remoteMedia) {
1414
+ this.logger.warn("RemoteMedia not initialized");
1415
+ return false;
1416
+ }
1417
+ return await this.remoteMedia.retryConsumeProducer(
1418
+ producerId,
1419
+ participantId,
1420
+ );
1421
+ }
1422
+
1423
+ // ========== Session Lifecycle ==========
1424
+
1425
+ /**
1426
+ * End the meeting session server-side. Clears the videoAuthToken cookie
1427
+ * and invalidates the token row so a stale cookie can't be replayed.
1428
+ *
1429
+ * Intended to be called on `leave()`, on a page `beforeunload`, or when the
1430
+ * consumer surfaces an "already joined in another tab" UX and wants to
1431
+ * clear the incumbent session before retrying. Safe to call from
1432
+ * `fetch({ keepalive: true })` during unload.
1433
+ *
1434
+ * Delegates to `sdk.video.endSession(roomId)` if an SDK was injected via
1435
+ * constructor options; otherwise this is a no-op (consumer must clear the
1436
+ * cookie through its own plumbing).
1437
+ *
1438
+ * @param {Object} [options]
1439
+ * @param {string} [options.roomId] - Override. Defaults to the current room.
1440
+ * @returns {Promise<void>}
1441
+ */
1442
+ async endSession(options = {}) {
1443
+ const roomId =
1444
+ options.roomId || this.currentRoomId || this.joinData?.videoRoom?.id;
1445
+ if (!roomId) {
1446
+ this.logger.warn("endSession: no roomId available");
1447
+ return;
1448
+ }
1449
+ if (!this.sdk?.video?.endSession) {
1450
+ this.logger.warn(
1451
+ "endSession: sdk not injected — consumer must clear session cookie manually",
1452
+ );
1453
+ return;
1454
+ }
1455
+ try {
1456
+ await this.sdk.video.endSession(roomId);
1457
+ this.logger.info("Session ended", { roomId });
1458
+ } catch (err) {
1459
+ this.logger.error("endSession failed", err);
1460
+ }
1461
+ }
1462
+
1463
+ // ========== Long-Disconnect Transport Recreation ==========
1464
+
1465
+ /**
1466
+ * Re-run the `room.join` flow after mediasoup wiped stale transports.
1467
+ * The server responds with fresh media.routerCapabilities + media.transports;
1468
+ * we reuse the existing loadDevice / transport listener plumbing so producers
1469
+ * and consumers get re-wired.
1470
+ *
1471
+ * Consumer must re-publish local streams (camera/microphone) after this —
1472
+ * producers died with the old transports.
1473
+ * @private
1474
+ */
1475
+ async _rejoinRoomForTransportRecreation() {
1476
+ if (!this.joinData || !this.connection) {
1477
+ this.logger.warn(
1478
+ "Cannot rejoin for transport recreation: no joinData or connection",
1479
+ );
1480
+ return;
1481
+ }
1482
+ this.logger.info(
1483
+ "Re-emitting room.join to recreate transports after long disconnect",
1484
+ );
1485
+
1486
+ // Reset the transports promise — _setupMediaEventListeners() creates it
1487
+ // via a fresh media.transports listener in the join flow.
1488
+ this._setupMediaEventListeners();
1489
+
1490
+ // Also need a fresh routerCapabilities listener since device must be
1491
+ // (re-)loaded. mediasoup.loadDevice() is idempotent only if device
1492
+ // isn't already loaded; after cleanup it should be safe to reload.
1493
+ this.connection.emit("room.join", {});
1494
+
1495
+ try {
1496
+ if (!this.mediasoup.device.loaded) {
1497
+ await this.mediasoup.loadDevice();
1498
+ }
1499
+ await this._waitForTransports();
1500
+ this.emit("transports:recreated");
1501
+ this.logger.info("Transport recreation complete");
1502
+ } catch (err) {
1503
+ this.logger.error("Transport recreation failed", err);
1504
+ this.emit("error", {
1505
+ code: "transport_recreation_failed",
1506
+ message: err.message,
1507
+ });
1508
+ }
1509
+ }
1510
+
1511
+ // ========== Reassignment Recovery ==========
1512
+
1513
+ /**
1514
+ * Arm the stable-connect timer. If the current connection stays up for >60s,
1515
+ * clear the reassignment attempt counter so the next cycle starts fresh.
1516
+ * @private
1517
+ */
1518
+ _armReassignmentStableTimer() {
1519
+ if (this._reassignmentStableTimer) {
1520
+ clearTimeout(this._reassignmentStableTimer);
1521
+ }
1522
+ this._reassignmentStableTimer = setTimeout(() => {
1523
+ if (this._reassignmentAttempts.length > 0) {
1524
+ this.logger.info(
1525
+ "Connection stable >60s; resetting reassignment attempt counter",
1526
+ );
1527
+ this._reassignmentAttempts = [];
1528
+ }
1529
+ this._reassignmentStableTimer = null;
1530
+ }, 60_000);
1531
+ }
1532
+
1533
+ /**
1534
+ * Handle a reassignmentRequired signal from the ConnectionManager.
1535
+ * Caps at 3 attempts in a 60s window with backoff 500ms, 2s, 5s.
1536
+ * @private
1537
+ */
1538
+ async _handleReassignment(code) {
1539
+ if (this._reassignmentInFlight) {
1540
+ this.logger.warn(
1541
+ "Reassignment already in flight; ignoring duplicate signal",
1542
+ );
1543
+ return;
1544
+ }
1545
+ this._reassignmentInFlight = true;
1546
+
1547
+ try {
1548
+ // Prune attempts older than 60s
1549
+ const now = Date.now();
1550
+ const windowStart = now - 60_000;
1551
+ this._reassignmentAttempts = this._reassignmentAttempts.filter(
1552
+ (ts) => ts > windowStart,
1553
+ );
1554
+
1555
+ const attemptIndex = this._reassignmentAttempts.length;
1556
+ if (attemptIndex >= 3) {
1557
+ this.logger.error(
1558
+ "Reassignment cap reached (3 in 60s); emitting reassignment_exhausted",
1559
+ );
1560
+ this.emit("error", {
1561
+ code: "reassignment_exhausted",
1562
+ message: "Reassignment attempts exhausted",
1563
+ triggeredBy: code,
1564
+ });
1565
+ return;
1566
+ }
1567
+
1568
+ this._reassignmentAttempts.push(now);
1569
+
1570
+ const backoffs = [500, 2000, 5000];
1571
+ const delay = backoffs[attemptIndex];
1572
+ this.logger.info(
1573
+ `Reassignment attempt ${attemptIndex + 1}/3 in ${delay}ms (code=${code})`,
1574
+ );
1575
+
1576
+ await new Promise((resolve) => setTimeout(resolve, delay));
1577
+
1578
+ if (!this.sdk?.video?.joinRoom || !this._lastJoinArgs) {
1579
+ this.logger.error("Cannot reassign: sdk or last joinRoom args missing");
1580
+ this.emit("error", {
1581
+ code: "reassignment_exhausted",
1582
+ message: "Reassignment plumbing missing (no sdk or joinRoomArgs)",
1583
+ triggeredBy: code,
1584
+ });
1585
+ return;
1586
+ }
1587
+
1588
+ // Teardown current media state. The old pod's mediasoup Router is
1589
+ // gone (or the token was rejected); we re-run the full join flow.
1590
+ await this._teardownForReassignment();
1591
+
1592
+ // Fetch a fresh assignment.
1593
+ const args = Array.isArray(this._lastJoinArgs)
1594
+ ? this._lastJoinArgs
1595
+ : [this._lastJoinArgs];
1596
+ const response = await this.sdk.video.joinRoom(...args);
1597
+
1598
+ // Re-run the join flow with the fresh response.
1599
+ await this.joinFromApiResponse(response, {
1600
+ joinRoomArgs: this._lastJoinArgs,
1601
+ });
1602
+
1603
+ this.emit("reassigned", { code, attempt: attemptIndex + 1 });
1604
+ this.logger.info("Reassignment succeeded", { attempt: attemptIndex + 1 });
1605
+ } catch (err) {
1606
+ this.logger.error("Reassignment failed", err);
1607
+ // Don't count fetch/connect failures — they'll trigger another
1608
+ // reassignmentRequired or a terminal error on their own.
1609
+ } finally {
1610
+ this._reassignmentInFlight = false;
1611
+ }
1612
+ }
1613
+
1614
+ /**
1615
+ * Tear down state before reconnecting on reassignment. Media is
1616
+ * unrecoverable (mediasoup Router died with the pod), so we clean up
1617
+ * transports, producers, consumers, local device tracks, and the old
1618
+ * Socket.IO handle. Consumer re-publishes camera/mic after the `reassigned`
1619
+ * event fires — matches the plan's "Media state lost (unavoidable)" stance
1620
+ * for pod death.
1621
+ * @private
1622
+ */
1623
+ async _teardownForReassignment() {
1624
+ try {
1625
+ if (this.localMedia) {
1626
+ await this.localMedia.cleanup();
1627
+ }
1628
+ } catch (err) {
1629
+ this.logger.warn("LocalMedia cleanup threw during reassignment", err);
1630
+ }
1631
+ try {
1632
+ if (this.mediasoup) {
1633
+ await this.mediasoup.cleanup();
1634
+ }
1635
+ } catch (err) {
1636
+ this.logger.warn("Mediasoup cleanup threw during reassignment", err);
1637
+ }
1638
+ try {
1639
+ if (this.remoteMedia) {
1640
+ await this.remoteMedia.cleanup();
1641
+ }
1642
+ } catch (err) {
1643
+ this.logger.warn("RemoteMedia cleanup threw during reassignment", err);
1644
+ }
1645
+ try {
1646
+ if (this.connection) {
1647
+ await this.connection.disconnect();
1648
+ }
1649
+ } catch (err) {
1650
+ this.logger.warn("Connection disconnect threw during reassignment", err);
1651
+ }
1652
+
1653
+ // Null out managers so _initializeManagers runs again with the new URL.
1654
+ this.connection = null;
1655
+ this.mediasoup = null;
1656
+ this.localMedia = null;
1657
+ this.remoteMedia = null;
1658
+
1659
+ if (this._reassignmentStableTimer) {
1660
+ clearTimeout(this._reassignmentStableTimer);
1661
+ this._reassignmentStableTimer = null;
1662
+ }
1663
+ }
1210
1664
  }