@ermis-network/ermis-chat-sdk 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +330 -0
  2. package/dist/encryption/index.browser.cjs +13045 -0
  3. package/dist/encryption/index.browser.cjs.map +1 -0
  4. package/dist/encryption/index.browser.mjs +12959 -0
  5. package/dist/encryption/index.browser.mjs.map +1 -0
  6. package/dist/encryption/index.cjs +13045 -0
  7. package/dist/encryption/index.cjs.map +1 -0
  8. package/dist/encryption/index.d.mts +3 -0
  9. package/dist/encryption/index.d.ts +3 -0
  10. package/dist/encryption/index.mjs +12959 -0
  11. package/dist/encryption/index.mjs.map +1 -0
  12. package/dist/index-CcvHIY5q.d.mts +4988 -0
  13. package/dist/index-CcvHIY5q.d.ts +4988 -0
  14. package/dist/index.browser.cjs +20192 -5766
  15. package/dist/index.browser.cjs.map +1 -1
  16. package/dist/index.browser.full-bundle.min.js +20 -16
  17. package/dist/index.browser.full-bundle.min.js.map +1 -1
  18. package/dist/index.browser.mjs +20106 -5731
  19. package/dist/index.browser.mjs.map +1 -1
  20. package/dist/index.cjs +20191 -5765
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.mts +15 -1337
  23. package/dist/index.d.ts +15 -1337
  24. package/dist/index.mjs +20106 -5731
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/wasm_worker.worker.mjs +8 -4
  27. package/dist/wasm_worker.worker.mjs.map +1 -1
  28. package/package.json +21 -6
  29. package/public/e2ee-media-stream-worker.js +627 -0
  30. package/public/openmls_wasm_bg.wasm +0 -0
  31. package/src/attachment_utils.ts +0 -148
  32. package/src/auth.ts +0 -352
  33. package/src/channel.ts +0 -1879
  34. package/src/channel_state.ts +0 -612
  35. package/src/client.ts +0 -1759
  36. package/src/client_state.ts +0 -55
  37. package/src/connection.ts +0 -587
  38. package/src/ermis_call_node.ts +0 -1046
  39. package/src/errors.ts +0 -60
  40. package/src/events.ts +0 -46
  41. package/src/hevc_decoder_config.ts +0 -305
  42. package/src/index.ts +0 -17
  43. package/src/media_stream_receiver.ts +0 -593
  44. package/src/media_stream_sender.ts +0 -465
  45. package/src/shims/empty.ts +0 -1
  46. package/src/signal_message.ts +0 -171
  47. package/src/system_message.ts +0 -259
  48. package/src/token_manager.ts +0 -48
  49. package/src/types.ts +0 -594
  50. package/src/utils.ts +0 -553
  51. package/src/wasm/ermis_call_node_wasm.d.ts +0 -156
  52. package/src/wasm/ermis_call_node_wasm.js +0 -1568
  53. package/src/wasm_worker.ts +0 -219
  54. package/src/wasm_worker_proxy.ts +0 -244
