@unboundcx/video-sdk-client 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1051 @@
1
+ import { EventEmitter } from '../utils/EventEmitter.js';
2
+ import { Logger } from '../utils/Logger.js';
3
+ import {
4
+ PermissionDeniedError,
5
+ DeviceNotFoundError,
6
+ wrapError,
7
+ } from '../utils/errors.js';
8
+ import { VideoProcessor } from '../VideoProcessor.js';
9
+
10
+ /**
11
+ * Manages local media streams (camera, microphone, screen share)
12
+ *
13
+ * Events:
14
+ * - 'stream:added' - New local stream added
15
+ * - 'stream:removed' - Local stream removed
16
+ * - 'device:changed' - Device changed
17
+ * - 'track:ended' - Track ended unexpectedly
18
+ */
19
+ export class LocalMediaManager extends EventEmitter {
20
+ /**
21
+ * @param {Object} options
22
+ * @param {MediasoupManager} options.mediasoup - Mediasoup manager instance
23
+ * @param {boolean} options.debug - Enable debug logging
24
+ */
25
+ constructor(options) {
26
+ super();
27
+
28
+ this.mediasoup = options.mediasoup;
29
+ this.logger = new Logger('SDK:LocalMediaManager', options.debug);
30
+
31
+ // Store active streams
32
+ this.streams = {
33
+ camera: null,
34
+ microphone: null,
35
+ screenShare: null,
36
+ };
37
+
38
+ // Store a separate unmuted microphone stream for muted-speaking detection
39
+ // This stream is never paused/disabled so we can always detect audio levels
40
+ this.rawMicrophoneStream = null;
41
+
42
+ // Store producers
43
+ this.producers = {
44
+ video: null,
45
+ audio: null,
46
+ screenShare: null,
47
+ screenShareAudio: null,
48
+ };
49
+
50
+ // Mute state
51
+ this.muteState = {
52
+ camera: false,
53
+ screenShareAudio: false,
54
+ microphone: false,
55
+ };
56
+
57
+ // Video processor for virtual backgrounds
58
+ this.videoProcessor = new VideoProcessor({ debug: options.debug });
59
+ this.currentBackgroundOptions = null;
60
+ }
61
+
62
+ /**
63
+ * Get available media devices
64
+ * @returns {Promise<Object>} Object with cameras, microphones, speakers arrays
65
+ */
66
+ async getDevices() {
67
+ this.logger.log('Getting available devices');
68
+
69
+ try {
70
+ // Request permissions first to get device labels
71
+ await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
72
+
73
+ const devices = await navigator.mediaDevices.enumerateDevices();
74
+
75
+ const result = {
76
+ cameras: devices.filter((d) => d.kind === 'videoinput'),
77
+ microphones: devices.filter((d) => d.kind === 'audioinput'),
78
+ speakers: devices.filter((d) => d.kind === 'audiooutput'),
79
+ };
80
+
81
+ this.logger.info('Available devices:', {
82
+ cameras: result.cameras.length,
83
+ microphones: result.microphones.length,
84
+ speakers: result.speakers.length,
85
+ });
86
+
87
+ return result;
88
+ } catch (error) {
89
+ this.logger.error('Failed to get devices:', error);
90
+ throw wrapError(error, 'getDevices');
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Start camera and publish to server
96
+ * @param {Object} options
97
+ * @param {string} options.deviceId - Camera device ID
98
+ * @param {string} options.resolution - Resolution preset (480p, 720p, 1080p)
99
+ * @param {string} options.maxResolution - Max resolution for simulcast (720p, 1080p)
100
+ * @param {number} options.frameRate - Frame rate (default: 24)
101
+ * @param {Object} options.background - Background effect options
102
+ * @param {string} options.background.type - 'none' | 'blur' | 'image'
103
+ * @param {number} options.background.blurLevel - Blur level in pixels (default: 8)
104
+ * @param {string} options.background.imageUrl - Background image URL
105
+ * @returns {Promise<MediaStream>}
106
+ */
107
+ async publishCamera(options = {}) {
108
+ this.logger.info('Publishing camera', options);
109
+
110
+ try {
111
+ let stream;
112
+ let originalTrack;
113
+
114
+ // Check if we're reusing an existing track from WaitingRoom
115
+ if (options.existingTrack) {
116
+ this.logger.info('VIDEO_QUALITY :: Using existing track from WaitingRoom', {
117
+ trackKind: options.existingTrack.kind,
118
+ trackId: options.existingTrack.id,
119
+ trackReadyState: options.existingTrack.readyState
120
+ });
121
+ originalTrack = options.existingTrack;
122
+ stream = new MediaStream([originalTrack]);
123
+
124
+ const settings = originalTrack.getSettings();
125
+ this.logger.info('VIDEO_QUALITY :: Existing track settings:', {
126
+ width: settings.width,
127
+ height: settings.height,
128
+ frameRate: settings.frameRate,
129
+ deviceId: settings.deviceId,
130
+ allSettings: settings
131
+ });
132
+ } else {
133
+ // Request new stream (old behavior)
134
+ const constraints = this._getVideoConstraints(options);
135
+ this.logger.info('VIDEO_QUALITY :: Requesting camera with constraints:', constraints);
136
+
137
+ try {
138
+ stream = await navigator.mediaDevices.getUserMedia({
139
+ video: constraints,
140
+ });
141
+ } catch (err) {
142
+ if (err.name === 'OverconstrainedError' || err.name === 'ConstraintNotSatisfiedError') {
143
+ this.logger.warn('VIDEO_QUALITY :: Camera constraints too strict, retrying without min constraints:', err.constraint);
144
+
145
+ const fallbackConstraints = {
146
+ deviceId: constraints.deviceId,
147
+ width: { ideal: constraints.width.ideal },
148
+ height: { ideal: constraints.height.ideal },
149
+ frameRate: constraints.frameRate
150
+ };
151
+
152
+ this.logger.info('Retrying with fallback constraints:', fallbackConstraints);
153
+ stream = await navigator.mediaDevices.getUserMedia({
154
+ video: fallbackConstraints,
155
+ });
156
+ } else {
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ originalTrack = stream.getVideoTracks()[0];
162
+ const originalSettings = originalTrack.getSettings();
163
+ this.logger.info('VIDEO_QUALITY :: Original camera stream obtained:', {
164
+ width: originalSettings.width,
165
+ height: originalSettings.height,
166
+ frameRate: originalSettings.frameRate,
167
+ deviceId: originalSettings.deviceId
168
+ });
169
+
170
+ // Log camera capabilities
171
+ try {
172
+ const capabilities = originalTrack.getCapabilities();
173
+ this.logger.info('VIDEO_QUALITY :: Camera capabilities:', {
174
+ width: capabilities.width,
175
+ height: capabilities.height,
176
+ frameRate: capabilities.frameRate,
177
+ aspectRatio: capabilities.aspectRatio
178
+ });
179
+
180
+ if (capabilities.width && capabilities.width.max > originalSettings.width) {
181
+ this.logger.warn(`VIDEO_QUALITY :: ⚠️ Camera supports up to ${capabilities.width.max}×${capabilities.height.max} but only got ${originalSettings.width}×${originalSettings.height}!`);
182
+ this.logger.warn('Possible causes: Browser settings, camera firmware, system camera settings, or bandwidth limits');
183
+ }
184
+ } catch (err) {
185
+ this.logger.warn('Could not get camera capabilities:', err);
186
+ }
187
+ }
188
+
189
+ // Apply background effect if specified
190
+ if (options.background && options.background.type !== 'none') {
191
+ this.logger.info('Applying background effect:', options.background);
192
+ this.currentBackgroundOptions = options.background;
193
+ stream = await this.videoProcessor.applyBackground(stream, options.background);
194
+
195
+ // Log processed stream dimensions
196
+ const processedTrack = stream.getVideoTracks()[0];
197
+ const processedSettings = processedTrack.getSettings();
198
+ this.logger.info('Background applied, processed stream:', {
199
+ width: processedSettings.width,
200
+ height: processedSettings.height,
201
+ frameRate: processedSettings.frameRate
202
+ });
203
+ }
204
+
205
+ const track = stream.getVideoTracks()[0];
206
+
207
+ // Store stream
208
+ this.streams.camera = stream;
209
+
210
+ // Setup track ended handler
211
+ track.addEventListener('ended', () => {
212
+ this.logger.warn('Camera track ended');
213
+ this.emit('track:ended', { type: 'camera' });
214
+ this.streams.camera = null;
215
+ });
216
+
217
+ // Publish to mediasoup
218
+ const producer = await this.mediasoup.produce(track, {
219
+ appData: { type: 'video' },
220
+ simulcast: options.simulcast, // Pass through simulcast option
221
+ maxResolution: options.maxResolution, // Pass through max resolution preference
222
+ });
223
+
224
+ this.producers.video = producer;
225
+
226
+ this.logger.info('Camera published', {
227
+ deviceId: track.getSettings().deviceId,
228
+ resolution: `${track.getSettings().width}x${track.getSettings().height}`,
229
+ backgroundEffect: options.background?.type || 'none',
230
+ });
231
+
232
+ this.emit('stream:added', { type: 'camera', stream, producer });
233
+
234
+ return stream;
235
+ } catch (error) {
236
+ this.logger.error('Failed to publish camera:', error);
237
+
238
+ if (error.name === 'NotAllowedError') {
239
+ throw new PermissionDeniedError('camera', error);
240
+ }
241
+
242
+ if (error.name === 'NotFoundError') {
243
+ throw new DeviceNotFoundError('camera', options.deviceId);
244
+ }
245
+
246
+ throw wrapError(error, 'publishCamera');
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Start microphone and publish to server
252
+ * @param {Object} options
253
+ * @param {string} options.deviceId - Microphone device ID
254
+ * @param {boolean} options.echoCancellation - Enable echo cancellation
255
+ * @param {boolean} options.noiseSuppression - Enable noise suppression
256
+ * @returns {Promise<MediaStream>}
257
+ */
258
+ async publishMicrophone(options = {}) {
259
+ this.logger.info('Publishing microphone', options);
260
+
261
+ try {
262
+ let stream;
263
+ let track;
264
+
265
+ // Check if we're reusing an existing track from WaitingRoom
266
+ if (options.existingTrack) {
267
+ this.logger.info('Using existing audio track from WaitingRoom');
268
+ track = options.existingTrack;
269
+ stream = new MediaStream([track]);
270
+ } else {
271
+ // Request new stream (old behavior)
272
+ const constraints = {
273
+ deviceId: options.deviceId ? { exact: options.deviceId } : undefined,
274
+ echoCancellation: options.echoCancellation !== false,
275
+ noiseSuppression: options.noiseSuppression !== false,
276
+ autoGainControl: false, // Disabled - amplifies background noise
277
+ };
278
+
279
+ stream = await navigator.mediaDevices.getUserMedia({
280
+ audio: constraints,
281
+ });
282
+
283
+ track = stream.getAudioTracks()[0];
284
+ }
285
+
286
+ // Store stream
287
+ this.streams.microphone = stream;
288
+
289
+ // Also store a clone for muted-speaking detection (never disabled)
290
+ const clonedTrack = track.clone();
291
+ this.rawMicrophoneStream = new MediaStream([clonedTrack]);
292
+ this.logger.info('Created raw microphone stream clone for muted-speaking detection');
293
+
294
+ // Setup track ended handler
295
+ track.addEventListener('ended', () => {
296
+ this.logger.warn('Microphone track ended');
297
+ this.emit('track:ended', { type: 'microphone' });
298
+ this.streams.microphone = null;
299
+ // Also clean up raw stream
300
+ if (this.rawMicrophoneStream) {
301
+ this.rawMicrophoneStream.getTracks().forEach(t => t.stop());
302
+ this.rawMicrophoneStream = null;
303
+ }
304
+ });
305
+
306
+ // Publish to mediasoup
307
+ const producer = await this.mediasoup.produce(track, {
308
+ appData: { type: 'audio' },
309
+ });
310
+
311
+ this.producers.audio = producer;
312
+
313
+ this.logger.info('Microphone published', {
314
+ deviceId: track.getSettings().deviceId,
315
+ });
316
+
317
+ this.emit('stream:added', { type: 'microphone', stream, producer });
318
+
319
+ return stream;
320
+ } catch (error) {
321
+ this.logger.error('Failed to publish microphone:', error);
322
+
323
+ if (error.name === 'NotAllowedError') {
324
+ throw new PermissionDeniedError('microphone', error);
325
+ }
326
+
327
+ if (error.name === 'NotFoundError') {
328
+ throw new DeviceNotFoundError('microphone', options.deviceId);
329
+ }
330
+
331
+ throw wrapError(error, 'publishMicrophone');
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Start screen sharing and publish to server
337
+ * @param {Object} options
338
+ * @param {boolean} options.audio - Include system audio
339
+ * @returns {Promise<MediaStream>}
340
+ */
341
+ async publishScreenShare(options = {}) {
342
+ this.logger.info('LocalMediaManager :: publishScreenShare called with options:', JSON.stringify(options));
343
+
344
+ try {
345
+ // Get display media
346
+ // Note: Audio is only available when sharing a Chrome Tab or Window (not Screen)
347
+ // Chrome will show an audio checkbox for Tab and Window sharing if audio is requested
348
+ const constraints = {
349
+ video: {
350
+ cursor: 'always',
351
+ },
352
+ // Request audio with processing disabled for system audio
353
+ // System audio should not have echo cancellation, noise suppression, or auto gain control
354
+ audio: {
355
+ echoCancellation: false,
356
+ noiseSuppression: false,
357
+ autoGainControl: false,
358
+ sampleRate: 48000,
359
+ sampleSize: 16,
360
+ channelCount: 2
361
+ }
362
+ };
363
+
364
+ this.logger.info('LocalMediaManager :: Requesting display media with constraints:', JSON.stringify(constraints));
365
+ const stream = await navigator.mediaDevices.getDisplayMedia(constraints);
366
+ this.logger.info('LocalMediaManager :: Display media obtained - video tracks:', stream.getVideoTracks().length, 'audio tracks:', stream.getAudioTracks().length);
367
+
368
+ // Log audio track details if present
369
+ const audioTracks = stream.getAudioTracks();
370
+ if (audioTracks.length > 0) {
371
+ this.logger.info('LocalMediaManager :: Audio track found:', audioTracks[0].label, 'enabled:', audioTracks[0].enabled);
372
+ } else {
373
+ this.logger.warn('LocalMediaManager :: No audio track in stream. User may not have checked "Share audio" checkbox or shared Screen instead of Tab/Window.');
374
+ }
375
+
376
+ const videoTrack = stream.getVideoTracks()[0];
377
+
378
+ // Store stream
379
+ this.streams.screenShare = stream;
380
+
381
+ // Setup track ended handler (user stopped sharing)
382
+ videoTrack.addEventListener('ended', () => {
383
+ this.logger.info('Screen share track ended (user stopped sharing)');
384
+ this.stopScreenShare();
385
+ });
386
+
387
+ // Publish video track to mediasoup
388
+ const producer = await this.mediasoup.produce(videoTrack, {
389
+ appData: { type: 'screenShare' },
390
+ });
391
+
392
+ this.producers.screenShare = producer;
393
+
394
+ this.logger.info('Screen share published');
395
+
396
+ this.emit('stream:added', { type: 'screenShare', stream, producer });
397
+
398
+ // If audio track exists, publish it separately
399
+ if (audioTracks.length > 0) {
400
+ const audioTrack = audioTracks[0];
401
+ const audioProducer = await this.mediasoup.produce(audioTrack, {
402
+ appData: { type: 'screenShareAudio' },
403
+ });
404
+ this.producers.screenShareAudio = audioProducer;
405
+ this.logger.info('Screen share audio published');
406
+
407
+ // Setup track ended handler for audio
408
+ audioTrack.addEventListener('ended', () => {
409
+ this.logger.info('Screen share audio track ended');
410
+ if (this.producers.screenShareAudio) {
411
+ this.mediasoup.closeProducer('screenShareAudio');
412
+ this.producers.screenShareAudio = null;
413
+ }
414
+ });
415
+ } else {
416
+ this.logger.warn('LocalMediaManager :: No audio track available. Note: In Chrome, check the "Share audio" checkbox when sharing a Tab or Window. Screen sharing does not support audio.');
417
+ }
418
+
419
+ return stream;
420
+ } catch (error) {
421
+ this.logger.error('Failed to publish screen share:', error);
422
+
423
+ if (error.name === 'NotAllowedError') {
424
+ throw new PermissionDeniedError('screen', error);
425
+ }
426
+
427
+ throw wrapError(error, 'publishScreenShare');
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Stop camera
433
+ */
434
+ async stopCamera() {
435
+ this.logger.info('Stopping camera');
436
+
437
+ if (!this.streams.camera) {
438
+ this.logger.warn('No camera stream to stop');
439
+ return;
440
+ }
441
+
442
+ // Stop all tracks
443
+ this.streams.camera.getTracks().forEach((track) => track.stop());
444
+
445
+ // Close producer
446
+ if (this.producers.video) {
447
+ await this.mediasoup.closeProducer('video');
448
+ this.producers.video = null;
449
+ }
450
+
451
+ this.streams.camera = null;
452
+
453
+ this.emit('stream:removed', { type: 'camera' });
454
+ }
455
+
456
+ /**
457
+ * Stop microphone
458
+ */
459
+ async stopMicrophone() {
460
+ this.logger.info('Stopping microphone');
461
+
462
+ if (!this.streams.microphone) {
463
+ this.logger.warn('No microphone stream to stop');
464
+ return;
465
+ }
466
+
467
+ // Stop all tracks
468
+ this.streams.microphone.getTracks().forEach((track) => track.stop());
469
+
470
+ // Close producer
471
+ if (this.producers.audio) {
472
+ await this.mediasoup.closeProducer('audio');
473
+ this.producers.audio = null;
474
+ }
475
+
476
+ this.streams.microphone = null;
477
+
478
+ this.emit('stream:removed', { type: 'microphone' });
479
+ }
480
+
481
+ /**
482
+ * Stop screen sharing
483
+ */
484
+ async stopScreenShare() {
485
+ this.logger.info('Stopping screen share');
486
+
487
+ if (!this.streams.screenShare) {
488
+ this.logger.warn('No screen share stream to stop');
489
+ return;
490
+ }
491
+
492
+ // Stop all tracks
493
+ this.streams.screenShare.getTracks().forEach((track) => track.stop());
494
+
495
+ // Close producers
496
+ if (this.producers.screenShare) {
497
+ await this.mediasoup.closeProducer('screenShare');
498
+ this.producers.screenShare = null;
499
+ }
500
+
501
+ if (this.producers.screenShareAudio) {
502
+ await this.mediasoup.closeProducer('screenShareAudio');
503
+ this.producers.screenShareAudio = null;
504
+ }
505
+
506
+ this.streams.screenShare = null;
507
+
508
+ this.emit('stream:removed', { type: 'screenShare' });
509
+ }
510
+
511
+ /**
512
+ * Enable screenshare audio (add audio to existing screenshare)
513
+ * @returns {Promise<void>}
514
+ */
515
+ async enableScreenShareAudio() {
516
+ this.logger.info('Enabling screen share audio');
517
+
518
+ if (!this.streams.screenShare) {
519
+ throw new Error('No active screen share to add audio to');
520
+ }
521
+
522
+ if (this.producers.screenShareAudio) {
523
+ this.logger.warn('Screen share audio already enabled');
524
+ return;
525
+ }
526
+
527
+ try {
528
+ // Request a new screenshare with audio
529
+ const stream = await navigator.mediaDevices.getDisplayMedia({
530
+ video: true,
531
+ audio: {
532
+ echoCancellation: false,
533
+ noiseSuppression: false,
534
+ autoGainControl: false,
535
+ sampleRate: 48000,
536
+ sampleSize: 16,
537
+ channelCount: 2
538
+ }
539
+ });
540
+
541
+ // Check if audio track was granted
542
+ const audioTrack = stream.getAudioTracks()[0];
543
+ if (!audioTrack) {
544
+ this.logger.warn('No audio track available from display media');
545
+ // Stop the new stream since we only wanted audio
546
+ stream.getTracks().forEach((track) => track.stop());
547
+ return;
548
+ }
549
+
550
+ // Replace video track in existing stream (user might have selected different window)
551
+ const oldVideoTrack = this.streams.screenShare.getVideoTracks()[0];
552
+ const newVideoTrack = stream.getVideoTracks()[0];
553
+
554
+ // Update the video track in the stream
555
+ this.streams.screenShare.removeTrack(oldVideoTrack);
556
+ this.streams.screenShare.addTrack(newVideoTrack);
557
+ oldVideoTrack.stop();
558
+
559
+ // Replace the video producer
560
+ await this.mediasoup.replaceTrack('screenShare', newVideoTrack);
561
+
562
+ // Add audio track to stream
563
+ this.streams.screenShare.addTrack(audioTrack);
564
+
565
+ // Publish audio track
566
+ const audioProducer = await this.mediasoup.produce(audioTrack, {
567
+ appData: { type: 'screenShareAudio' },
568
+ });
569
+ this.producers.screenShareAudio = audioProducer;
570
+ this.muteState.screenShareAudio = false;
571
+
572
+ // Setup track ended handler
573
+ newVideoTrack.addEventListener('ended', () => {
574
+ this.logger.info('Screen share track ended (user stopped sharing)');
575
+ this.stopScreenShare();
576
+ });
577
+
578
+ this.logger.info('Screen share audio enabled');
579
+ this.emit('stream:updated', { type: 'screenShare', stream: this.streams.screenShare });
580
+
581
+ } catch (error) {
582
+ this.logger.error('Failed to enable screen share audio:', error);
583
+ throw error;
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Disable screenshare audio (remove audio from existing screenshare)
589
+ * @returns {Promise<void>}
590
+ */
591
+ async disableScreenShareAudio() {
592
+ this.logger.info('Disabling screen share audio');
593
+
594
+ if (!this.producers.screenShareAudio) {
595
+ this.logger.warn('No screen share audio to disable');
596
+ return;
597
+ }
598
+
599
+ // Close the audio producer
600
+ await this.mediasoup.closeProducer('screenShareAudio');
601
+ this.producers.screenShareAudio = null;
602
+
603
+ // Remove and stop audio track from stream
604
+ const audioTrack = this.streams.screenShare.getAudioTracks()[0];
605
+ if (audioTrack) {
606
+ this.streams.screenShare.removeTrack(audioTrack);
607
+ audioTrack.stop();
608
+ }
609
+
610
+ this.muteState.screenShareAudio = false;
611
+
612
+ this.logger.info('Screen share audio disabled');
613
+ this.emit('stream:updated', { type: 'screenShare', stream: this.streams.screenShare });
614
+ }
615
+
616
+ /**
617
+ * Mute screenshare audio
618
+ * @returns {Promise<void>}
619
+ */
620
+ async muteScreenShareAudio() {
621
+ if (!this.producers.screenShareAudio) {
622
+ this.logger.warn('No screen share audio to mute');
623
+ return;
624
+ }
625
+
626
+ try {
627
+ await this.mediasoup.pauseProducer('screenShareAudio');
628
+ this.muteState.screenShareAudio = true;
629
+ this.logger.info('Screen share audio muted');
630
+ this.emit('mute-state-changed', { type: 'screenShareAudio', muted: true });
631
+ } catch (error) {
632
+ this.logger.error('Failed to mute screen share audio:', error);
633
+ throw error;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Unmute screenshare audio
639
+ * @returns {Promise<void>}
640
+ */
641
+ async unmuteScreenShareAudio() {
642
+ if (!this.producers.screenShareAudio) {
643
+ this.logger.warn('No screen share audio to unmute');
644
+ return;
645
+ }
646
+
647
+ try {
648
+ await this.mediasoup.resumeProducer('screenShareAudio');
649
+ this.muteState.screenShareAudio = false;
650
+ this.logger.info('Screen share audio unmuted');
651
+ this.emit('mute-state-changed', { type: 'screenShareAudio', muted: false });
652
+ } catch (error) {
653
+ this.logger.error('Failed to unmute screen share audio:', error);
654
+ throw error;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Toggle screenshare audio mute state
660
+ * @returns {Promise<boolean>} New mute state
661
+ */
662
+ async toggleScreenShareAudioMute() {
663
+ if (this.muteState.screenShareAudio) {
664
+ await this.unmuteScreenShareAudio();
665
+ } else {
666
+ await this.muteScreenShareAudio();
667
+ }
668
+ return this.muteState.screenShareAudio;
669
+ }
670
+
671
+ /**
672
+ * Change camera device
673
+ * @param {Object|string} options - Camera options or deviceId string for backward compatibility
674
+ * @param {string} options.deviceId - Camera device ID (desktop)
675
+ * @param {string} options.facingMode - Camera facing mode: 'user' or 'environment' (mobile)
676
+ * @returns {Promise<MediaStream>}
677
+ */
678
+ async changeCamera(options) {
679
+ // Support backward compatibility: if options is a string, treat it as deviceId
680
+ if (typeof options === 'string') {
681
+ options = { deviceId: options };
682
+ }
683
+
684
+ this.logger.info('Changing camera:', options);
685
+
686
+ if (!this.producers.video) {
687
+ throw new Error('No active camera to change');
688
+ }
689
+
690
+ try {
691
+ // Get new stream
692
+ const constraints = this._getVideoConstraints(options);
693
+ let stream = await navigator.mediaDevices.getUserMedia({
694
+ video: constraints,
695
+ });
696
+
697
+ // Reapply background effect if it was active
698
+ if (this.currentBackgroundOptions) {
699
+ this.logger.info('Reapplying background effect after camera change');
700
+ stream = await this.videoProcessor.applyBackground(
701
+ stream,
702
+ this.currentBackgroundOptions,
703
+ );
704
+ }
705
+
706
+ const newTrack = stream.getVideoTracks()[0];
707
+
708
+ // Replace track in producer
709
+ await this.producers.video.replaceTrack({ track: newTrack });
710
+
711
+ // If producer was paused (camera muted), resume it
712
+ // When user manually changes camera, they likely want to use it
713
+ if (this.producers.video.paused) {
714
+ this.logger.info('Producer was paused, resuming to send new camera track');
715
+ this.producers.video.resume();
716
+ this.muteState.camera = false;
717
+ }
718
+
719
+ // Stop old stream
720
+ if (this.streams.camera) {
721
+ this.streams.camera.getTracks().forEach((track) => track.stop());
722
+ }
723
+
724
+ // Store new stream
725
+ this.streams.camera = stream;
726
+
727
+ this.logger.info('Camera changed successfully');
728
+
729
+ // Determine the deviceId to emit
730
+ // For mobile using facingMode, map to 'user' or 'environment'
731
+ // For desktop, use the actual deviceId
732
+ let emitDeviceId;
733
+ if (options.facingMode) {
734
+ // Mobile: use facingMode as the identifier
735
+ emitDeviceId = options.facingMode;
736
+ } else {
737
+ // Desktop: use deviceId from options or extract from track
738
+ emitDeviceId = options.deviceId || newTrack.getSettings().deviceId;
739
+ }
740
+
741
+ this.emit('device:changed', { type: 'camera', deviceId: emitDeviceId, stream });
742
+
743
+ return stream;
744
+ } catch (error) {
745
+ this.logger.error('Failed to change camera:', error);
746
+ throw wrapError(error, 'changeCamera');
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Update background effect on active camera
752
+ * @param {Object} options - Background options
753
+ * @param {string} options.type - 'none' | 'blur' | 'image'
754
+ * @param {number} options.blurLevel - Blur level in pixels (default: 8)
755
+ * @param {string} options.imageUrl - Background image URL
756
+ * @returns {Promise<MediaStream>}
757
+ */
758
+ async updateCameraBackground(options) {
759
+ this.logger.info('Updating camera background:', options);
760
+
761
+ if (!this.streams.camera) {
762
+ throw new Error('No active camera stream');
763
+ }
764
+
765
+ if (!this.producers.video) {
766
+ throw new Error('No active video producer');
767
+ }
768
+
769
+ try {
770
+ // Clean up old processor if exists
771
+ if (this.currentBackgroundOptions) {
772
+ await this.videoProcessor.cleanup();
773
+ }
774
+
775
+ // Get current device ID
776
+ const currentTrack = this.streams.camera.getVideoTracks()[0];
777
+ const deviceId = currentTrack.getSettings().deviceId;
778
+
779
+ // If removing background, restart with original stream
780
+ if (options.type === 'none') {
781
+ this.logger.info('Removing background effect');
782
+ this.currentBackgroundOptions = null;
783
+
784
+ const constraints = this._getVideoConstraints({ deviceId });
785
+ const newStream = await navigator.mediaDevices.getUserMedia({
786
+ video: constraints,
787
+ });
788
+
789
+ const newTrack = newStream.getVideoTracks()[0];
790
+
791
+ await this.producers.video.replaceTrack({ track: newTrack });
792
+
793
+ this.streams.camera.getTracks().forEach((t) => t.stop());
794
+ this.streams.camera = newStream;
795
+
796
+ this.logger.info('Background effect removed');
797
+ this.emit('background:changed', { type: 'none' });
798
+
799
+ return newStream;
800
+ }
801
+
802
+ // Apply new background effect
803
+ this.currentBackgroundOptions = options;
804
+
805
+ // Get fresh stream
806
+ const constraints = this._getVideoConstraints({ deviceId });
807
+ let stream = await navigator.mediaDevices.getUserMedia({
808
+ video: constraints,
809
+ });
810
+
811
+ // Apply background
812
+ stream = await this.videoProcessor.applyBackground(stream, options);
813
+
814
+ const newTrack = stream.getVideoTracks()[0];
815
+
816
+ // Replace track in producer
817
+ await this.producers.video.replaceTrack({ track: newTrack });
818
+
819
+ // Stop old stream
820
+ this.streams.camera.getTracks().forEach((t) => t.stop());
821
+ this.streams.camera = stream;
822
+
823
+ this.logger.info('Background effect updated successfully');
824
+ this.emit('background:changed', options);
825
+
826
+ return stream;
827
+ } catch (error) {
828
+ this.logger.error('Failed to update camera background:', error);
829
+ throw wrapError(error, 'updateCameraBackground');
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Change microphone device
835
+ * @param {string} deviceId - New microphone device ID
836
+ * @returns {Promise<MediaStream>}
837
+ */
838
+ async changeMicrophone(deviceId) {
839
+ this.logger.info('Changing microphone to:', deviceId);
840
+
841
+ if (!this.producers.audio) {
842
+ throw new Error('No active microphone to change');
843
+ }
844
+
845
+ try {
846
+ // Get new stream
847
+ const stream = await navigator.mediaDevices.getUserMedia({
848
+ audio: {
849
+ deviceId: { exact: deviceId },
850
+ echoCancellation: true,
851
+ noiseSuppression: true,
852
+ },
853
+ });
854
+
855
+ const newTrack = stream.getAudioTracks()[0];
856
+
857
+ // Replace track in producer
858
+ await this.producers.audio.replaceTrack({ track: newTrack });
859
+
860
+ // If producer was paused (microphone muted), resume it
861
+ // When user manually changes microphone, they likely want to use it
862
+ if (this.producers.audio.paused) {
863
+ this.logger.info('Producer was paused, resuming to send new microphone track');
864
+ this.producers.audio.resume();
865
+ this.muteState.microphone = false;
866
+ }
867
+
868
+ // Stop old stream
869
+ if (this.streams.microphone) {
870
+ this.streams.microphone.getTracks().forEach((track) => track.stop());
871
+ }
872
+
873
+ // Store new stream
874
+ this.streams.microphone = stream;
875
+
876
+ this.logger.info('Microphone changed successfully');
877
+
878
+ this.emit('device:changed', { type: 'microphone', deviceId, stream });
879
+
880
+ return stream;
881
+ } catch (error) {
882
+ this.logger.error('Failed to change microphone:', error);
883
+ throw wrapError(error, 'changeMicrophone');
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Mute camera (pause producer)
889
+ */
890
+ async muteCamera() {
891
+ if (!this.producers.video) {
892
+ this.logger.warn('No camera producer to mute');
893
+ return;
894
+ }
895
+
896
+ this.logger.info('Muting camera');
897
+ this.producers.video.pause();
898
+ this.muteState.camera = true;
899
+ // Note: Mute state is persisted via REST API call from the client app
900
+ }
901
+
902
+ /**
903
+ * Unmute camera (resume producer)
904
+ */
905
+ async unmuteCamera() {
906
+ if (!this.producers.video) {
907
+ this.logger.warn('No camera producer to unmute');
908
+ return;
909
+ }
910
+
911
+ this.logger.info('Unmuting camera');
912
+ this.producers.video.resume();
913
+ this.muteState.camera = false;
914
+ // Note: Mute state is persisted via REST API call from the client app
915
+ }
916
+
917
+ /**
918
+ * Mute microphone (pause producer)
919
+ */
920
+ async muteMicrophone() {
921
+ if (!this.producers.audio) {
922
+ this.logger.warn('No microphone producer to mute');
923
+ return;
924
+ }
925
+
926
+ this.logger.info('Muting microphone');
927
+ this.producers.audio.pause();
928
+ this.muteState.microphone = true;
929
+ // Note: Mute state is persisted via REST API call from the client app
930
+ }
931
+
932
+ /**
933
+ * Unmute microphone (resume producer)
934
+ */
935
+ async unmuteMicrophone() {
936
+ if (!this.producers.audio) {
937
+ this.logger.warn('No microphone producer to unmute');
938
+ return;
939
+ }
940
+
941
+ this.logger.info('Unmuting microphone');
942
+ this.producers.audio.resume();
943
+ this.muteState.microphone = false;
944
+ // Note: Mute state is persisted via REST API call from the client app
945
+ }
946
+
947
+ /**
948
+ * Get video constraints based on resolution preset
949
+ * @private
950
+ */
951
+ _getVideoConstraints(options = {}) {
952
+ const resolutions = {
953
+ '480p': { width: 640, height: 480 },
954
+ '720p': { width: 1280, height: 720 },
955
+ '1080p': { width: 1920, height: 1080 },
956
+ };
957
+
958
+ const resolution = resolutions[options.resolution] || resolutions['1080p']; // Default to 1080p for maximum quality
959
+
960
+ // Use ideal + max constraints like the old working /video system
961
+ // Do NOT use min - it causes OverconstrainedError on many cameras
962
+ // Browser will give us the best resolution it can up to the max
963
+ const constraints = {
964
+ width: {
965
+ ideal: resolution.width,
966
+ max: resolution.width
967
+ },
968
+ height: {
969
+ ideal: resolution.height,
970
+ max: resolution.height
971
+ },
972
+ frameRate: { ideal: options.frameRate || 30 },
973
+ aspectRatio: { ideal: 16 / 9 }
974
+ };
975
+
976
+ // Use facingMode if provided (mobile), otherwise use deviceId (desktop)
977
+ if (options.facingMode) {
978
+ constraints.facingMode = { exact: options.facingMode };
979
+ } else if (options.deviceId) {
980
+ constraints.deviceId = { exact: options.deviceId };
981
+ }
982
+
983
+ return constraints;
984
+ }
985
+
986
+ /**
987
+ * Get local stream by type
988
+ * @param {string} type - Stream type (camera, microphone, screenShare)
989
+ * @returns {MediaStream|null}
990
+ */
991
+ getStream(type) {
992
+ // Special case: return raw microphone stream for muted-speaking detection
993
+ if (type === 'microphone-raw') {
994
+ return this.rawMicrophoneStream || null;
995
+ }
996
+ return this.streams[type] || null;
997
+ }
998
+
999
+ /**
1000
+ * Check if camera is active
1001
+ * @returns {boolean}
1002
+ */
1003
+ get isCameraActive() {
1004
+ return !!this.streams.camera && !!this.producers.video;
1005
+ }
1006
+
1007
+ /**
1008
+ * Check if microphone is active
1009
+ * @returns {boolean}
1010
+ */
1011
+ get isMicrophoneActive() {
1012
+ return !!this.streams.microphone && !!this.producers.audio;
1013
+ }
1014
+
1015
+ /**
1016
+ * Check if screen share is active
1017
+ * @returns {boolean}
1018
+ */
1019
+ get isScreenShareActive() {
1020
+ return !!this.streams.screenShare && !!this.producers.screenShare;
1021
+ }
1022
+
1023
+ /**
1024
+ * Check if camera is muted
1025
+ * @returns {boolean}
1026
+ */
1027
+ get isCameraMuted() {
1028
+ return this.muteState.camera;
1029
+ }
1030
+
1031
+ /**
1032
+ * Check if microphone is muted
1033
+ * @returns {boolean}
1034
+ */
1035
+ get isMicrophoneMuted() {
1036
+ return this.muteState.microphone;
1037
+ }
1038
+
1039
+ /**
1040
+ * Clean up all local streams
1041
+ */
1042
+ async cleanup() {
1043
+ this.logger.info('Cleaning up local streams...');
1044
+
1045
+ await this.stopCamera();
1046
+ await this.stopMicrophone();
1047
+ await this.stopScreenShare();
1048
+
1049
+ this.logger.info('Local streams cleanup complete');
1050
+ }
1051
+ }