@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.
- package/README.md +330 -0
- package/dist/encryption/index.browser.cjs +13045 -0
- package/dist/encryption/index.browser.cjs.map +1 -0
- package/dist/encryption/index.browser.mjs +12959 -0
- package/dist/encryption/index.browser.mjs.map +1 -0
- package/dist/encryption/index.cjs +13045 -0
- package/dist/encryption/index.cjs.map +1 -0
- package/dist/encryption/index.d.mts +3 -0
- package/dist/encryption/index.d.ts +3 -0
- package/dist/encryption/index.mjs +12959 -0
- package/dist/encryption/index.mjs.map +1 -0
- package/dist/index-CcvHIY5q.d.mts +4988 -0
- package/dist/index-CcvHIY5q.d.ts +4988 -0
- package/dist/index.browser.cjs +20192 -5766
- package/dist/index.browser.cjs.map +1 -1
- package/dist/index.browser.full-bundle.min.js +20 -16
- package/dist/index.browser.full-bundle.min.js.map +1 -1
- package/dist/index.browser.mjs +20106 -5731
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.cjs +20191 -5765
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +15 -1337
- package/dist/index.d.ts +15 -1337
- package/dist/index.mjs +20106 -5731
- package/dist/index.mjs.map +1 -1
- package/dist/wasm_worker.worker.mjs +8 -4
- package/dist/wasm_worker.worker.mjs.map +1 -1
- package/package.json +21 -6
- package/public/e2ee-media-stream-worker.js +627 -0
- package/public/openmls_wasm_bg.wasm +0 -0
- package/src/attachment_utils.ts +0 -148
- package/src/auth.ts +0 -352
- package/src/channel.ts +0 -1879
- package/src/channel_state.ts +0 -612
- package/src/client.ts +0 -1759
- package/src/client_state.ts +0 -55
- package/src/connection.ts +0 -587
- package/src/ermis_call_node.ts +0 -1046
- package/src/errors.ts +0 -60
- package/src/events.ts +0 -46
- package/src/hevc_decoder_config.ts +0 -305
- package/src/index.ts +0 -17
- package/src/media_stream_receiver.ts +0 -593
- package/src/media_stream_sender.ts +0 -465
- package/src/shims/empty.ts +0 -1
- package/src/signal_message.ts +0 -171
- package/src/system_message.ts +0 -259
- package/src/token_manager.ts +0 -48
- package/src/types.ts +0 -594
- package/src/utils.ts +0 -553
- package/src/wasm/ermis_call_node_wasm.d.ts +0 -156
- package/src/wasm/ermis_call_node_wasm.js +0 -1568
- package/src/wasm_worker.ts +0 -219
- package/src/wasm_worker_proxy.ts +0 -244
package/src/ermis_call_node.ts
DELETED
|
@@ -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
|
-
}
|