@@ -1,1046 +0,0 @@
1
- import { ErmisChat } from './client';
2
- import { WasmWorkerProxy } from './wasm_worker_proxy';
3
- import {
4
- CallAction,
5
- CallEventData,
6
- CallStatus,
7
- DefaultGenerics,
8
- Event,
9
- ExtendableGenerics,
10
- Metadata,
11
- SignalData,
12
- UserCallInfo,
13
- } from './types';
14
- import { MediaStreamSender } from './media_stream_sender';
15
- import { MediaStreamReceiver } from './media_stream_receiver';
16
-
17
- export class ErmisCallNode<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> {
18
- wasmPath: string;
19
- workerPath: string;
20
-
21
- relayUrl = 'https://test-iroh.ermis.network.:8443';
22
-
23
- /** Reference to the Ermis Chat client instance */
24
- _client: ErmisChat<ErmisChatGenerics>;
25
-
26
- /** Unique identifier for the current call session */
27
- sessionID: string;
28
-
29
- /** Channel ID for communication between users */
30
- cid?: string;
31
-
32
- /** Type of call: 'audio' or 'video' */
33
- callType?: string;
34
-
35
- /** ID of the current user — always reads live value from client */
36
- get userID(): string | undefined {
37
- return this._client?.userID;
38
- }
39
-
40
- /** Current status of the call */
41
- callStatus? = '';
42
-
43
- metadata?: Metadata;
44
-
45
- callNode: WasmWorkerProxy | null = null;
46
-
47
- /** Local media stream from user's camera/microphone */
48
- localStream?: MediaStream | null = null;
49
-
50
- /** Remote media stream from the other participant */
51
- remoteStream?: MediaStream | null = null;
52
-
53
- /** Information about the caller */
54
- callerInfo?: UserCallInfo;
55
-
56
- /** Information about the call receiver */
57
- receiverInfo?: UserCallInfo;
58
-
59
- /** Callback triggered when call events occur (incoming/outgoing) */
60
- onCallEvent?: (data: CallEventData) => void;
61
-
62
- /** Callback triggered when local stream is available */
63
- onLocalStream?: (stream: MediaStream) => void;
64
-
65
- /** Callback triggered when remote stream is available */
66
- onRemoteStream?: (stream: MediaStream) => void;
67
-
68
- /** Callback for connection status message changes */
69
- onConnectionMessageChange?: (message: string | null) => void;
70
-
71
- /** Callback for call status changes */
72
- onCallStatus?: (status: string | null) => void;
73
-
74
- /** Callback for messages received through WebRTC data channel */
75
- onDataChannelMessage?: (data: any) => void;
76
-
77
- /** Callback for when a call is upgraded (e.g., audio to video) */
78
- onUpgradeCall?: (upgraderInfo: UserCallInfo) => void;
79
-
80
- /** Callback for screen sharing status changes */
81
- onScreenShareChange?: (isSharing: boolean) => void;
82
-
83
- /** Callback for error handling */
84
- onError?: (error: string) => void;
85
-
86
- /** Callback for device list changes */
87
- onDeviceChange?: (audioDevices: MediaDeviceInfo[], videoDevices: MediaDeviceInfo[]) => void;
88
-
89
- /** Available audio input devices */
90
- private availableAudioDevices: MediaDeviceInfo[] = [];
91
-
92
- /** Available video input devices */
93
- private availableVideoDevices: MediaDeviceInfo[] = [];
94
-
95
- /** Currently selected audio device ID */
96
- private selectedAudioDeviceId?: string;
97
-
98
- /** Currently selected video device ID */
99
- private selectedVideoDeviceId?: string;
100
-
101
- /** Timeout for ending call if not answered after a period */
102
- private missCallTimeout: ReturnType<typeof setTimeout> | null = null;
103
-
104
- /** Interval for sending health check via WebRTC */
105
- private healthCallInterval: ReturnType<typeof setInterval> | null = null;
106
-
107
- /** Interval for sending health check via server */
108
- private healthCallServerInterval: ReturnType<typeof setInterval> | null = null;
109
-
110
- /** Timeout for detecting if remote peer has disconnected */
111
- private healthCallTimeout: ReturnType<typeof setTimeout> | null = null;
112
-
113
- /** Timeout for showing warning when connection becomes unstable */
114
- private healthCallWarningTimeout: ReturnType<typeof setTimeout> | null = null;
115
-
116
- /** Handler for signal events */
117
- private signalHandler: any;
118
-
119
- /** Handler for connection change events */
120
- private connectionChangedHandler: any;
121
-
122
- /** Handler for message updated events */
123
- private messageUpdatedHandler: any;
124
-
125
- /** Flag indicating if the user is offline */
126
- private isOffline: boolean = false;
127
-
128
- /**
129
- * True if this call instance is destroyed (e.g., when another device accepts the call).
130
- * When true, SIGNAL_CALL events will be ignored.
131
- */
132
- private isDestroyed = false;
133
-
134
- public mediaSender: MediaStreamSender | null = null;
135
- public mediaReceiver: MediaStreamReceiver | null = null;
136
-
137
- constructor(
138
- client: ErmisChat<ErmisChatGenerics>,
139
- sessionID: string,
140
- wasmPath: string,
141
- relayUrl: string,
142
- workerPath?: string,
143
- ) {
144
- this._client = client;
145
- this.cid = '';
146
- this.callType = '';
147
- this.sessionID = sessionID;
148
- // userID is now a getter — reads from this._client.userID directly
149
- this.metadata = {};
150
- this.wasmPath = wasmPath;
151
- this.relayUrl = relayUrl;
152
- this.workerPath = workerPath || '/wasm_worker.worker.mjs';
153
-
154
- this.listenSocketEvents();
155
- this.setupDeviceChangeListener();
156
- this.loadWasm();
157
- }
158
-
159
- private async loadWasm(): Promise<void> {
160
- try {
161
- // Tạo Worker proxy — WASM chạy hoàn toàn trên Worker thread
162
- this.callNode = new WasmWorkerProxy(new URL(this.workerPath, window.location.origin));
163
- await this.callNode.init(this.wasmPath);
164
- } catch (error) {
165
- console.error('Failed to load ErmisCall WASM Worker:', error);
166
- throw error;
167
- }
168
- }
169
-
170
- private async initialize(): Promise<WasmWorkerProxy> {
171
- try {
172
- // Re-create Worker nếu đã bị terminate bởi call trước
173
- // (Worker mới nhưng dùng cached Blob URL + compiled WASM Module → rất nhanh)
174
- if (!this.callNode) {
175
- await this.loadWasm();
176
- }
177
-
178
- const proxy = this.callNode!;
179
-
180
- await proxy.spawn([this.relayUrl]);
181
-
182
- // 1. Init Sender — proxy implements INodeCall
183
- this.mediaSender = new MediaStreamSender(proxy as any);
184
-
185
- // 2. Init Receiver — proxy implements INodeCall
186
- this.mediaReceiver = new MediaStreamReceiver(proxy as any, {
187
- onConnected: () => {
188
- this.setCallStatus(CallStatus.CONNECTED);
189
- this.connectCall();
190
- if (this.missCallTimeout) {
191
- clearTimeout(this.missCallTimeout);
192
- this.missCallTimeout = null;
193
- }
194
- if (this.healthCallServerInterval) clearInterval(this.healthCallServerInterval);
195
- this.healthCallServerInterval = setInterval(() => {
196
- this.healthCall();
197
- }, 10000);
198
-
199
- const remoteStream = this.mediaReceiver?.getRemoteStream();
200
-
201
- if (remoteStream && this.onRemoteStream) {
202
- this.onRemoteStream(remoteStream);
203
- }
204
- },
205
-
206
- onTransceiverState: (state) => {
207
- if (typeof this.onDataChannelMessage === 'function') {
208
- this.onDataChannelMessage(state);
209
- }
210
- },
211
-
212
- onRequestConfig: () => {
213
- console.log('📤 Responding to REQUEST_CONFIG by sending configs');
214
- this.mediaSender?.sendConfigs();
215
- },
216
-
217
- onRequestKeyFrame: () => {
218
- console.log('📤 Responding to REQUEST_KEY_FRAME by forcing key frame');
219
- this.mediaSender?.requestKeyFrame();
220
- },
221
-
222
- onEndCall: () => {
223
- console.log('📥 Received END_CALL from remote peer');
224
- this.destroy();
225
- },
226
- });
227
-
228
- // Bắt đầu recv loop trong Worker
229
- await proxy.startRecvLoop();
230
-
231
- return proxy;
232
- } catch (error) {
233
- console.error('Failed to initialize Ermis SDK:', error);
234
- throw error;
235
- }
236
- }
237
-
238
- public async getLocalEndpointAddr(): Promise<string | null> {
239
- try {
240
- await this.initialize();
241
-
242
- if (!this.callNode) {
243
- console.error('ErmisCall is not initialized.');
244
- return null;
245
- }
246
-
247
- const address = await this.callNode.getLocalEndpointAddr();
248
- if (this.metadata) {
249
- this.metadata.address = address;
250
- }
251
- return address;
252
- } catch (error) {
253
- console.error('Failed to get address from ErmisCall:', error);
254
- return null;
255
- }
256
- }
257
-
258
- private getClient(): ErmisChat<ErmisChatGenerics> {
259
- return this._client;
260
- }
261
-
262
- private async _sendSignal(payload: SignalData) {
263
- try {
264
- return await this.getClient().post(this.getClient().baseURL + '/signal', {
265
- ...payload,
266
- cid: this.cid || payload.cid,
267
- is_video: this.callType === 'video' || payload.is_video,
268
- ios: false,
269
- session_id: this.sessionID,
270
- });
271
- } catch (error: any) {
272
- const action = payload.action;
273
-
274
- // Skip error message for HEALTH_CALL action
275
- if (action === CallAction.HEALTH_CALL) {
276
- return;
277
- }
278
-
279
- if (typeof this.onError === 'function') {
280
- if (error.code === 'ERR_NETWORK') {
281
- if (action === CallAction.CREATE_CALL) {
282
- this.onError('call_network_error');
283
- }
284
- } else {
285
- if (error.response.data.ermis_code === 20) {
286
- this.onError('call_recipient_busy');
287
- } else {
288
- const errMsg = error.response.data?.message ? error.response.data?.message : 'call_failed';
289
- this.onError(errMsg);
290
- }
291
- }
292
- }
293
- }
294
- }
295
-
296
- private async getAvailableDevices(): Promise<{ audioDevices: MediaDeviceInfo[]; videoDevices: MediaDeviceInfo[] }> {
297
- try {
298
- const devices = await navigator.mediaDevices.enumerateDevices();
299
-
300
- const audioDevices = devices.filter((device) => device.kind === 'audioinput');
301
- const videoDevices = devices.filter((device) => device.kind === 'videoinput');
302
-
303
- this.availableAudioDevices = audioDevices;
304
- this.availableVideoDevices = videoDevices;
305
-
306
- return { audioDevices, videoDevices };
307
- } catch (error) {
308
- console.error('Error enumerating devices:', error);
309
- return { audioDevices: [], videoDevices: [] };
310
- }
311
- }
312
-
313
- private async getMediaConstraints() {
314
- // Get available devices first
315
- const { audioDevices, videoDevices } = await this.getAvailableDevices();
316
-
317
- // Notify UI about available devices
318
- if (this.onDeviceChange) {
319
- this.onDeviceChange(audioDevices, videoDevices);
320
- }
321
-
322
- // Auto-select default devices if none selected
323
- if (!this.selectedAudioDeviceId && audioDevices.length > 0) {
324
- this.selectedAudioDeviceId = audioDevices[0].deviceId;
325
- }
326
- if (!this.selectedVideoDeviceId && videoDevices.length > 0) {
327
- this.selectedVideoDeviceId = videoDevices[0].deviceId;
328
- }
329
-
330
- // Build constraints with specific device IDs if selected
331
- const audioConstraints = {
332
- deviceId: this.selectedAudioDeviceId ? { exact: this.selectedAudioDeviceId } : undefined,
333
- echoCancellation: true,
334
- noiseSuppression: true,
335
- sampleRate: 48000,
336
- };
337
-
338
- const videoConstraints =
339
- this.callType === 'video'
340
- ? {
341
- deviceId: this.selectedVideoDeviceId ? { exact: this.selectedVideoDeviceId } : undefined,
342
- width: 640,
343
- height: 360,
344
- }
345
- : false;
346
-
347
- const finalConstraints: MediaStreamConstraints = {
348
- audio: audioConstraints,
349
- video: videoConstraints,
350
- };
351
-
352
- return finalConstraints;
353
- }
354
-
355
- public async startLocalStream() {
356
- const mediaConstraints = await this.getMediaConstraints();
357
-
358
- try {
359
- const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
360
- return this.applyLocalStream(stream);
361
- } catch (error: any) {
362
- console.warn('Error getting user media:', error?.message);
363
-
364
- // Video call: try fallback to audio-only (camera not available)
365
- if (this.callType === 'video' && mediaConstraints.video) {
366
- try {
367
- const audioOnlyStream = await navigator.mediaDevices.getUserMedia({
368
- audio: mediaConstraints.audio,
369
- video: false,
370
- });
371
- this.setConnectionMessage('Camera not available, using audio only');
372
- return this.applyLocalStream(audioOnlyStream);
373
- } catch {
374
- // Audio fallback also failed
375
- }
376
- }
377
-
378
- // No device found at all — report error
379
- if (typeof this.onError === 'function') {
380
- this.onError('call_no_devices');
381
- }
382
- return null;
383
- }
384
- }
385
-
386
- private applyLocalStream(stream: MediaStream) {
387
- if (this.callStatus === CallStatus.ENDED) {
388
- stream.getTracks().forEach((track) => track.stop());
389
- this.destroy();
390
- return;
391
- }
392
- if (this.onLocalStream) {
393
- this.onLocalStream(stream);
394
- }
395
- this.localStream = stream;
396
- return stream;
397
- }
398
-
399
- private setConnectionMessage(message: string | null) {
400
- if (typeof this.onConnectionMessageChange === 'function') {
401
- this.onConnectionMessageChange(message);
402
- }
403
- }
404
-
405
- private setCallStatus(status: CallStatus) {
406
- this.callStatus = status;
407
- if (typeof this.onCallStatus === 'function') {
408
- this.onCallStatus(status);
409
- }
410
- }
411
-
412
- private setUserInfo(cid: string | undefined, eventUserId: string | undefined) {
413
- if (!cid || !eventUserId) return;
414
-
415
- const channel = cid ? this.getClient().activeChannels[cid] : undefined;
416
- const stateMembers = channel?.state?.members || {};
417
- const memberIds = Object.keys(stateMembers);
418
-
419
- const callerId = eventUserId || '';
420
- const receiverId = memberIds.find((id) => id !== callerId) || '';
421
-
422
- // Try multiple sources for user info (in order of reliability):
423
- // 1. channel.data.members (enriched array from watch/query — most reliable)
424
- // 2. channel.state.members[id].user (may be overwritten by async event handlers)
425
- // 3. client.state.users (may not be populated due to disabled updateUser in updateUserReference)
426
- const dataMembers: any[] = (channel?.data as any)?.members || [];
427
-
428
- const findUserFromDataMembers = (userId: string) => {
429
- const member = dataMembers.find((m: any) => m.user_id === userId || m.user?.id === userId);
430
- return member?.user;
431
- };
432
-
433
- const callerUser =
434
- findUserFromDataMembers(callerId) ||
435
- (stateMembers[callerId] as any)?.user ||
436
- this.getClient().state.users[callerId];
437
- const receiverUser =
438
- findUserFromDataMembers(receiverId) ||
439
- (stateMembers[receiverId] as any)?.user ||
440
- this.getClient().state.users[receiverId];
441
-
442
- this.callerInfo = {
443
- id: callerId,
444
- name: callerUser?.name || callerId,
445
- avatar: callerUser?.avatar || '',
446
- };
447
- this.receiverInfo = {
448
- id: receiverId,
449
- name: receiverUser?.name || receiverId,
450
- avatar: receiverUser?.avatar || '',
451
- };
452
- }
453
-
454
- private listenSocketEvents() {
455
- this.signalHandler = async (event: Event<ErmisChatGenerics>) => {
456
- const { action, user_id: eventUserId, session_id: eventSessionId, cid, is_video, signal, metadata } = event;
457
-
458
- switch (action) {
459
- case CallAction.CREATE_CALL:
460
- if (eventUserId === this.userID && eventSessionId !== this.sessionID) {
461
- // If the event is triggered by the current user but the session ID is different,
462
- // it means another device (or tab) of the same user has started a call.
463
- // In this case, mark this call instance as destroyed and ignore further events.
464
- this.isDestroyed = true;
465
- this.destroy();
466
- return;
467
- }
468
- this.isDestroyed = false;
469
- this.callStatus = '';
470
- this.callType = is_video ? 'video' : 'audio';
471
-
472
- this.setUserInfo(cid, eventUserId);
473
- this.cid = cid || '';
474
- this.metadata = metadata || {};
475
-
476
- if (typeof this.onCallEvent === 'function') {
477
- this.onCallEvent({
478
- type: eventUserId !== this.userID ? 'incoming' : 'outgoing',
479
- callType: is_video ? 'video' : 'audio',
480
- cid: cid || '',
481
- callerInfo: this.callerInfo,
482
- receiverInfo: this.receiverInfo,
483
- metadata: this.metadata,
484
- });
485
- }
486
-
487
- this.setCallStatus(CallStatus.RINGING);
488
-
489
- await this.startLocalStream();
490
- if (this.callStatus === CallStatus.ENDED) return;
491
-
492
- if (eventUserId !== this.userID) {
493
- await this.initialize();
494
- }
495
-
496
- if (eventUserId === this.userID) {
497
- // Set missCall timeout if no connection after 60s
498
- if (this.missCallTimeout) clearTimeout(this.missCallTimeout);
499
- this.missCallTimeout = setTimeout(async () => {
500
- await this.missCall();
501
- }, 60000);
502
- }
503
- break;
504
-
505
- case CallAction.ACCEPT_CALL:
506
- if (eventUserId === this.userID && eventSessionId !== this.sessionID) {
507
- this.isDestroyed = true;
508
- this.destroy();
509
- return;
510
- }
511
-
512
- if (eventUserId !== this.userID && !this.isDestroyed) {
513
- // Caller side: establish peer connection FIRST
514
- if (this.mediaReceiver && this.mediaSender) {
515
- await this.mediaReceiver.acceptConnection();
516
- await this.mediaSender.sendConnected();
517
- }
518
-
519
- // Then init encoders/decoders (safe to sendControlFrame now)
520
- if (this.localStream && this.mediaSender && this.mediaReceiver && this.callType) {
521
- this.mediaSender?.initEncoders(this.localStream);
522
- this.mediaReceiver?.initDecoders(this.callType);
523
- }
524
-
525
- // Re-send configs after encoders have populated them
526
- if (this.mediaSender) {
527
- await this.mediaSender.sendConfigs();
528
- }
529
- }
530
- break;
531
-
532
- case CallAction.END_CALL:
533
- case CallAction.REJECT_CALL:
534
- case CallAction.MISS_CALL:
535
- // this.setCallStatus(CallStatus.ENDED);
536
- await this.destroy();
537
- break;
538
- }
539
- };
540
-
541
- this.connectionChangedHandler = (event: Event<ErmisChatGenerics>) => {
542
- const online = event.online;
543
- this.isOffline = !online;
544
- if (!online) {
545
- this.setConnectionMessage('Your network connection is unstable');
546
-
547
- // Clear health_call intervals when offline
548
- if (this.healthCallInterval) {
549
- clearInterval(this.healthCallInterval);
550
- this.healthCallInterval = null;
551
- }
552
- if (this.healthCallServerInterval) {
553
- clearInterval(this.healthCallServerInterval);
554
- this.healthCallServerInterval = null;
555
- }
556
- } else {
557
- this.setConnectionMessage(null);
558
-
559
- // When back online, if CONNECTED, set up health_call intervals again
560
- if (this.callStatus === CallStatus.CONNECTED) {
561
- if (!this.healthCallServerInterval) {
562
- this.healthCallServerInterval = setInterval(() => {
563
- this.healthCall();
564
- }, 10000);
565
- }
566
- }
567
- }
568
- };
569
-
570
- this.messageUpdatedHandler = (event: Event<ErmisChatGenerics>) => {
571
- if (this.callStatus === CallStatus.CONNECTED && event.cid === this.cid) {
572
- const upgradeUserId = event.user?.id;
573
-
574
- let upgraderInfo: UserCallInfo | undefined;
575
-
576
- if (upgradeUserId === this.callerInfo?.id) {
577
- upgraderInfo = this.callerInfo;
578
- } else if (upgradeUserId === this.receiverInfo?.id) {
579
- upgraderInfo = this.receiverInfo;
580
- }
581
-
582
- if (upgraderInfo && typeof this.onUpgradeCall === 'function') {
583
- this.onUpgradeCall(upgraderInfo);
584
- }
585
- }
586
- };
587
-
588
- this._client.on('signal', this.signalHandler);
589
- this._client.on('connection.changed', this.connectionChangedHandler);
590
- this._client.on('message.updated', this.messageUpdatedHandler);
591
- }
592
-
593
- private async cleanupCall() {
594
- if (this.mediaSender) {
595
- this.mediaSender?.stop();
596
- this.mediaSender = null;
597
- }
598
- if (this.mediaReceiver) {
599
- this.mediaReceiver.stop();
600
- this.mediaReceiver = null;
601
- }
602
-
603
- if (this.callNode) {
604
- try {
605
- // Timeout protection: don't let terminate() hang forever
606
- await Promise.race([this.callNode.terminate(), new Promise((resolve) => setTimeout(resolve, 1000))]);
607
- } catch {
608
- /* ignore — Worker may already be dead */
609
- }
610
- this.callNode = null;
611
- }
612
-
613
- // Clear all timeouts and intervals
614
- if (this.missCallTimeout) {
615
- clearTimeout(this.missCallTimeout);
616
- this.missCallTimeout = null;
617
- }
618
-
619
- if (this.healthCallInterval) {
620
- clearInterval(this.healthCallInterval);
621
- this.healthCallInterval = null;
622
- }
623
-
624
- if (this.healthCallServerInterval) {
625
- clearInterval(this.healthCallServerInterval);
626
- this.healthCallServerInterval = null;
627
- }
628
-
629
- if (this.healthCallTimeout) {
630
- clearTimeout(this.healthCallTimeout);
631
- this.healthCallTimeout = null;
632
- }
633
-
634
- if (this.healthCallWarningTimeout) {
635
- clearTimeout(this.healthCallWarningTimeout);
636
- this.healthCallWarningTimeout = null;
637
- }
638
-
639
- // this.setCallStatus(CallStatus.ENDED);
640
- this.setConnectionMessage(null);
641
- this.cid = '';
642
- this.callType = '';
643
- this.metadata = {};
644
-
645
- if (this.localStream) {
646
- this.localStream.getTracks().forEach((track) => track.stop());
647
- this.localStream = null;
648
- }
649
-
650
- if (this.remoteStream) {
651
- this.remoteStream.getTracks().forEach((track) => track.stop());
652
- this.remoteStream = null;
653
- }
654
-
655
- this.setCallStatus(CallStatus.ENDED);
656
- }
657
-
658
- public async destroy() {
659
- // if (this.signalHandler) this._client.off('signal', this.signalHandler);
660
- // if (this.connectionChangedHandler) this._client.off('connection.changed', this.connectionChangedHandler);
661
- // if (this.messageUpdatedHandler) this._client.off('message.updated', this.messageUpdatedHandler);
662
- await this.cleanupCall();
663
- }
664
-
665
- public async getDevices(): Promise<{ audioDevices: MediaDeviceInfo[]; videoDevices: MediaDeviceInfo[] }> {
666
- // Return cached devices if available, otherwise fetch new ones
667
- if (this.availableAudioDevices.length > 0 || this.availableVideoDevices.length > 0) {
668
- return {
669
- audioDevices: this.availableAudioDevices,
670
- videoDevices: this.availableVideoDevices,
671
- };
672
- }
673
- return await this.getAvailableDevices();
674
- }
675
-
676
- // Get current selected devices info
677
- public getSelectedDevices(): { audioDevice?: MediaDeviceInfo; videoDevice?: MediaDeviceInfo } {
678
- const audioDevice = this.selectedAudioDeviceId
679
- ? this.availableAudioDevices.find((device) => device.deviceId === this.selectedAudioDeviceId)
680
- : undefined;
681
-
682
- const videoDevice = this.selectedVideoDeviceId
683
- ? this.availableVideoDevices.find((device) => device.deviceId === this.selectedVideoDeviceId)
684
- : undefined;
685
-
686
- return { audioDevice, videoDevice };
687
- }
688
-
689
- // Get default devices (first available device)
690
- public getDefaultDevices(): { audioDevice?: MediaDeviceInfo; videoDevice?: MediaDeviceInfo } {
691
- return {
692
- audioDevice: this.availableAudioDevices[0],
693
- videoDevice: this.availableVideoDevices[0],
694
- };
695
- }
696
-
697
- public prefillUserInfo(cid: string) {
698
- this.setUserInfo(cid, this.userID);
699
- }
700
-
701
- public async createCall(callType: string, cid: string) {
702
- try {
703
- this.cid = cid;
704
- this.callType = callType;
705
- this.prefillUserInfo(cid);
706
-
707
- const address = await this.getLocalEndpointAddr();
708
-
709
- await this._sendSignal({
710
- action: CallAction.CREATE_CALL,
711
- cid,
712
- is_video: callType === 'video',
713
- metadata: { address },
714
- });
715
- } catch (error) {
716
- console.error('Failed to create call:', error);
717
- throw error;
718
- }
719
- }
720
-
721
- public async acceptCall() {
722
- try {
723
- await this._sendSignal({ action: CallAction.ACCEPT_CALL });
724
-
725
- // Receiver side: establish peer connection FIRST
726
- if (this.mediaSender) {
727
- const address = this.metadata?.address || '';
728
- await this.mediaSender.connect(address);
729
- }
730
-
731
- // Then init encoders/decoders (safe to sendControlFrame now)
732
- if (this.localStream && this.mediaSender && this.mediaReceiver) {
733
- this.mediaSender?.initEncoders(this.localStream);
734
- this.mediaReceiver?.initDecoders(this.callType || 'audio');
735
- }
736
-
737
- // Re-send configs after encoders have populated them
738
- if (this.mediaSender) {
739
- await this.mediaSender.sendConfigs();
740
- }
741
- } catch (error) {
742
- console.error('Failed to accept call:', error);
743
- throw error;
744
- }
745
- }
746
-
747
- public async endCall() {
748
- await this._sendSignal({ action: CallAction.END_CALL });
749
- this.destroy();
750
- }
751
-
752
- public async rejectCall() {
753
- await this._sendSignal({ action: CallAction.REJECT_CALL });
754
- this.destroy();
755
- }
756
-
757
- private async missCall() {
758
- await this._sendSignal({ action: CallAction.MISS_CALL });
759
- this.destroy();
760
- }
761
-
762
- private async connectCall() {
763
- return await this._sendSignal({ action: CallAction.CONNECT_CALL });
764
- }
765
-
766
- private async healthCall() {
767
- return await this._sendSignal({ action: CallAction.HEALTH_CALL });
768
- }
769
-
770
- private async addVideoTrackToLocalStream() {
771
- const mediaConstraints = await this.getMediaConstraints();
772
- const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
773
- const newVideoTrack = stream.getVideoTracks()[0];
774
- if (this.localStream) {
775
- this.localStream.addTrack(newVideoTrack);
776
-
777
- if (this.onLocalStream) {
778
- this.onLocalStream(this.localStream);
779
- }
780
- } else {
781
- this.localStream = stream;
782
- if (this.onLocalStream) {
783
- this.onLocalStream(this.localStream);
784
- }
785
- }
786
- }
787
-
788
- public async upgradeCall() {
789
- try {
790
- this.callType = 'video';
791
- await this.addVideoTrackToLocalStream();
792
- await this._sendSignal({ action: CallAction.UPGRADE_CALL });
793
-
794
- if (this.localStream) {
795
- this.mediaSender?.initVideoEncoder(this.localStream?.getVideoTracks()[0]);
796
- const audioEnable = !!this.localStream?.getAudioTracks().some((track) => track.enabled);
797
- const videoEnable = !!this.localStream?.getVideoTracks().some((track) => track.enabled);
798
- await this.mediaSender?.sendTransceiverState(audioEnable, videoEnable);
799
- }
800
- } catch (error) {
801
- console.error('Failed to upgrade call:', error);
802
- throw error;
803
- }
804
- }
805
-
806
- public async requestUpgradeCall(enabled: boolean) {
807
- if (enabled) {
808
- this.callType = 'video';
809
- await this.addVideoTrackToLocalStream();
810
-
811
- if (this.localStream) {
812
- this.mediaSender?.initVideoEncoder(this.localStream?.getVideoTracks()[0]);
813
- const audioEnable = !!this.localStream?.getAudioTracks().some((track) => track.enabled);
814
- const videoEnable = !!this.localStream?.getVideoTracks().some((track) => track.enabled);
815
- await this.mediaSender?.sendTransceiverState(audioEnable, videoEnable);
816
- }
817
- }
818
- }
819
-
820
- public async startScreenShare() {
821
- // @ts-ignore
822
- if (!navigator.mediaDevices.getDisplayMedia) {
823
- throw new Error('Screen sharing is not supported in this browser.');
824
- }
825
-
826
- // @ts-ignore
827
- const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
828
- const screenTrack = screenStream.getVideoTracks()[0];
829
-
830
- // Replace video track in localStream
831
- if (this.localStream) {
832
- // Stop old track
833
- this.localStream.getVideoTracks().forEach((track) => track.stop());
834
- // Add new track to localStream
835
- this.localStream.removeTrack(this.localStream.getVideoTracks()[0]);
836
- this.localStream.addTrack(screenTrack);
837
- } else {
838
- // If no localStream, create new one
839
- this.localStream = screenStream;
840
- }
841
-
842
- // When screen sharing stops, automatically switch back to camera
843
- screenTrack.onended = () => {
844
- this.stopScreenShare();
845
- };
846
-
847
- // Call callback if UI needs to update
848
- if (this.onLocalStream) {
849
- // @ts-ignore
850
- this.onLocalStream(this.localStream);
851
-
852
- this.mediaSender?.replaceVideoTrack(this.localStream.getVideoTracks()[0]);
853
- }
854
-
855
- // Call callback when screen sharing starts
856
- if (typeof this.onScreenShareChange === 'function') {
857
- this.onScreenShareChange(true);
858
- }
859
- }
860
-
861
- public async stopScreenShare() {
862
- const mediaConstraints = await this.getMediaConstraints();
863
-
864
- try {
865
- // Only request video; we already have an active audio track in localStream
866
- const cameraStream = await navigator.mediaDevices.getUserMedia({
867
- video: mediaConstraints.video,
868
- audio: false,
869
- });
870
- const cameraTrack = cameraStream.getVideoTracks()[0];
871
-
872
- // Replace video track in localStream
873
- if (this.localStream) {
874
- // Stop old screen tracks
875
- this.localStream.getVideoTracks().forEach((track) => {
876
- track.stop();
877
- this.localStream?.removeTrack(track);
878
- });
879
-
880
- // Add new camera track
881
- this.localStream.addTrack(cameraTrack);
882
- } else {
883
- this.localStream = cameraStream;
884
- }
885
-
886
- // Call callback if UI needs to update
887
- if (this.onLocalStream) {
888
- this.onLocalStream(this.localStream);
889
- this.mediaSender?.replaceVideoTrack(this.localStream.getVideoTracks()[0]);
890
- }
891
-
892
- // Call callback when screen sharing stops
893
- if (typeof this.onScreenShareChange === 'function') {
894
- this.onScreenShareChange(false);
895
- }
896
- } catch (error) {
897
- console.error('Error stopping screen share and reverting to camera:', error);
898
- }
899
- }
900
-
901
- public async toggleMic(enabled: boolean) {
902
- if (this.localStream) {
903
- this.localStream.getAudioTracks().forEach((track) => {
904
- track.enabled = enabled;
905
- });
906
-
907
- const audioEnable = enabled;
908
- const videoEnable = this.localStream.getVideoTracks().some((track) => track.enabled);
909
- await this.mediaSender?.sendTransceiverState(audioEnable, videoEnable);
910
- }
911
- }
912
-
913
- public async toggleCamera(enabled: boolean) {
914
- if (this.localStream) {
915
- this.localStream.getVideoTracks().forEach((track) => {
916
- track.enabled = enabled;
917
- });
918
-
919
- const audioEnable = this.localStream.getAudioTracks().some((track) => track.enabled);
920
- const videoEnable = enabled;
921
- await this.mediaSender?.sendTransceiverState(audioEnable, videoEnable);
922
- }
923
- }
924
-
925
- // Public method to switch audio device
926
- public async switchAudioDevice(deviceId: string): Promise<boolean> {
927
- try {
928
- // Validate device exists in available devices
929
- const targetDevice = this.availableAudioDevices.find((device) => device.deviceId === deviceId);
930
- if (!targetDevice) {
931
- console.error('Audio device not found:', deviceId);
932
- if (this.onError) {
933
- this.onError('Selected microphone not found');
934
- }
935
- return false;
936
- }
937
-
938
- this.selectedAudioDeviceId = deviceId;
939
-
940
- if (!this.localStream) return false;
941
-
942
- // Lấy lại cấu hình chuẩn để không mất khử ồn, channelCount, v.v.
943
- const mediaConstraints = await this.getMediaConstraints();
944
-
945
- // Get new audio stream with selected device
946
- const newStream = await navigator.mediaDevices.getUserMedia({
947
- audio: mediaConstraints.audio,
948
- video: false,
949
- });
950
-
951
- const newAudioTrack = newStream.getAudioTracks()[0];
952
- const oldAudioTrack = this.localStream.getAudioTracks()[0];
953
-
954
- // Replace audio track in custom encoder pipeline
955
- if (this.mediaSender && newAudioTrack) {
956
- await this.mediaSender.replaceAudioTrack(newAudioTrack);
957
- }
958
-
959
- // Replace audio track in local stream
960
- if (oldAudioTrack) {
961
- this.localStream.removeTrack(oldAudioTrack);
962
- oldAudioTrack.stop();
963
- }
964
- this.localStream.addTrack(newAudioTrack);
965
-
966
- // Update UI
967
- if (this.onLocalStream) {
968
- this.onLocalStream(this.localStream);
969
- }
970
-
971
- return true;
972
- } catch (error) {
973
- console.error('Error switching audio device:', error);
974
- if (this.onError) {
975
- this.onError('Failed to switch microphone');
976
- }
977
- return false;
978
- }
979
- }
980
-
981
- // Public method to switch video device
982
- public async switchVideoDevice(deviceId: string): Promise<boolean> {
983
- try {
984
- // Validate device exists in available devices
985
- const targetDevice = this.availableVideoDevices.find((device) => device.deviceId === deviceId);
986
- if (!targetDevice) {
987
- console.error('Video device not found:', deviceId);
988
- if (this.onError) {
989
- this.onError('Selected camera not found');
990
- }
991
- return false;
992
- }
993
-
994
- this.selectedVideoDeviceId = deviceId;
995
-
996
- if (!this.localStream) return false;
997
-
998
- // Lấy lại cấu hình chuẩn để không mất độ phân giải
999
- const mediaConstraints = await this.getMediaConstraints();
1000
-
1001
- // Get new video stream with selected device
1002
- const newStream = await navigator.mediaDevices.getUserMedia({
1003
- audio: false,
1004
- video: mediaConstraints.video,
1005
- });
1006
-
1007
- const newVideoTrack = newStream.getVideoTracks()[0];
1008
- const oldVideoTrack = this.localStream.getVideoTracks()[0];
1009
-
1010
- // Replace video track in custom encoder pipeline
1011
- if (this.mediaSender && newVideoTrack) {
1012
- await this.mediaSender.replaceVideoTrack(newVideoTrack);
1013
- }
1014
-
1015
- // Replace video track in local stream
1016
- if (oldVideoTrack) {
1017
- this.localStream.removeTrack(oldVideoTrack);
1018
- oldVideoTrack.stop();
1019
- }
1020
- this.localStream.addTrack(newVideoTrack);
1021
-
1022
- // Update UI
1023
- if (this.onLocalStream) {
1024
- this.onLocalStream(this.localStream);
1025
- }
1026
-
1027
- return true;
1028
- } catch (error) {
1029
- console.error('Error switching video device:', error);
1030
- if (this.onError) {
1031
- this.onError('Failed to switch camera');
1032
- }
1033
- return false;
1034
- }
1035
- }
1036
-
1037
- // Listen for device changes
1038
- private setupDeviceChangeListener() {
1039
- navigator.mediaDevices.addEventListener('devicechange', async () => {
1040
- const { audioDevices, videoDevices } = await this.getAvailableDevices();
1041
- if (this.onDeviceChange) {
1042
- this.onDeviceChange(audioDevices, videoDevices);
1043
- }
1044
- });
1045
- }
1046
- }