@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,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoProcessor - Handles video processing (blur, virtual backgrounds)
|
|
3
|
+
* Uses MediaPipe SelfieSegmentation for background removal
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import SelfieSegmentationPkg from '@mediapipe/selfie_segmentation';
|
|
7
|
+
import { Logger } from './utils/Logger.js';
|
|
8
|
+
|
|
9
|
+
// Handle CommonJS module in Vite
|
|
10
|
+
const { SelfieSegmentation } = SelfieSegmentationPkg;
|
|
11
|
+
|
|
12
|
+
export class VideoProcessor {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.logger = new Logger('VideoProcessor', options.debug);
|
|
15
|
+
this.selfieSegmentation = null;
|
|
16
|
+
this.isInitialized = false;
|
|
17
|
+
this.processing = false;
|
|
18
|
+
this.offScreenVideo = null;
|
|
19
|
+
this.offScreenCanvas = null;
|
|
20
|
+
this.canvasCtx = null;
|
|
21
|
+
this.backgroundImage = null;
|
|
22
|
+
this.animationFrameId = null;
|
|
23
|
+
|
|
24
|
+
// Edge refinement settings
|
|
25
|
+
this.edgeBlurAmount = 5; // Increased from 4px for smoother edges
|
|
26
|
+
this.lightWrapIntensity = 0.15; // How much background light spills onto edges
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize MediaPipe SelfieSegmentation model
|
|
31
|
+
*/
|
|
32
|
+
async initialize() {
|
|
33
|
+
if (this.isInitialized) {
|
|
34
|
+
this.logger.info('VideoProcessor already initialized');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
this.logger.info('Initializing MediaPipe SelfieSegmentation');
|
|
40
|
+
|
|
41
|
+
this.selfieSegmentation = new SelfieSegmentation({
|
|
42
|
+
locateFile: (file) => {
|
|
43
|
+
return `/mediapipe/selfie_segmentation/${file}`;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this.selfieSegmentation.setOptions({
|
|
48
|
+
modelSelection: 1, // 0 = general, 1 = landscape (better quality)
|
|
49
|
+
selfieMode: false, // Set to false for better non-mirrored results
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await this.selfieSegmentation.initialize();
|
|
53
|
+
|
|
54
|
+
this.isInitialized = true;
|
|
55
|
+
this.logger.info('VideoProcessor initialized successfully');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
this.logger.error('Failed to initialize VideoProcessor:', error);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply background effect to video stream
|
|
64
|
+
* @param {MediaStream} videoStream - Input video stream
|
|
65
|
+
* @param {Object} options - Background options
|
|
66
|
+
* @param {string} options.type - 'blur' | 'image' | 'none'
|
|
67
|
+
* @param {number} options.blurLevel - Blur amount in pixels (default: 8)
|
|
68
|
+
* @param {string} options.imageUrl - Background image URL (for type: 'image')
|
|
69
|
+
* @returns {Promise<MediaStream>} Processed video stream
|
|
70
|
+
*/
|
|
71
|
+
async applyBackground(videoStream, options = {}) {
|
|
72
|
+
const { type = 'none', blurLevel = 8, imageUrl = null } = options;
|
|
73
|
+
|
|
74
|
+
if (type === 'none') {
|
|
75
|
+
this.logger.info('No background effect - returning original stream');
|
|
76
|
+
return videoStream;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!this.isInitialized) {
|
|
80
|
+
await this.initialize();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.logger.info('Applying background effect:', { type, blurLevel, imageUrl });
|
|
84
|
+
|
|
85
|
+
// Load background image if needed
|
|
86
|
+
if (type === 'image' && imageUrl) {
|
|
87
|
+
this.backgroundImage = await this._loadImage(imageUrl);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Create off-screen video element
|
|
91
|
+
this.offScreenVideo = document.createElement('video');
|
|
92
|
+
this.offScreenVideo.srcObject = videoStream;
|
|
93
|
+
this.offScreenVideo.autoplay = true;
|
|
94
|
+
this.offScreenVideo.muted = true;
|
|
95
|
+
this.offScreenVideo.playsInline = true;
|
|
96
|
+
|
|
97
|
+
// Create off-screen canvas
|
|
98
|
+
this.offScreenCanvas = document.createElement('canvas');
|
|
99
|
+
this.canvasCtx = this.offScreenCanvas.getContext('2d', {
|
|
100
|
+
willReadFrequently: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Wait for video metadata
|
|
104
|
+
await new Promise((resolve) => {
|
|
105
|
+
this.offScreenVideo.onloadedmetadata = () => {
|
|
106
|
+
this.offScreenCanvas.width = this.offScreenVideo.videoWidth;
|
|
107
|
+
this.offScreenCanvas.height = this.offScreenVideo.videoHeight;
|
|
108
|
+
this.logger.info('Video dimensions:', {
|
|
109
|
+
width: this.offScreenVideo.videoWidth,
|
|
110
|
+
height: this.offScreenVideo.videoHeight,
|
|
111
|
+
});
|
|
112
|
+
resolve();
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Wait for video to actually start playing
|
|
117
|
+
await this.offScreenVideo.play();
|
|
118
|
+
|
|
119
|
+
// Wait for first frame to be processed before returning stream
|
|
120
|
+
this.processing = true;
|
|
121
|
+
const firstFrameProcessed = await this._startProcessingAndWaitForFirstFrame(
|
|
122
|
+
type,
|
|
123
|
+
blurLevel,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (!firstFrameProcessed) {
|
|
127
|
+
this.logger.warn('First frame not processed, returning anyway');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Return canvas stream
|
|
131
|
+
const processedStream = this.offScreenCanvas.captureStream(30);
|
|
132
|
+
this.logger.info('Background effect applied successfully');
|
|
133
|
+
|
|
134
|
+
return processedStream;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Stop processing and cleanup resources
|
|
139
|
+
*/
|
|
140
|
+
async cleanup() {
|
|
141
|
+
this.logger.info('Cleaning up VideoProcessor');
|
|
142
|
+
|
|
143
|
+
this.processing = false;
|
|
144
|
+
|
|
145
|
+
if (this.animationFrameId) {
|
|
146
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
147
|
+
this.animationFrameId = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.offScreenVideo) {
|
|
151
|
+
this.offScreenVideo.pause();
|
|
152
|
+
this.offScreenVideo.srcObject = null;
|
|
153
|
+
this.offScreenVideo = null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (this.offScreenCanvas) {
|
|
157
|
+
this.offScreenCanvas = null;
|
|
158
|
+
this.canvasCtx = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (this._maskCanvas) {
|
|
162
|
+
this._maskCanvas = null;
|
|
163
|
+
this._maskCtx = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (this._refinedMaskCanvas) {
|
|
167
|
+
this._refinedMaskCanvas = null;
|
|
168
|
+
this._refinedMaskCtx = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this._foregroundCanvas) {
|
|
172
|
+
this._foregroundCanvas = null;
|
|
173
|
+
this._foregroundCtx = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this._backgroundCanvas) {
|
|
177
|
+
this._backgroundCanvas = null;
|
|
178
|
+
this._backgroundCtx = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.backgroundImage = null;
|
|
182
|
+
|
|
183
|
+
this.logger.info('VideoProcessor cleaned up');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Dispose of all resources including the model
|
|
188
|
+
*/
|
|
189
|
+
async dispose() {
|
|
190
|
+
await this.cleanup();
|
|
191
|
+
|
|
192
|
+
if (this.selfieSegmentation) {
|
|
193
|
+
this.selfieSegmentation.close();
|
|
194
|
+
this.selfieSegmentation = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.isInitialized = false;
|
|
198
|
+
this.logger.info('VideoProcessor disposed');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Private methods
|
|
202
|
+
|
|
203
|
+
async _loadImage(url) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const img = new Image();
|
|
206
|
+
img.crossOrigin = 'anonymous';
|
|
207
|
+
img.onload = () => resolve(img);
|
|
208
|
+
img.onerror = reject;
|
|
209
|
+
img.src = url;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async _startProcessingAndWaitForFirstFrame(type, blurLevel) {
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
let firstFrameDrawn = false;
|
|
216
|
+
|
|
217
|
+
this.selfieSegmentation.onResults((results) => {
|
|
218
|
+
this._drawFrame(results, type, blurLevel);
|
|
219
|
+
|
|
220
|
+
if (!firstFrameDrawn) {
|
|
221
|
+
firstFrameDrawn = true;
|
|
222
|
+
this.logger.info('First frame processed and drawn');
|
|
223
|
+
resolve(true);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const processFrame = async () => {
|
|
228
|
+
if (!this.processing || !this.offScreenVideo) {
|
|
229
|
+
if (!firstFrameDrawn) resolve(false);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check if video is ready and has dimensions
|
|
234
|
+
if (
|
|
235
|
+
this.offScreenVideo.readyState >= 2 &&
|
|
236
|
+
this.offScreenVideo.videoWidth > 0 &&
|
|
237
|
+
this.offScreenVideo.videoHeight > 0
|
|
238
|
+
) {
|
|
239
|
+
try {
|
|
240
|
+
await this.selfieSegmentation.send({ image: this.offScreenVideo });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
this.logger.error('Error sending frame to MediaPipe:', err);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.animationFrameId = requestAnimationFrame(processFrame);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Timeout after 5 seconds if first frame never comes
|
|
250
|
+
setTimeout(() => {
|
|
251
|
+
if (!firstFrameDrawn) {
|
|
252
|
+
this.logger.warn('Timeout waiting for first frame');
|
|
253
|
+
resolve(false);
|
|
254
|
+
}
|
|
255
|
+
}, 5000);
|
|
256
|
+
|
|
257
|
+
processFrame();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
_drawFrame(results, type, blurLevel) {
|
|
262
|
+
if (!this.canvasCtx || !this.offScreenCanvas) return;
|
|
263
|
+
|
|
264
|
+
const { width, height} = this.offScreenCanvas;
|
|
265
|
+
|
|
266
|
+
this.canvasCtx.save();
|
|
267
|
+
this.canvasCtx.clearRect(0, 0, width, height);
|
|
268
|
+
|
|
269
|
+
// Create temporary canvases for processing
|
|
270
|
+
if (!this._maskCanvas) {
|
|
271
|
+
this._maskCanvas = document.createElement('canvas');
|
|
272
|
+
this._maskCtx = this._maskCanvas.getContext('2d');
|
|
273
|
+
}
|
|
274
|
+
if (!this._refinedMaskCanvas) {
|
|
275
|
+
this._refinedMaskCanvas = document.createElement('canvas');
|
|
276
|
+
this._refinedMaskCtx = this._refinedMaskCanvas.getContext('2d');
|
|
277
|
+
}
|
|
278
|
+
if (!this._foregroundCanvas) {
|
|
279
|
+
this._foregroundCanvas = document.createElement('canvas');
|
|
280
|
+
this._foregroundCtx = this._foregroundCanvas.getContext('2d');
|
|
281
|
+
}
|
|
282
|
+
if (!this._backgroundCanvas) {
|
|
283
|
+
this._backgroundCanvas = document.createElement('canvas');
|
|
284
|
+
this._backgroundCtx = this._backgroundCanvas.getContext('2d');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Set canvas sizes
|
|
288
|
+
this._maskCanvas.width = width;
|
|
289
|
+
this._maskCanvas.height = height;
|
|
290
|
+
this._refinedMaskCanvas.width = width;
|
|
291
|
+
this._refinedMaskCanvas.height = height;
|
|
292
|
+
this._foregroundCanvas.width = width;
|
|
293
|
+
this._foregroundCanvas.height = height;
|
|
294
|
+
this._backgroundCanvas.width = width;
|
|
295
|
+
this._backgroundCanvas.height = height;
|
|
296
|
+
|
|
297
|
+
// ===== STEP 1: Multi-pass mask refinement =====
|
|
298
|
+
// Draw raw mask
|
|
299
|
+
this._maskCtx.clearRect(0, 0, width, height);
|
|
300
|
+
this._maskCtx.drawImage(results.segmentationMask, 0, 0, width, height);
|
|
301
|
+
|
|
302
|
+
// Apply progressive blur for smooth edges
|
|
303
|
+
this._refinedMaskCtx.clearRect(0, 0, width, height);
|
|
304
|
+
// First pass: moderate blur
|
|
305
|
+
this._refinedMaskCtx.filter = 'blur(2px)';
|
|
306
|
+
this._refinedMaskCtx.drawImage(this._maskCanvas, 0, 0);
|
|
307
|
+
this._refinedMaskCtx.filter = 'none';
|
|
308
|
+
|
|
309
|
+
// Second pass: additional blur on top (cumulative effect)
|
|
310
|
+
this._refinedMaskCtx.globalAlpha = 1.0;
|
|
311
|
+
this._refinedMaskCtx.filter = `blur(${this.edgeBlurAmount}px)`;
|
|
312
|
+
this._refinedMaskCtx.drawImage(this._maskCanvas, 0, 0);
|
|
313
|
+
this._refinedMaskCtx.filter = 'none';
|
|
314
|
+
this._refinedMaskCtx.globalAlpha = 1.0;
|
|
315
|
+
|
|
316
|
+
// ===== STEP 2: Prepare background =====
|
|
317
|
+
this._backgroundCtx.clearRect(0, 0, width, height);
|
|
318
|
+
if (type === 'image' && this.backgroundImage) {
|
|
319
|
+
this._backgroundCtx.drawImage(this.backgroundImage, 0, 0, width, height);
|
|
320
|
+
} else if (type === 'blur') {
|
|
321
|
+
this._backgroundCtx.filter = `blur(${blurLevel}px)`;
|
|
322
|
+
this._backgroundCtx.drawImage(this.offScreenVideo, 0, 0, width, height);
|
|
323
|
+
this._backgroundCtx.filter = 'none';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ===== STEP 3: Apply light wrapping =====
|
|
327
|
+
// Create a blurred version of background for light wrap
|
|
328
|
+
const lightWrapCanvas = document.createElement('canvas');
|
|
329
|
+
lightWrapCanvas.width = width;
|
|
330
|
+
lightWrapCanvas.height = height;
|
|
331
|
+
const lightWrapCtx = lightWrapCanvas.getContext('2d');
|
|
332
|
+
|
|
333
|
+
// Heavy blur on background for light wrap effect
|
|
334
|
+
lightWrapCtx.filter = 'blur(15px)';
|
|
335
|
+
lightWrapCtx.drawImage(this._backgroundCanvas, 0, 0);
|
|
336
|
+
lightWrapCtx.filter = 'none';
|
|
337
|
+
|
|
338
|
+
// Create inverted edge mask (for light wrap on edges only)
|
|
339
|
+
lightWrapCtx.globalCompositeOperation = 'destination-in';
|
|
340
|
+
lightWrapCtx.globalAlpha = this.lightWrapIntensity;
|
|
341
|
+
|
|
342
|
+
// Draw inverted mask to only affect edges
|
|
343
|
+
lightWrapCtx.filter = 'blur(8px) contrast(2)';
|
|
344
|
+
lightWrapCtx.drawImage(this._refinedMaskCanvas, 0, 0);
|
|
345
|
+
lightWrapCtx.filter = 'none';
|
|
346
|
+
lightWrapCtx.globalAlpha = 1.0;
|
|
347
|
+
|
|
348
|
+
// ===== STEP 4: Composite foreground =====
|
|
349
|
+
this._foregroundCtx.clearRect(0, 0, width, height);
|
|
350
|
+
|
|
351
|
+
// Draw person
|
|
352
|
+
this._foregroundCtx.drawImage(this.offScreenVideo, 0, 0, width, height);
|
|
353
|
+
|
|
354
|
+
// Apply refined mask using destination-in
|
|
355
|
+
this._foregroundCtx.globalCompositeOperation = 'destination-in';
|
|
356
|
+
this._foregroundCtx.drawImage(this._refinedMaskCanvas, 0, 0);
|
|
357
|
+
this._foregroundCtx.globalCompositeOperation = 'source-over';
|
|
358
|
+
|
|
359
|
+
// Add light wrap on foreground edges using screen blend
|
|
360
|
+
this._foregroundCtx.globalCompositeOperation = 'screen';
|
|
361
|
+
this._foregroundCtx.globalAlpha = 0.6;
|
|
362
|
+
this._foregroundCtx.drawImage(lightWrapCanvas, 0, 0);
|
|
363
|
+
this._foregroundCtx.globalAlpha = 1.0;
|
|
364
|
+
this._foregroundCtx.globalCompositeOperation = 'source-over';
|
|
365
|
+
|
|
366
|
+
// ===== STEP 5: Final composite =====
|
|
367
|
+
// Draw background
|
|
368
|
+
this.canvasCtx.drawImage(this._backgroundCanvas, 0, 0);
|
|
369
|
+
|
|
370
|
+
// Draw foreground with light wrap on top
|
|
371
|
+
this.canvasCtx.drawImage(this._foregroundCanvas, 0, 0);
|
|
372
|
+
|
|
373
|
+
this.canvasCtx.restore();
|
|
374
|
+
}
|
|
375
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video Meeting SDK - Main Entry Point
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* import { VideoMeetingClient } from '@unboundcx/video-sdk';
|
|
6
|
+
*
|
|
7
|
+
* const client = new VideoMeetingClient({
|
|
8
|
+
* serverUrl: 'wss://video.example.com',
|
|
9
|
+
* debug: true
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* await client.connect(authToken);
|
|
13
|
+
* await client.joinRoom(roomId);
|
|
14
|
+
* await client.publishCamera();
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Main client
|
|
18
|
+
export { VideoMeetingClient } from './VideoMeetingClient.js';
|
|
19
|
+
export { VideoProcessor } from './VideoProcessor.js';
|
|
20
|
+
|
|
21
|
+
// Managers (for advanced usage)
|
|
22
|
+
export { ConnectionManager } from './managers/ConnectionManager.js';
|
|
23
|
+
export { MediasoupManager } from './managers/MediasoupManager.js';
|
|
24
|
+
export { LocalMediaManager } from './managers/LocalMediaManager.js';
|
|
25
|
+
export { RemoteMediaManager } from './managers/RemoteMediaManager.js';
|
|
26
|
+
|
|
27
|
+
// Utilities
|
|
28
|
+
export { EventEmitter } from './utils/EventEmitter.js';
|
|
29
|
+
export { Logger } from './utils/Logger.js';
|
|
30
|
+
|
|
31
|
+
// Error classes
|
|
32
|
+
export {
|
|
33
|
+
SDKError,
|
|
34
|
+
ConnectionError,
|
|
35
|
+
MediasoupError, // Keep for backward compatibility, but also export as MediaTransportError
|
|
36
|
+
MediasoupError as MediaTransportError,
|
|
37
|
+
PermissionDeniedError,
|
|
38
|
+
DeviceNotFoundError,
|
|
39
|
+
StateError,
|
|
40
|
+
RoomError,
|
|
41
|
+
TimeoutError,
|
|
42
|
+
} from './utils/errors.js';
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { io } from 'socket.io-client';
|
|
2
|
+
import { EventEmitter } from '../utils/EventEmitter.js';
|
|
3
|
+
import { Logger } from '../utils/Logger.js';
|
|
4
|
+
import { ConnectionError, TimeoutError } from '../utils/errors.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Manages Socket.io connection and signaling with the video server
|
|
8
|
+
*
|
|
9
|
+
* Events:
|
|
10
|
+
* - 'connected' - Connected to server
|
|
11
|
+
* - 'disconnected' - Disconnected from server
|
|
12
|
+
* - 'error' - Connection error
|
|
13
|
+
* - 'message' - Received message from server
|
|
14
|
+
*/
|
|
15
|
+
export class ConnectionManager extends EventEmitter {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {string} options.serverUrl - WebSocket server URL
|
|
19
|
+
* @param {boolean} options.debug - Enable debug logging
|
|
20
|
+
*/
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super();
|
|
23
|
+
|
|
24
|
+
this.serverUrl = options.serverUrl;
|
|
25
|
+
this.logger = new Logger('SDK:ConnectionManager', options.debug);
|
|
26
|
+
this.socket = null;
|
|
27
|
+
this.isConnected = false;
|
|
28
|
+
this.reconnectAttempts = 0;
|
|
29
|
+
this.maxReconnectAttempts = 5;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Connect to the server
|
|
34
|
+
* @param {Object} auth - Authentication data
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async connect(auth = {}) {
|
|
38
|
+
if (this.isConnected) {
|
|
39
|
+
this.logger.warn('Already connected');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.logger.info('Connecting to server:', this.serverUrl);
|
|
44
|
+
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
try {
|
|
47
|
+
// Match the old working videoCreateSocket.js pattern exactly
|
|
48
|
+
// Keep it simple: just withCredentials and auth
|
|
49
|
+
const socketOptions = {
|
|
50
|
+
withCredentials: true,
|
|
51
|
+
auth,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
console.log('ConnectionManager :: Auth object being sent:', auth);
|
|
55
|
+
console.log('ConnectionManager :: Full socket options:', socketOptions);
|
|
56
|
+
|
|
57
|
+
this.logger.info('Creating socket.io connection with options:', {
|
|
58
|
+
serverUrl: this.serverUrl,
|
|
59
|
+
...socketOptions
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this.socket = io(this.serverUrl, socketOptions);
|
|
63
|
+
|
|
64
|
+
this.logger.info('Socket.io instance created, waiting for connect event');
|
|
65
|
+
|
|
66
|
+
// Setup event listeners
|
|
67
|
+
this.socket.on('connect', () => {
|
|
68
|
+
this.logger.info('Connected to server');
|
|
69
|
+
this.isConnected = true;
|
|
70
|
+
this.reconnectAttempts = 0;
|
|
71
|
+
this.emit('connected', { socketId: this.socket.id });
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.socket.on('disconnect', (reason) => {
|
|
76
|
+
this.logger.warn('Disconnected from server:', reason);
|
|
77
|
+
this.isConnected = false;
|
|
78
|
+
this.emit('disconnected', { reason });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.socket.on('connect_error', (error) => {
|
|
82
|
+
this.logger.error('Connection error:', error);
|
|
83
|
+
this.reconnectAttempts++;
|
|
84
|
+
|
|
85
|
+
const connectionError = new ConnectionError(
|
|
86
|
+
'Failed to connect to server',
|
|
87
|
+
{ error: error.message, attempts: this.reconnectAttempts }
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
this.emit('error', connectionError);
|
|
91
|
+
|
|
92
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
93
|
+
reject(connectionError);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Timeout after 10 seconds
|
|
98
|
+
const timeout = setTimeout(() => {
|
|
99
|
+
if (!this.isConnected) {
|
|
100
|
+
this.socket?.disconnect();
|
|
101
|
+
reject(new TimeoutError('connect', 10000));
|
|
102
|
+
}
|
|
103
|
+
}, 10000);
|
|
104
|
+
|
|
105
|
+
this.socket.once('connect', () => clearTimeout(timeout));
|
|
106
|
+
|
|
107
|
+
} catch (error) {
|
|
108
|
+
this.logger.error('Failed to create socket:', error);
|
|
109
|
+
reject(new ConnectionError('Failed to create socket connection', { error }));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Disconnect from server
|
|
116
|
+
* @returns {Promise<void>}
|
|
117
|
+
*/
|
|
118
|
+
async disconnect() {
|
|
119
|
+
if (!this.socket) {
|
|
120
|
+
this.logger.warn('No socket to disconnect');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.logger.info('Disconnecting from server');
|
|
125
|
+
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
this.socket.once('disconnect', () => {
|
|
128
|
+
this.isConnected = false;
|
|
129
|
+
this.socket = null;
|
|
130
|
+
resolve();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.socket.disconnect();
|
|
134
|
+
|
|
135
|
+
// Force resolve after 1 second
|
|
136
|
+
setTimeout(resolve, 1000);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Emit an event to the server and wait for response
|
|
142
|
+
* @param {string} event - Event name
|
|
143
|
+
* @param {*} data - Event data
|
|
144
|
+
* @param {number} timeout - Timeout in ms (default 5000)
|
|
145
|
+
* @returns {Promise<*>} Server response
|
|
146
|
+
*/
|
|
147
|
+
async request(event, data = {}, timeout = 5000) {
|
|
148
|
+
if (!this.isConnected || !this.socket) {
|
|
149
|
+
throw new ConnectionError('Not connected to server');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.logger.log('Request:', event, data);
|
|
153
|
+
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
reject(new TimeoutError(event, timeout));
|
|
157
|
+
}, timeout);
|
|
158
|
+
|
|
159
|
+
this.socket.emit(event, data, (response) => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
|
|
162
|
+
if (response?.error) {
|
|
163
|
+
this.logger.error('Request error:', event, response.error);
|
|
164
|
+
reject(new ConnectionError(
|
|
165
|
+
`Server error: ${response.error.message || 'Unknown error'}`,
|
|
166
|
+
{ event, error: response.error }
|
|
167
|
+
));
|
|
168
|
+
} else {
|
|
169
|
+
this.logger.log('Response:', event, response);
|
|
170
|
+
resolve(response);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Emit an event to server without waiting for response
|
|
178
|
+
* @param {string} event - Event name
|
|
179
|
+
* @param {*} data - Event data
|
|
180
|
+
*/
|
|
181
|
+
emit(event, data = {}) {
|
|
182
|
+
if (!this.isConnected || !this.socket) {
|
|
183
|
+
throw new ConnectionError('Not connected to server');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.logger.log('Emit:', event, data);
|
|
187
|
+
this.socket.emit(event, data);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Alias for emit (backward compatibility)
|
|
192
|
+
* @param {string} event - Event name
|
|
193
|
+
* @param {*} data - Event data
|
|
194
|
+
*/
|
|
195
|
+
send(event, data = {}) {
|
|
196
|
+
return this.emit(event, data);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Listen for server events
|
|
201
|
+
* @param {string} event - Event name
|
|
202
|
+
* @param {Function} handler - Event handler
|
|
203
|
+
* @returns {Function} Unsubscribe function
|
|
204
|
+
*/
|
|
205
|
+
onServerEvent(event, handler) {
|
|
206
|
+
if (!this.socket) {
|
|
207
|
+
throw new ConnectionError('Socket not initialized');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.logger.log('Listening for server event:', event);
|
|
211
|
+
this.socket.on(event, handler);
|
|
212
|
+
|
|
213
|
+
// Return unsubscribe function
|
|
214
|
+
return () => {
|
|
215
|
+
this.socket?.off(event, handler);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Remove server event listener
|
|
221
|
+
* @param {string} event - Event name
|
|
222
|
+
* @param {Function} handler - Event handler to remove
|
|
223
|
+
*/
|
|
224
|
+
offServerEvent(event, handler) {
|
|
225
|
+
this.socket?.off(event, handler);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get connection state
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
get connected() {
|
|
233
|
+
return this.isConnected && this.socket?.connected;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get socket ID
|
|
238
|
+
* @returns {string|null}
|
|
239
|
+
*/
|
|
240
|
+
get socketId() {
|
|
241
|
+
return this.socket?.id || null;
|
|
242
|
+
}
|
|
243
|
+
}
|