@unboundcx/video-sdk-client 2.0.0 → 2.0.2

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