@unboundcx/video-sdk-client 2.0.0 → 2.0.1

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