@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.
- package/AudioMixer.js +235 -0
- package/README.md +400 -0
- package/VideoMeetingClient.js +1210 -0
- package/VideoProcessor.js +375 -0
- package/index.js +42 -0
- package/managers/ConnectionManager.js +243 -0
- package/managers/LocalMediaManager.js +1051 -0
- package/managers/MediasoupManager.js +789 -0
- package/managers/RemoteMediaManager.js +972 -0
- package/managers/StatsCollector.js +710 -0
- package/package.json +56 -0
- package/utils/EventEmitter.js +103 -0
- package/utils/Logger.js +114 -0
- package/utils/errors.js +136 -0
|
@@ -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
|
+
}
|