call-engine-wx 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2372 @@
1
+ /*! call-engine-wx | (c) 2024 Tencent | MIT */
2
+ import TRTCCloud from '@tencentcloud/trtc-component-wx';
3
+ import TencentCloudChat from '@tencentcloud/lite-chat';
4
+ export { default as TencentCloudChat } from '@tencentcloud/lite-chat';
5
+
6
+ class EventEmitter {
7
+ constructor() {
8
+ this._handlers = new Map();
9
+ }
10
+ on(event, fn) {
11
+ if (typeof fn !== 'function')
12
+ return this;
13
+ let list = this._handlers.get(event);
14
+ if (!list) {
15
+ list = [];
16
+ this._handlers.set(event, list);
17
+ }
18
+ list.push({ fn, once: false });
19
+ return this;
20
+ }
21
+ once(event, fn) {
22
+ if (typeof fn !== 'function')
23
+ return this;
24
+ let list = this._handlers.get(event);
25
+ if (!list) {
26
+ list = [];
27
+ this._handlers.set(event, list);
28
+ }
29
+ list.push({ fn, once: true });
30
+ return this;
31
+ }
32
+ off(event, fn) {
33
+ if (!event) {
34
+ this._handlers.clear();
35
+ return this;
36
+ }
37
+ const list = this._handlers.get(event);
38
+ if (!list)
39
+ return this;
40
+ if (!fn) {
41
+ this._handlers.delete(event);
42
+ return this;
43
+ }
44
+ const next = list.filter((h) => h.fn !== fn);
45
+ if (next.length === 0) {
46
+ this._handlers.delete(event);
47
+ }
48
+ else {
49
+ this._handlers.set(event, next);
50
+ }
51
+ return this;
52
+ }
53
+ emit(event, ...args) {
54
+ const list = this._handlers.get(event);
55
+ if (!list || list.length === 0)
56
+ return false;
57
+ // Snapshot — listener may mutate the list via off/once.
58
+ const snapshot = list.slice();
59
+ for (const h of snapshot) {
60
+ try {
61
+ h.fn.apply(null, args);
62
+ }
63
+ catch (err) {
64
+ // Never let one bad listener break the rest.
65
+ // eslint-disable-next-line no-console
66
+ console.error('[call-engine-wx] listener threw:', err);
67
+ }
68
+ if (h.once)
69
+ this.off(event, h.fn);
70
+ }
71
+ return true;
72
+ }
73
+ removeAllListeners(event) {
74
+ if (event) {
75
+ this._handlers.delete(event);
76
+ }
77
+ else {
78
+ this._handlers.clear();
79
+ }
80
+ return this;
81
+ }
82
+ listenerCount(event) {
83
+ const list = this._handlers.get(event);
84
+ return list ? list.length : 0;
85
+ }
86
+ }
87
+
88
+ // ====================== enum =======================
89
+ var CallStatus;
90
+ (function (CallStatus) {
91
+ CallStatus[CallStatus["NONE"] = 0] = "NONE";
92
+ CallStatus[CallStatus["WAITING"] = 1] = "WAITING";
93
+ CallStatus[CallStatus["CALLING"] = 2] = "CALLING";
94
+ })(CallStatus || (CallStatus = {}));
95
+ var CallRole;
96
+ (function (CallRole) {
97
+ CallRole[CallRole["NONE"] = 0] = "NONE";
98
+ CallRole[CallRole["CALLER"] = 1] = "CALLER";
99
+ CallRole[CallRole["CALLEE"] = 2] = "CALLEE";
100
+ })(CallRole || (CallRole = {}));
101
+ var CallMediaType;
102
+ (function (CallMediaType) {
103
+ CallMediaType[CallMediaType["UNKNOWN"] = 0] = "UNKNOWN";
104
+ CallMediaType[CallMediaType["AUDIO"] = 1] = "AUDIO";
105
+ CallMediaType[CallMediaType["VIDEO"] = 2] = "VIDEO";
106
+ })(CallMediaType || (CallMediaType = {}));
107
+ var CallScene;
108
+ (function (CallScene) {
109
+ CallScene[CallScene["NONE"] = 0] = "NONE";
110
+ CallScene[CallScene["GROUP"] = 1] = "GROUP";
111
+ CallScene[CallScene["MULTI"] = 2] = "MULTI";
112
+ CallScene[CallScene["SINGLE"] = 3] = "SINGLE";
113
+ })(CallScene || (CallScene = {}));
114
+ var IOSOfflinePushType;
115
+ (function (IOSOfflinePushType) {
116
+ IOSOfflinePushType[IOSOfflinePushType["APNs"] = 0] = "APNs";
117
+ IOSOfflinePushType[IOSOfflinePushType["VoIP"] = 1] = "VoIP";
118
+ })(IOSOfflinePushType || (IOSOfflinePushType = {}));
119
+ var CallInvitationResultCodeType;
120
+ (function (CallInvitationResultCodeType) {
121
+ CallInvitationResultCodeType[CallInvitationResultCodeType["SUCCESS"] = 0] = "SUCCESS";
122
+ CallInvitationResultCodeType[CallInvitationResultCodeType["INVITED"] = 1] = "INVITED";
123
+ CallInvitationResultCodeType[CallInvitationResultCodeType["LINE_BUSY"] = 2] = "LINE_BUSY";
124
+ })(CallInvitationResultCodeType || (CallInvitationResultCodeType = {}));
125
+ var CallEndReason;
126
+ (function (CallEndReason) {
127
+ CallEndReason[CallEndReason["UNKNOWN"] = 0] = "UNKNOWN";
128
+ CallEndReason[CallEndReason["HANGUP"] = 1] = "HANGUP";
129
+ CallEndReason[CallEndReason["REJECT"] = 2] = "REJECT";
130
+ CallEndReason[CallEndReason["NO_RESPONSE"] = 3] = "NO_RESPONSE";
131
+ CallEndReason[CallEndReason["OFFLINE"] = 4] = "OFFLINE";
132
+ CallEndReason[CallEndReason["LINE_BUSY"] = 5] = "LINE_BUSY";
133
+ CallEndReason[CallEndReason["CANCELED"] = 6] = "CANCELED";
134
+ CallEndReason[CallEndReason["OTHER_DEVICE_ACCEPTED"] = 7] = "OTHER_DEVICE_ACCEPTED";
135
+ CallEndReason[CallEndReason["OTHER_DEVICE_REJECT"] = 8] = "OTHER_DEVICE_REJECT";
136
+ CallEndReason[CallEndReason["END_BY_SERVER"] = 9] = "END_BY_SERVER";
137
+ })(CallEndReason || (CallEndReason = {}));
138
+ var LOG_LEVEL;
139
+ (function (LOG_LEVEL) {
140
+ LOG_LEVEL[LOG_LEVEL["DEBUG"] = 0] = "DEBUG";
141
+ LOG_LEVEL[LOG_LEVEL["INFO"] = 1] = "INFO";
142
+ LOG_LEVEL[LOG_LEVEL["WARN"] = 2] = "WARN";
143
+ LOG_LEVEL[LOG_LEVEL["ERROR"] = 3] = "ERROR";
144
+ LOG_LEVEL[LOG_LEVEL["NONE"] = 4] = "NONE";
145
+ })(LOG_LEVEL || (LOG_LEVEL = {}));
146
+ var CameraPosition;
147
+ (function (CameraPosition) {
148
+ CameraPosition[CameraPosition["FRONT"] = 1] = "FRONT";
149
+ CameraPosition[CameraPosition["BACK"] = 0] = "BACK";
150
+ })(CameraPosition || (CameraPosition = {}));
151
+ var AudioPlayBackDevice;
152
+ (function (AudioPlayBackDevice) {
153
+ AudioPlayBackDevice[AudioPlayBackDevice["SPEAKER"] = 0] = "SPEAKER";
154
+ AudioPlayBackDevice[AudioPlayBackDevice["EAR"] = 1] = "EAR";
155
+ })(AudioPlayBackDevice || (AudioPlayBackDevice = {}));
156
+ const CallInvitationRespondedType = Object.freeze({
157
+ None: 0,
158
+ AcceptedByInvitee: 1,
159
+ RejectedByInvitee: 2,
160
+ NoResponseUntilTimeout: 3,
161
+ Hangup: 4,
162
+ Join: 5,
163
+ Offline: 6,
164
+ InviteUser: 7,
165
+ });
166
+ const TUIErrorCode = Object.freeze({
167
+ ERR_FAILED: -1, // generic, uncategorised failure
168
+ });
169
+ const TUICallEvent = Object.freeze({
170
+ ERROR: 'onError',
171
+ SDK_READY: 'sdkReady',
172
+ KICKED_OUT: 'onKickedOffline',
173
+ onUserSigExpired: 'onUserSigExpired',
174
+ ON_CALL_BEGIN: 'onCallBegin',
175
+ ON_CALL_END: 'onCallEnd',
176
+ ON_CALL_RECEIVED: 'onCallReceived',
177
+ ON_CALL_CANCELED: 'onCallCanceled',
178
+ ON_CALL_NOT_CONNECTED: 'onCallNotConnected',
179
+ INVITED: 'onInvited',
180
+ USER_ENTER: 'onUserJoin',
181
+ USER_LEAVE: 'onUserLeave',
182
+ VIEW_LAYOUT_CHANGED: 'viewLayoutChanged',
183
+ });
184
+
185
+ // 日志上报相关
186
+ // 常量
187
+ const NAME = {
188
+ NUMBER: 'number',
189
+ OBJECT: 'object',
190
+ ARRAY: 'array',
191
+ FUNCTION: 'function'};
192
+
193
+ const isObject = function (input) {
194
+ return input !== null && typeof input === NAME.OBJECT;
195
+ };
196
+ const isNumber = function (input) {
197
+ return typeof input === NAME.NUMBER;
198
+ };
199
+ /**
200
+ * 检测input类型是否为数组
201
+ * @param {*} input 任意类型的输入
202
+ * @returns {Boolean} true->array / false->not an array
203
+ */
204
+ const isArray = function (input) {
205
+ if (typeof Array.isArray === NAME.FUNCTION) {
206
+ return Array.isArray(input);
207
+ }
208
+ return getType(input) === NAME.ARRAY;
209
+ };
210
+ /**
211
+ * 检测input类型是否为function
212
+ * @param {*} input 任意类型的输入
213
+ * @returns {Boolean} true->input is a function
214
+ */
215
+ const isFunction = function (input) {
216
+ return typeof input === NAME.FUNCTION;
217
+ };
218
+ /**
219
+ * Get the object type string
220
+ * @param {*} input 任意类型的输入
221
+ * @returns {String} the object type string
222
+ */
223
+ const getType = function (input) {
224
+ return Object.prototype.toString
225
+ .call(input)
226
+ .match(/^\[object (.*)\]$/)[1]
227
+ .toLowerCase();
228
+ };
229
+ // ----------------- SSO Head -----------------
230
+ // Pinned at build time so heartbeats / start_call requests carry a
231
+ // consistent client identifier the backend can use for diagnostics.
232
+ const SDK_VERSION = '1.0.0.0';
233
+ const SDK_HEAD_VERSION = 2;
234
+ // Cached device snapshot. `wx.getSystemInfoSync()` is cheap but does
235
+ // touch native, so resolve once at module load and reuse for every
236
+ // outgoing SSO packet.
237
+ let _cachedDeviceInfo = null;
238
+ /**
239
+ * Resolve the current device's OS / model info for the SSO Head.
240
+ *
241
+ * Detection order:
242
+ * 1. WeChat Mini Program — `wx.getSystemInfoSync()` (system="iOS 18.0", model)
243
+ * 2. uni-app — `uni.getSystemInfoSync()` (same shape)
244
+ * 3. Browser fallback — parse `navigator.userAgent`
245
+ * 4. Hard-coded default — last resort so we never throw at runtime
246
+ */
247
+ const getDeviceInfo = function () {
248
+ if (_cachedDeviceInfo)
249
+ return _cachedDeviceInfo;
250
+ const info = { osName: 'Unknown', osVersion: '0.0.0', deviceName: 'Unknown' };
251
+ try {
252
+ // @ts-ignore — `wx` is a runtime global in the WeChat Mini Program env
253
+ const wxGlobal = typeof wx !== 'undefined' ? wx : null;
254
+ // @ts-ignore — `uni` is the uni-app runtime global
255
+ const uniGlobal = typeof uni !== 'undefined' ? uni : null;
256
+ if (wxGlobal && typeof wxGlobal.getSystemInfoSync === 'function') {
257
+ const sys = wxGlobal.getSystemInfoSync() || {};
258
+ const [osName = 'Unknown', osVersion = '0.0.0'] = String(sys.system || '').split(' ');
259
+ info.osName = osName;
260
+ info.osVersion = osVersion;
261
+ info.deviceName = sys.model || sys.brand || 'Unknown';
262
+ }
263
+ else if (uniGlobal && typeof uniGlobal.getSystemInfoSync === 'function') {
264
+ const sys = uniGlobal.getSystemInfoSync() || {};
265
+ const [osName = 'Unknown', osVersion = '0.0.0'] = String(sys.system || '').split(' ');
266
+ info.osName = osName;
267
+ info.osVersion = osVersion;
268
+ info.deviceName = sys.model || sys.brand || 'Unknown';
269
+ }
270
+ else if (typeof navigator !== 'undefined' && navigator.userAgent) {
271
+ // Lightweight UA parse — good enough for the Head field.
272
+ const ua = navigator.userAgent;
273
+ if (/Mac OS X/i.test(ua)) {
274
+ info.osName = 'MacOS';
275
+ const m = ua.match(/Mac OS X ([\d_\.]+)/);
276
+ info.osVersion = m ? m[1].replace(/_/g, '.') : '0.0.0';
277
+ info.deviceName = 'Mac';
278
+ }
279
+ else if (/Windows/i.test(ua)) {
280
+ info.osName = 'Windows';
281
+ const m = ua.match(/Windows NT ([\d\.]+)/);
282
+ info.osVersion = m ? m[1] : '0.0.0';
283
+ info.deviceName = 'PC';
284
+ }
285
+ else if (/Android/i.test(ua)) {
286
+ info.osName = 'Android';
287
+ const m = ua.match(/Android ([\d\.]+)/);
288
+ info.osVersion = m ? m[1] : '0.0.0';
289
+ info.deviceName = 'Android';
290
+ }
291
+ else if (/iPhone|iPad|iPod/i.test(ua)) {
292
+ info.osName = 'iOS';
293
+ const m = ua.match(/OS ([\d_]+)/);
294
+ info.osVersion = m ? m[1].replace(/_/g, '.') : '0.0.0';
295
+ info.deviceName = /iPad/i.test(ua) ? 'iPad' : 'iPhone';
296
+ }
297
+ }
298
+ }
299
+ catch (_) { /* swallow — fall through to defaults */ }
300
+ _cachedDeviceInfo = info;
301
+ return info;
302
+ };
303
+ /**
304
+ * Build the common `Head` block for SSO requests
305
+ * (`start_call`, `heart_beat`, etc.).
306
+ *
307
+ * Only `Command` changes per request — every other field is derived
308
+ * from the device once and cached.
309
+ */
310
+ const buildSSOHead = function (command) {
311
+ const device = getDeviceInfo();
312
+ return {
313
+ Command: command,
314
+ DeviceName: device.deviceName,
315
+ LongPollingKey: '',
316
+ OSName: device.osName,
317
+ OSVersion: device.osVersion,
318
+ RoomId: '',
319
+ SdkVersion: SDK_VERSION,
320
+ Version: SDK_HEAD_VERSION,
321
+ };
322
+ };
323
+ // ----------------- Terminal ID -----------------
324
+ /**
325
+ * Generate a per-device terminal id used by `call_engine_srv.handle_call`.
326
+ *
327
+ * Layout: 16 random bytes rendered as uppercase hex, segmented 4-2-2-2-6
328
+ * e.g. "A1B2C3D4-E5F6-7890-ABCD-1122334455FF"
329
+ *
330
+ * The format mirrors what the reference iOS / Android clients send so the
331
+ * backend (and the multi-device sync flow) treat us identically.
332
+ */
333
+ const generateTerminalId = function () {
334
+ // Prefer crypto.getRandomValues when available (Mini Program 2.x+, browser).
335
+ const bytes = new Array(16);
336
+ // @ts-ignore — crypto is available in modern WXMP / browsers
337
+ const cryptoObj = (typeof crypto !== 'undefined' && crypto)
338
+ // @ts-ignore — uni-app exposes uni.getRandomValues / wx.getRandomValues asynchronously,
339
+ // so we don't rely on them here.
340
+ || null;
341
+ if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
342
+ const buf = new Uint8Array(16);
343
+ cryptoObj.getRandomValues(buf);
344
+ for (let i = 0; i < 16; i++)
345
+ bytes[i] = buf[i];
346
+ }
347
+ else {
348
+ for (let i = 0; i < 16; i++)
349
+ bytes[i] = Math.floor(Math.random() * 256);
350
+ }
351
+ const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
352
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
353
+ };
354
+ // ----------------- call 函数-----------------
355
+ const generateRandomStrRoomId = function (strRoomId) {
356
+ if (strRoomId)
357
+ return strRoomId;
358
+ return `roomId_${Math.floor(Math.random() * 1000000)}`;
359
+ };
360
+ const generateCallInfo = function (params = {}) {
361
+ let scene = CallScene.NONE;
362
+ const inviteeList = params.userIDList || [];
363
+ if (inviteeList.length > 1)
364
+ scene = CallScene.MULTI;
365
+ if (inviteeList.length === 1)
366
+ scene = CallScene.SINGLE;
367
+ return {
368
+ callId: params.callId || '',
369
+ roomId: {
370
+ strRoomId: params.strRoomID || params.strRoomId || '',
371
+ intRoomId: params.roomID || params.roomId || 0,
372
+ },
373
+ inviter: params.inviter || '',
374
+ inviteeList,
375
+ groupId: params.chatGroupID || '',
376
+ mediaType: params.type,
377
+ status: params.status || CallStatus.NONE,
378
+ role: params.role || CallRole.NONE,
379
+ scene,
380
+ callStartTime: 0,
381
+ };
382
+ };
383
+
384
+ /** Backend `RoomIdType` enum (1: number, 2: string). */
385
+ const CallRoomIdType = Object.freeze({
386
+ IntRoomId: 1,
387
+ StrRoomId: 2,
388
+ });
389
+ // ---------- C -> S ----------
390
+ /**
391
+ * Build the `OfflinePushInfo` JSON sub-tree.
392
+ * @private
393
+ */
394
+ function encodeOfflinePushInfo(pushInfo, mediaType) {
395
+ pushInfo = pushInfo || {};
396
+ const apnsInfo = {
397
+ Title: pushInfo.title || '',
398
+ Sound: pushInfo.iosSound || '',
399
+ BadgeMode: pushInfo.ignoreIosBadge ? 1 : 0,
400
+ IsVoipPush: pushInfo.iosPushType || 0,
401
+ ContentAvailable: pushInfo.enableIosBackgroundNotification ? 1 : 0,
402
+ InterruptionLevel: pushInfo.iosInterruptionLevel || '',
403
+ Image: pushInfo.iosImage || '',
404
+ };
405
+ const androidInfo = {
406
+ Sound: pushInfo.androidSound || '',
407
+ Title: pushInfo.title || '',
408
+ OPPOChannelID: pushInfo.androidOppoChannelId || '',
409
+ OPPOCategory: pushInfo.oppoCategory || 'IM',
410
+ OPPONotifyLevel: pushInfo.oppoNotifyLevel || 0,
411
+ GoogleChannelID: pushInfo.androidFcmChannelId || '',
412
+ GoogleImage: pushInfo.fcmImage || '',
413
+ XiaoMiChannelID: pushInfo.androidXiaomiChannelId || '',
414
+ VIVOClassification: pushInfo.androidVivoClassification || 0,
415
+ VIVOCategory: pushInfo.vivoCategory || 'IM',
416
+ HuaWeiCategory: pushInfo.androidHuaweiCategory || 'IM',
417
+ HuaWeiImage: pushInfo.huaweiImage || '',
418
+ HonorImportance: pushInfo.honorImportance || 'NORMAL',
419
+ HonorImage: pushInfo.honorImage || '',
420
+ };
421
+ const voipExt = {
422
+ entity: { action: 2 },
423
+ timPushFeatures: { fcmPushType: 0, fcmNotificationType: 1 },
424
+ voip_ext: {
425
+ voip_type: 'call',
426
+ voip_media_type: mediaType === CallMediaType.VIDEO ? 'video' : 'audio',
427
+ },
428
+ };
429
+ return {
430
+ Title: pushInfo.title || '',
431
+ Description: pushInfo.description || '',
432
+ Ext: JSON.stringify(voipExt),
433
+ AndroidInfo: androidInfo,
434
+ ApnsInfo: apnsInfo,
435
+ };
436
+ }
437
+ /**
438
+ * Build the request body for `call_engine_srv.start_call`.
439
+ * @param {string} inviter
440
+ * @param {string[]} userIdList
441
+ * @param {number} mediaType CallMediaType.Audio | Video
442
+ * @param {{
443
+ * roomId?: { intRoomId?: number, strRoomId?: string },
444
+ * timeout?: number, // seconds; default 30
445
+ * userData?: string,
446
+ * isEphemeralCall?: boolean,
447
+ * offlinePushInfo?: object,
448
+ * }} callParams
449
+ * @param {string} groupId
450
+ * @returns {object} JSON-encodable body
451
+ */
452
+ function encodeStartCallRequest(inviter, userIdList, mediaType, callParams, groupId) {
453
+ const params = callParams || {};
454
+ const roomId = params.roomId || {};
455
+ const callInfo = {
456
+ CallId: '',
457
+ CallMediaType: mediaType >>> 0,
458
+ };
459
+ if (typeof roomId.intRoomId === 'number' && roomId.intRoomId > 0) {
460
+ callInfo.RoomId = String(roomId.intRoomId);
461
+ callInfo.RoomIdType = CallRoomIdType.IntRoomId;
462
+ }
463
+ else if ((!roomId.intRoomId || roomId.intRoomId === 0) &&
464
+ typeof roomId.strRoomId === 'string' &&
465
+ roomId.strRoomId.length > 0) {
466
+ callInfo.RoomId = roomId.strRoomId;
467
+ callInfo.RoomIdType = CallRoomIdType.StrRoomId;
468
+ }
469
+ callInfo.ExcludeFromHistoryMessage = false;
470
+ callInfo.GroupId = groupId || '';
471
+ callInfo.IsEphemeralCall = !!params.isEphemeralCall;
472
+ const timeoutSec = typeof params.timeout === 'number' ? params.timeout : 30;
473
+ return {
474
+ Body: {
475
+ Caller_Account: inviter,
476
+ CalleeList_Account: (userIdList || []).slice(),
477
+ CallInfo: callInfo,
478
+ Timeout: timeoutSec * 1000,
479
+ UserData: params.userData || '',
480
+ OfflinePushInfo: encodeOfflinePushInfo(params.offlinePushInfo, mediaType),
481
+ },
482
+ Head: buildSSOHead('call_engine_srv.start_call'),
483
+ };
484
+ }
485
+ /**
486
+ * Build the request body for `call_engine_srv.handle_call`
487
+ * (callee accepts or rejects an incoming invitation).
488
+ * @param {string} callId
489
+ * @param {string} terminalId
490
+ * @param {number} actionType CallInvitationRespondedType.Accepted | .Rejected
491
+ * @returns {object} JSON-encodable body
492
+ */
493
+ function encodeRespondInvitationRequest(callId, terminalId, actionType, command) {
494
+ return {
495
+ Body: {
496
+ CallId: callId || '',
497
+ TerminalId: terminalId || '',
498
+ ActionType: actionType >>> 0,
499
+ },
500
+ Head: buildSSOHead(command),
501
+ };
502
+ }
503
+ /**
504
+ * Build the request body for `call_engine_srv.cancel_invite`
505
+ * @param {string} inviter caller user id
506
+ * @param {string} callId invitation id
507
+ * @param {string[]} calleeList invitees to cancel
508
+ * @param {string} [command] SSO command (defaults to cancel_invite)
509
+ * @returns {object} JSON-encodable body
510
+ */
511
+ function encodeCancelRequest(inviter, callId, calleeList, command) {
512
+ const list = Array.isArray(calleeList) ? calleeList.filter(Boolean) : [];
513
+ return {
514
+ Body: {
515
+ CallId: callId || '',
516
+ Operator_Account: inviter || '',
517
+ CalleeList_Account: list,
518
+ },
519
+ Head: buildSSOHead(command),
520
+ };
521
+ }
522
+ /**
523
+ * @param {string} userId callee user id (the login user)
524
+ * @param {string} [command] SSO command (defaults to get_invitation)
525
+ * @returns {object} JSON-encodable body
526
+ */
527
+ function encodeGetInvitationRequest(userId, command) {
528
+ return {
529
+ Body: {
530
+ User_Account: userId || '',
531
+ },
532
+ Head: buildSSOHead(command),
533
+ };
534
+ }
535
+
536
+ const SEND_KEY = 'sendTRTCCustomData';
537
+ const CMD_START_CALL = 'call_engine_srv.start_call';
538
+ const CMD_HEART_BEAT = 'call_engine_srv.heart_beat';
539
+ const CMD_HANDLE_CALL = 'call_engine_srv.handle_call'; // accept、reject
540
+ const CMD_HANGUP_CALL = 'call_engine_srv.hangup';
541
+ const CMD_CANCEL_CALL = 'call_engine_srv.cancel_invite';
542
+ const CMD_GET_INVITATION = 'call_engine_srv.get_invitation'; // 登录后主动获取一下 useId 的通话信息
543
+ // Heartbeat tuning (mirrors call-engine-wx/call-constants.js).
544
+ const HEARTBEAT_INTERVAL_MS = 2000;
545
+ const HEARTBEAT_FAIL_RETRY_DEFAULT = 5; // 5 misses ≈ 10s before giving up
546
+ const HEARTBEAT_FAIL_INTERVAL_MS = 2000;
547
+ const TRANSPORT_ERR = -1;
548
+ const callResponse = { errCode: 0, errMsg: '', inviteId: '', callResultList: [] };
549
+ /**
550
+ * Issue `call_engine_srv.start_call` and decode the response.
551
+ */
552
+ async function startCall(options) {
553
+ const opts = options || {};
554
+ const chat = opts.chat;
555
+ if (!chat || !isFunction(chat.callExperimentalAPI)) {
556
+ return {
557
+ ...callResponse,
558
+ errCode: TRANSPORT_ERR,
559
+ errMsg: 'chat.callExperimentalAPI is not available',
560
+ };
561
+ }
562
+ if (!opts.inviter) {
563
+ return { ...callResponse, errCode: TRANSPORT_ERR, errMsg: 'inviter is required' };
564
+ }
565
+ if (!isArray(opts.userIdList) || opts.userIdList.length === 0) {
566
+ return { ...callResponse, errCode: TRANSPORT_ERR, errMsg: 'userIdList cannot be empty' };
567
+ }
568
+ if (opts.mediaType !== CallMediaType.AUDIO && opts.mediaType !== CallMediaType.VIDEO) {
569
+ return { ...callResponse, errCode: TRANSPORT_ERR, errMsg: 'invalid mediaType' };
570
+ }
571
+ const callParams = {
572
+ roomId: opts.roomId || {},
573
+ timeout: opts.timeout,
574
+ userData: opts.userData,
575
+ isEphemeralCall: !!opts.isEphemeralCall,
576
+ offlinePushInfo: opts.offlinePushInfo,
577
+ };
578
+ const body = encodeStartCallRequest(opts.inviter, opts.userIdList, opts.mediaType, callParams, opts.groupId || '');
579
+ let res;
580
+ try {
581
+ const payload = {
582
+ serviceCommand: CMD_START_CALL,
583
+ data: JSON.stringify(body),
584
+ };
585
+ res = await chat.callExperimentalAPI(SEND_KEY, payload);
586
+ }
587
+ catch (err) {
588
+ const code = (err && (err.code || err.errorCode)) || TRANSPORT_ERR;
589
+ const msg = (err && (err.message || err.errorInfo)) || 'send failed';
590
+ return { ...callResponse, errCode: code, errMsg: msg };
591
+ }
592
+ const resData = (res && res.data) || res || {};
593
+ if (resData.ErrorCode !== 0) {
594
+ return { ...callResponse, errCode: resData.ErrorCode, errMsg: resData.ErrorInfo };
595
+ }
596
+ return {
597
+ errCode: resData?.ErrorCode || 0,
598
+ errMsg: resData?.errMsg || '',
599
+ inviteId: resData?.Response?.CallId || '',
600
+ callResultList: resData?.Response?.CallResult || [],
601
+ };
602
+ }
603
+ // ====================== Heartbeat ======================
604
+ let _hbTimer = null;
605
+ let _hbCallId = '';
606
+ let _hbFailCount = 0;
607
+ function stopHeartBeat() {
608
+ if (_hbTimer) {
609
+ clearInterval(_hbTimer);
610
+ _hbTimer = null;
611
+ }
612
+ _hbCallId = '';
613
+ _hbFailCount = 0;
614
+ }
615
+ /**
616
+ * Send one heartbeat packet. Returns a normalised
617
+ * @private
618
+ */
619
+ async function _sendHeartBeat(chat, callId, userId) {
620
+ const heartBeatBody = {
621
+ Body: {
622
+ User_Account: userId,
623
+ CallId: callId,
624
+ },
625
+ Head: buildSSOHead(CMD_HEART_BEAT),
626
+ };
627
+ try {
628
+ const payload = {
629
+ serviceCommand: CMD_HEART_BEAT,
630
+ data: JSON.stringify(heartBeatBody),
631
+ };
632
+ const res = await chat.callExperimentalAPI(SEND_KEY, payload);
633
+ const resData = (res && res.data) || res || {};
634
+ // Backend uses either ErrorCode (capitalised) or errorCode depending
635
+ // on the route; accept both to avoid false positives.
636
+ const errCode = isNumber(resData.ErrorCode)
637
+ ? resData.ErrorCode
638
+ : (isNumber(resData.errorCode) ? resData.errorCode : 0);
639
+ if (errCode !== 0) {
640
+ return { errCode, errMsg: resData.ErrorInfo || resData.errorInfo || '' };
641
+ }
642
+ return { errCode: 0, errMsg: '' };
643
+ }
644
+ catch (err) {
645
+ const code = (err && (err.code || err.errorCode)) || TRANSPORT_ERR;
646
+ const msg = (err && (err.message || err.errorInfo)) || 'send failed';
647
+ return { errCode: code, errMsg: msg };
648
+ }
649
+ }
650
+ function startHeartBeat(options) {
651
+ const opts = options || {};
652
+ const { chat, callId, userId } = opts;
653
+ if (!chat || !isFunction(chat.callExperimentalAPI)) {
654
+ console.warn('[SSOChannel] startHeartBeat: chat.callExperimentalAPI unavailable');
655
+ return;
656
+ }
657
+ if (!callId || !userId) {
658
+ console.warn('[SSOChannel] startHeartBeat: callId and userId are required');
659
+ return;
660
+ }
661
+ stopHeartBeat();
662
+ const intervalMs = isNumber(opts.intervalMs) && opts.intervalMs > 0
663
+ ? opts.intervalMs : HEARTBEAT_INTERVAL_MS;
664
+ const retryCount = isNumber(opts.retryCount) && opts.retryCount > 0
665
+ ? opts.retryCount : HEARTBEAT_FAIL_RETRY_DEFAULT;
666
+ _hbCallId = callId;
667
+ const tick = async () => {
668
+ if (!_hbCallId || _hbCallId !== callId)
669
+ return;
670
+ const sent = await _sendHeartBeat(chat, callId, userId);
671
+ if (!_hbCallId || _hbCallId !== callId)
672
+ return;
673
+ if (sent.errCode === 0) {
674
+ _hbFailCount = 0;
675
+ return;
676
+ }
677
+ _hbFailCount += 1;
678
+ console.warn('[SSOChannel] heartbeat failed', _hbFailCount, sent.errCode, sent.errMsg);
679
+ if (_hbFailCount >= retryCount) {
680
+ const dropped = _hbCallId;
681
+ stopHeartBeat();
682
+ if (typeof opts.onTimeout === 'function') {
683
+ try {
684
+ opts.onTimeout(dropped);
685
+ }
686
+ catch (_) { /* ignore */ }
687
+ }
688
+ }
689
+ };
690
+ _hbTimer = setInterval(tick, intervalMs);
691
+ }
692
+ async function _respondInvitation(options, actionType) {
693
+ const opts = options || {};
694
+ const chat = opts.chat;
695
+ if (!chat || !isFunction(chat.callExperimentalAPI)) {
696
+ return { errCode: TRANSPORT_ERR, errMsg: 'chat.callExperimentalAPI is not available' };
697
+ }
698
+ if (!opts.callId) {
699
+ return { errCode: TRANSPORT_ERR, errMsg: 'callId is required' };
700
+ }
701
+ if (!opts.terminalId) {
702
+ return { errCode: TRANSPORT_ERR, errMsg: 'terminalId is required' };
703
+ }
704
+ let serviceCommand = CMD_HANDLE_CALL;
705
+ if (actionType === CallInvitationRespondedType.Hangup) {
706
+ serviceCommand = CMD_HANGUP_CALL;
707
+ }
708
+ const body = encodeRespondInvitationRequest(opts.callId, opts.terminalId, actionType, serviceCommand);
709
+ try {
710
+ const payload = {
711
+ serviceCommand,
712
+ data: JSON.stringify(body),
713
+ };
714
+ const res = await chat.callExperimentalAPI(SEND_KEY, payload);
715
+ const resData = (res && res.data) || res || {};
716
+ const errCode = isNumber(resData.ErrorCode)
717
+ ? resData.ErrorCode
718
+ : (isNumber(resData.errorCode) ? resData.errorCode : 0);
719
+ if (errCode !== 0) {
720
+ return { errCode, errMsg: resData.ErrorInfo || resData.errorInfo || '' };
721
+ }
722
+ return { errCode: 0, errMsg: '' };
723
+ }
724
+ catch (err) {
725
+ const code = (err && (err.code || err.errorCode)) || TRANSPORT_ERR;
726
+ const msg = (err && (err.message || err.errorInfo)) || 'send failed';
727
+ return { errCode: code, errMsg: msg };
728
+ }
729
+ }
730
+ /**
731
+ * Callee accepts an incoming invitation.
732
+ * Wraps `_respondInvitation` with ActionType = Accepted.
733
+ */
734
+ async function accept(options) {
735
+ return _respondInvitation(options, CallInvitationRespondedType.AcceptedByInvitee);
736
+ }
737
+ /**
738
+ * Callee rejects an incoming invitation.
739
+ * Wraps `_respondInvitation` with ActionType = Rejected.
740
+ */
741
+ async function reject(options) {
742
+ return _respondInvitation(options, CallInvitationRespondedType.RejectedByInvitee);
743
+ }
744
+ /**
745
+ * In-call hangup (the call has already reached CALLING).
746
+ * Routes through `call_engine_srv.hangup` with the same
747
+ * `{Body, Head}` envelope handle_call uses; only ActionType differs.
748
+ */
749
+ async function hangup(options) {
750
+ return _respondInvitation(options, CallInvitationRespondedType.Hangup);
751
+ }
752
+ /**
753
+ * Caller cancels a pending invitation. Returns the same
754
+ * `{errCode, errMsg}` shape as accept / reject / hangup.
755
+ */
756
+ async function cancel(options) {
757
+ const opts = options || {};
758
+ const chat = opts.chat;
759
+ if (!chat || !isFunction(chat.callExperimentalAPI)) {
760
+ return { errCode: TRANSPORT_ERR, errMsg: 'chat.callExperimentalAPI is not available' };
761
+ }
762
+ if (!opts.callId) {
763
+ return { errCode: TRANSPORT_ERR, errMsg: 'callId is required' };
764
+ }
765
+ if (!opts.inviter) {
766
+ return { errCode: TRANSPORT_ERR, errMsg: 'inviter is required' };
767
+ }
768
+ if (!isArray(opts.calleeList) || opts.calleeList.length === 0) {
769
+ return { errCode: TRANSPORT_ERR, errMsg: 'calleeList cannot be empty' };
770
+ }
771
+ const body = encodeCancelRequest(opts.inviter, opts.callId, opts.calleeList, CMD_CANCEL_CALL);
772
+ try {
773
+ const payload = {
774
+ serviceCommand: CMD_CANCEL_CALL,
775
+ data: JSON.stringify(body),
776
+ };
777
+ const res = await chat.callExperimentalAPI(SEND_KEY, payload);
778
+ const resData = (res && res.data) || res || {};
779
+ const errCode = isNumber(resData.ErrorCode)
780
+ ? resData.ErrorCode
781
+ : isNumber(resData.ErrorCode) ? resData.errorCode : 0;
782
+ if (errCode !== 0) {
783
+ return { errCode, errMsg: resData.ErrorInfo || resData.errorInfo || '' };
784
+ }
785
+ return { errCode: 0, errMsg: '' };
786
+ }
787
+ catch (err) {
788
+ const code = (err && (err.code || err.errorCode)) || TRANSPORT_ERR;
789
+ const msg = (err && (err.message || err.errorInfo)) || 'send failed';
790
+ return { errCode: code, errMsg: msg };
791
+ }
792
+ }
793
+ /**
794
+ * Send `call_engine_srv.get_invitation` and return the raw notify-
795
+ * shaped payload (or null if no pending invitation exists for the
796
+ * given user).
797
+ */
798
+ async function getInvitation(options) {
799
+ const opts = options || {};
800
+ const chat = opts.chat;
801
+ if (!chat || !isFunction(chat.callExperimentalAPI)) {
802
+ return { errCode: TRANSPORT_ERR, errMsg: 'chat.callExperimentalAPI is not available', data: null };
803
+ }
804
+ if (!opts.userId) {
805
+ return { errCode: TRANSPORT_ERR, errMsg: 'userId is required', data: null };
806
+ }
807
+ const body = encodeGetInvitationRequest(opts.userId, CMD_GET_INVITATION);
808
+ try {
809
+ const payload = {
810
+ serviceCommand: CMD_GET_INVITATION,
811
+ data: JSON.stringify(body),
812
+ };
813
+ const res = await chat.callExperimentalAPI(SEND_KEY, payload);
814
+ const resData = (res && res.data) || res || {};
815
+ const errCode = isNumber(resData.ErrorCode)
816
+ ? resData.ErrorCode
817
+ : isNumber(resData.ErrorCode) ? resData.errorCode : 0;
818
+ if (errCode !== 0) {
819
+ return { errCode, errMsg: resData.ErrorInfo || resData.errorInfo || '', data: null };
820
+ }
821
+ const notifyData = resData?.Response;
822
+ if (!notifyData || !isObject(notifyData) || Object.keys(notifyData).length === 0) {
823
+ return { errCode: 0, errMsg: '', data: null };
824
+ }
825
+ return { errCode: 0, errMsg: '', data: notifyData };
826
+ }
827
+ catch (err) {
828
+ const code = (err && (err.code || err.errorCode)) || TRANSPORT_ERR;
829
+ const msg = (err && (err.message || err.errorInfo)) || 'send failed';
830
+ return { errCode: code, errMsg: msg, data: null };
831
+ }
832
+ }
833
+ const SSOChannel = {
834
+ CMD_START_CALL,
835
+ CMD_HEART_BEAT,
836
+ CMD_HANDLE_CALL,
837
+ CMD_HANGUP_CALL,
838
+ CMD_CANCEL_CALL,
839
+ CMD_GET_INVITATION,
840
+ HEARTBEAT_INTERVAL_MS,
841
+ HEARTBEAT_FAIL_RETRY_DEFAULT,
842
+ HEARTBEAT_FAIL_INTERVAL_MS,
843
+ startCall,
844
+ startHeartBeat,
845
+ stopHeartBeat,
846
+ accept,
847
+ reject,
848
+ hangup,
849
+ cancel,
850
+ getInvitation,
851
+ };
852
+
853
+ // Copyright (c) 2026 Tencent. All rights reserved.
854
+ //
855
+ // Port of `atomic_engine/src/engine/module/call/core_view_layout/
856
+ // call_view_layout.{h,cc}` to TypeScript for the Mini-Program-side
857
+ // `call-engine-wx` package.
858
+ //
859
+ // Goals:
860
+ // 1. 1:1 algorithmic parity with the C++ implementation — including
861
+ // canvas dimensions (720x1280), the float-template secondary
862
+ // frame ({480,80,200,300}), the grid unit/half breakpoints
863
+ // (count <= 4 vs > 4, large-view vs no large-view).
864
+ // 2. JSON output shape matches `CallViewLayout::ToJson()` field-by-
865
+ // field, so the same payload can travel between native peers
866
+ // and the wx client without translation.
867
+ // 3. Self-contained — only depends on `CallMediaType` from types.ts.
868
+ // `CallStateInput` is a minimal subset of the C++ `CallState`
869
+ // and is meant to be built ad-hoc by the caller, NOT to mirror
870
+ // every field of the C++ class.
871
+ //
872
+ // Note: the PIP template (single-tile active-speaker view) from the
873
+ // C++ implementation is intentionally NOT ported here — it is not
874
+ // used by the wx-side renderer yet. Re-introducing it should be
875
+ // straightforward (add LayoutTemplate.PIP, a PipLayoutResult variant,
876
+ // a `calculatePipLayout` branch, and the speaker-volumes input field).
877
+ //
878
+ // Out of scope: native render integration. This module is purely
879
+ // the layout calculator; the wx renderer consumes the JSON / typed
880
+ // result and positions <video> / <image> nodes itself.
881
+ // ============================ enums / consts ===========================
882
+ var LayoutTemplate;
883
+ (function (LayoutTemplate) {
884
+ LayoutTemplate[LayoutTemplate["FLOAT"] = 0] = "FLOAT";
885
+ LayoutTemplate[LayoutTemplate["GRID"] = 1] = "GRID";
886
+ })(LayoutTemplate || (LayoutTemplate = {}));
887
+ /**
888
+ * Mirror of C++ `CallParticipantStatus`. Only the values the layout
889
+ * algorithm actually distinguishes are listed here; any other value
890
+ * the caller may carry is preserved opaquely as long as it !== NONE.
891
+ */
892
+ var CallParticipantStatus;
893
+ (function (CallParticipantStatus) {
894
+ CallParticipantStatus[CallParticipantStatus["NONE"] = 0] = "NONE";
895
+ CallParticipantStatus[CallParticipantStatus["WAITING"] = 1] = "WAITING";
896
+ CallParticipantStatus[CallParticipantStatus["CALLING"] = 2] = "CALLING";
897
+ })(CallParticipantStatus || (CallParticipantStatus = {}));
898
+ const CANVAS_W = 720;
899
+ const CANVAS_H = 1280;
900
+ /** Default float-template secondary (small) frame — matches C++. */
901
+ const SECONDARY_DEFAULT_FRAME = { x: 480, y: 80, w: 200, h: 300 };
902
+ // =========================== public class =============================
903
+ class CallViewLayout {
904
+ constructor() {
905
+ this.currentTemplate = LayoutTemplate.FLOAT;
906
+ /** Sticky toggle: same id passed twice = clear. */
907
+ this.largeViewUserId = '';
908
+ this.isViewReversed = false;
909
+ this.currentLayout = makeEmptyFloatResult();
910
+ this.previousLayout = makeEmptyFloatResult();
911
+ }
912
+ // --- user-driven setters (match C++ signatures) ---
913
+ setLayoutTemplate(template) {
914
+ this.currentTemplate = template;
915
+ this.largeViewUserId = '';
916
+ this.isViewReversed = false;
917
+ }
918
+ setLargeViewUser(userId) {
919
+ if (this.largeViewUserId === userId) {
920
+ this.largeViewUserId = '';
921
+ }
922
+ else {
923
+ this.largeViewUserId = userId;
924
+ }
925
+ }
926
+ toggleViewReversed() {
927
+ this.isViewReversed = !this.isViewReversed;
928
+ }
929
+ // --- accessors ---
930
+ getCurrentTemplate() {
931
+ return this.currentTemplate;
932
+ }
933
+ getCurrentLayout() {
934
+ return this.currentLayout;
935
+ }
936
+ /**
937
+ * Recalculate using `state`. Returns `true` when the resulting
938
+ * layout is observably different from the previous one — host
939
+ * apps should only push a layout-changed event when this returns
940
+ * `true` (matches the C++ contract).
941
+ */
942
+ recalculate(state) {
943
+ this.previousLayout = this.currentLayout;
944
+ switch (this.currentTemplate) {
945
+ case LayoutTemplate.FLOAT:
946
+ this.currentLayout = this.calculateFloatLayout(state);
947
+ break;
948
+ case LayoutTemplate.GRID:
949
+ this.currentLayout = this.calculateGridLayout(state);
950
+ break;
951
+ }
952
+ return this.isLayoutChanged();
953
+ }
954
+ /**
955
+ * JSON shape matches `CallViewLayout::ToJson` exactly. We return a
956
+ * string (not a parsed object) to make the parity with the C++
957
+ * `ToJson()` totally explicit at call sites.
958
+ */
959
+ toJson() {
960
+ return JSON.stringify(this.toJsonObject());
961
+ }
962
+ /**
963
+ * Same as `toJson()` but skips the `JSON.stringify` step; useful
964
+ * for code paths that immediately serialise into a larger envelope.
965
+ */
966
+ toJsonObject() {
967
+ const layout = this.currentLayout;
968
+ const root = {
969
+ template: layoutTemplateToString(layout.template),
970
+ };
971
+ if (layout.template === LayoutTemplate.FLOAT) {
972
+ root.canvas = { w: layout.canvas.w, h: layout.canvas.h };
973
+ root.primaryUser = {
974
+ userId: layout.primaryUser.userId,
975
+ isLocal: layout.primaryUser.isLocal,
976
+ };
977
+ if (layout.secondaryUser) {
978
+ root.secondaryUser = {
979
+ userId: layout.secondaryUser.userId,
980
+ isLocal: layout.secondaryUser.isLocal,
981
+ frame: { ...layout.secondaryUser.frame },
982
+ };
983
+ }
984
+ else {
985
+ root.secondaryUser = null;
986
+ }
987
+ root.showRemoteInfo = layout.showRemoteInfo;
988
+ }
989
+ else {
990
+ // GRID
991
+ root.canvas = { w: layout.canvas.w, h: layout.canvas.h };
992
+ root.participants = layout.participants.map((p) => ({
993
+ userId: p.userId,
994
+ frame: { ...p.frame },
995
+ isLocal: p.isLocal,
996
+ showName: p.showName,
997
+ }));
998
+ }
999
+ return root;
1000
+ }
1001
+ // ============================ private ==============================
1002
+ isLayoutChanged() {
1003
+ const prev = this.previousLayout;
1004
+ const curr = this.currentLayout;
1005
+ if (prev.template !== curr.template)
1006
+ return true;
1007
+ if (curr.template === LayoutTemplate.FLOAT && prev.template === LayoutTemplate.FLOAT) {
1008
+ // Mirror C++ IsFloatLayoutChanged: only primary/secondary id +
1009
+ // showRemoteInfo participate in the diff.
1010
+ if (prev.primaryUser.userId !== curr.primaryUser.userId)
1011
+ return true;
1012
+ const prevSecId = prev.secondaryUser ? prev.secondaryUser.userId : '';
1013
+ const currSecId = curr.secondaryUser ? curr.secondaryUser.userId : '';
1014
+ if (prevSecId !== currSecId)
1015
+ return true;
1016
+ if (prev.showRemoteInfo !== curr.showRemoteInfo)
1017
+ return true;
1018
+ return false;
1019
+ }
1020
+ if (curr.template === LayoutTemplate.GRID && prev.template === LayoutTemplate.GRID) {
1021
+ if (prev.participants.length !== curr.participants.length)
1022
+ return true;
1023
+ for (let i = 0; i < curr.participants.length; i++) {
1024
+ const a = prev.participants[i];
1025
+ const b = curr.participants[i];
1026
+ if (a.userId !== b.userId)
1027
+ return true;
1028
+ if (a.frame.x !== b.frame.x)
1029
+ return true;
1030
+ if (a.frame.y !== b.frame.y)
1031
+ return true;
1032
+ if (a.frame.w !== b.frame.w)
1033
+ return true;
1034
+ if (a.frame.h !== b.frame.h)
1035
+ return true;
1036
+ if (a.showName !== b.showName)
1037
+ return true;
1038
+ }
1039
+ return false;
1040
+ }
1041
+ return true;
1042
+ }
1043
+ // ---- float ----
1044
+ calculateFloatLayout(state) {
1045
+ const selfId = state.selfId;
1046
+ let remoteId = '';
1047
+ for (const p of state.allParticipants) {
1048
+ if (p.id !== selfId) {
1049
+ remoteId = p.id;
1050
+ break;
1051
+ }
1052
+ }
1053
+ const isConnected = state.selfStatus === CallParticipantStatus.CALLING;
1054
+ const isAudio = state.mediaType === CallMediaType.AUDIO;
1055
+ let primaryId;
1056
+ let primaryIsLocal;
1057
+ let secondary;
1058
+ let showRemoteInfo;
1059
+ if (!isConnected && !isAudio) {
1060
+ // Video waiting: self preview as background, remote info overlay.
1061
+ primaryId = selfId;
1062
+ primaryIsLocal = true;
1063
+ secondary = null;
1064
+ showRemoteInfo = true;
1065
+ }
1066
+ else if (!isConnected && isAudio) {
1067
+ // Audio waiting: remote (avatar) as background.
1068
+ primaryId = remoteId;
1069
+ primaryIsLocal = false;
1070
+ secondary = null;
1071
+ showRemoteInfo = true;
1072
+ }
1073
+ else if (isAudio) {
1074
+ // Audio connected: remote primary, no secondary tile.
1075
+ primaryId = remoteId;
1076
+ primaryIsLocal = false;
1077
+ secondary = null;
1078
+ showRemoteInfo = true;
1079
+ }
1080
+ else {
1081
+ // Video connected: big = remote, small = self (unless reversed).
1082
+ if (!this.isViewReversed) {
1083
+ primaryId = remoteId;
1084
+ primaryIsLocal = false;
1085
+ secondary = {
1086
+ userId: selfId,
1087
+ isLocal: true,
1088
+ frame: { ...SECONDARY_DEFAULT_FRAME },
1089
+ };
1090
+ }
1091
+ else {
1092
+ primaryId = selfId;
1093
+ primaryIsLocal = true;
1094
+ secondary = {
1095
+ userId: remoteId,
1096
+ isLocal: false,
1097
+ frame: { ...SECONDARY_DEFAULT_FRAME },
1098
+ };
1099
+ }
1100
+ showRemoteInfo = false;
1101
+ }
1102
+ return {
1103
+ template: LayoutTemplate.FLOAT,
1104
+ canvas: { w: CANVAS_W, h: CANVAS_H },
1105
+ primaryUser: { userId: primaryId, isLocal: primaryIsLocal },
1106
+ secondaryUser: secondary,
1107
+ showRemoteInfo,
1108
+ };
1109
+ }
1110
+ // ---- grid ----
1111
+ calculateGridLayout(state) {
1112
+ // Build participant list in stable order: inviter first, then
1113
+ // invitees in original order. Only include status !== NONE.
1114
+ const statusMap = new Map();
1115
+ for (const p of state.allParticipants) {
1116
+ statusMap.set(p.id, p.status);
1117
+ }
1118
+ const userIds = [];
1119
+ if (state.inviter) {
1120
+ const s = statusMap.get(state.inviter);
1121
+ if (s !== undefined && s !== CallParticipantStatus.NONE) {
1122
+ userIds.push(state.inviter);
1123
+ }
1124
+ }
1125
+ for (const invitee of state.inviteeList) {
1126
+ const s = statusMap.get(invitee);
1127
+ if (s !== undefined && s !== CallParticipantStatus.NONE) {
1128
+ userIds.push(invitee);
1129
+ }
1130
+ }
1131
+ const participants = this.calculateGridPositions(userIds.length, userIds, state.selfId);
1132
+ return {
1133
+ template: LayoutTemplate.GRID,
1134
+ canvas: { w: CANVAS_W, h: CANVAS_H },
1135
+ participants,
1136
+ };
1137
+ }
1138
+ /**
1139
+ * Pure positional algorithm — does not mutate any class state.
1140
+ * `count` parameter is redundant with `userIds.length` but kept
1141
+ * to mirror the C++ signature.
1142
+ */
1143
+ calculateGridPositions(count, userIds, selfUserId) {
1144
+ const result = [];
1145
+ if (count <= 0)
1146
+ return result;
1147
+ const unit = (CANVAS_W / 3) | 0; // 240
1148
+ const half = (CANVAS_W / 2) | 0; // 360
1149
+ // Determine large_view_index
1150
+ let largeIdx = -1;
1151
+ if (this.largeViewUserId) {
1152
+ for (let i = 0; i < count; i++) {
1153
+ if (userIds[i] === this.largeViewUserId) {
1154
+ largeIdx = i;
1155
+ break;
1156
+ }
1157
+ }
1158
+ }
1159
+ // Branch 1: large view, count <= 4 — large square (full width),
1160
+ // others square (1/3 width) on the row below.
1161
+ if (largeIdx >= 0 && count <= 4) {
1162
+ const order = [largeIdx];
1163
+ for (let i = 0; i < count; i++)
1164
+ if (i !== largeIdx)
1165
+ order.push(i);
1166
+ const largeSide = CANVAS_W;
1167
+ const smallSide = unit;
1168
+ const remaining = count - 1;
1169
+ const frames = [{ x: 0, y: 0, w: largeSide, h: largeSide }];
1170
+ const bottomY = largeSide;
1171
+ for (let i = 0; i < remaining; i++) {
1172
+ frames.push({ x: i * smallSide, y: bottomY, w: smallSide, h: smallSide });
1173
+ }
1174
+ for (let i = 0; i < order.length && i < frames.length; i++) {
1175
+ const uid = userIds[order[i]];
1176
+ result.push({
1177
+ userId: uid,
1178
+ frame: frames[i],
1179
+ isLocal: uid === selfUserId,
1180
+ showName: i === 0,
1181
+ });
1182
+ }
1183
+ return result;
1184
+ }
1185
+ // Branch 2: large view, count > 4 — large 2/3 top-left, others 1/3.
1186
+ if (largeIdx >= 0 && count > 4) {
1187
+ const largeW = unit * 2;
1188
+ const largeH = unit * 2;
1189
+ const order = [largeIdx];
1190
+ for (let i = 0; i < count; i++)
1191
+ if (i !== largeIdx)
1192
+ order.push(i);
1193
+ // large at (0,0)
1194
+ {
1195
+ const uid = userIds[order[0]];
1196
+ result.push({
1197
+ userId: uid,
1198
+ frame: { x: 0, y: 0, w: largeW, h: largeH },
1199
+ isLocal: uid === selfUserId,
1200
+ showName: true,
1201
+ });
1202
+ }
1203
+ // right column next to large: up to 2 slots
1204
+ let idx = 1;
1205
+ if (idx < order.length) {
1206
+ const uid = userIds[order[idx]];
1207
+ result.push({
1208
+ userId: uid,
1209
+ frame: { x: largeW, y: 0, w: unit, h: unit },
1210
+ isLocal: uid === selfUserId,
1211
+ showName: false,
1212
+ });
1213
+ idx++;
1214
+ }
1215
+ if (idx < order.length) {
1216
+ const uid = userIds[order[idx]];
1217
+ result.push({
1218
+ userId: uid,
1219
+ frame: { x: largeW, y: unit, w: unit, h: unit },
1220
+ isLocal: uid === selfUserId,
1221
+ showName: false,
1222
+ });
1223
+ idx++;
1224
+ }
1225
+ // below large: 3 per row
1226
+ let currentY = largeH;
1227
+ while (idx < order.length) {
1228
+ for (let col = 0; col < 3 && idx < order.length; col++) {
1229
+ const uid = userIds[order[idx]];
1230
+ result.push({
1231
+ userId: uid,
1232
+ frame: { x: col * unit, y: currentY, w: unit, h: unit },
1233
+ isLocal: uid === selfUserId,
1234
+ showName: false,
1235
+ });
1236
+ idx++;
1237
+ }
1238
+ currentY += unit;
1239
+ }
1240
+ return result;
1241
+ }
1242
+ // Branch 3: no large view — standard layouts by count.
1243
+ if (count === 1) {
1244
+ result.push({
1245
+ userId: userIds[0],
1246
+ frame: { x: 0, y: 0, w: CANVAS_W, h: CANVAS_H },
1247
+ isLocal: userIds[0] === selfUserId,
1248
+ showName: false,
1249
+ });
1250
+ }
1251
+ else if (count === 2) {
1252
+ result.push({
1253
+ userId: userIds[0],
1254
+ frame: { x: 0, y: 0, w: half, h: half },
1255
+ isLocal: userIds[0] === selfUserId,
1256
+ showName: false,
1257
+ });
1258
+ result.push({
1259
+ userId: userIds[1],
1260
+ frame: { x: half, y: 0, w: half, h: half },
1261
+ isLocal: userIds[1] === selfUserId,
1262
+ showName: false,
1263
+ });
1264
+ }
1265
+ else if (count === 3) {
1266
+ result.push({
1267
+ userId: userIds[0],
1268
+ frame: { x: 0, y: 0, w: half, h: half },
1269
+ isLocal: userIds[0] === selfUserId,
1270
+ showName: false,
1271
+ });
1272
+ result.push({
1273
+ userId: userIds[1],
1274
+ frame: { x: half, y: 0, w: half, h: half },
1275
+ isLocal: userIds[1] === selfUserId,
1276
+ showName: false,
1277
+ });
1278
+ result.push({
1279
+ userId: userIds[2],
1280
+ frame: { x: (half / 2) | 0, y: half, w: half, h: half },
1281
+ isLocal: userIds[2] === selfUserId,
1282
+ showName: false,
1283
+ });
1284
+ }
1285
+ else if (count === 4) {
1286
+ result.push({
1287
+ userId: userIds[0],
1288
+ frame: { x: 0, y: 0, w: half, h: half },
1289
+ isLocal: userIds[0] === selfUserId,
1290
+ showName: false,
1291
+ });
1292
+ result.push({
1293
+ userId: userIds[1],
1294
+ frame: { x: half, y: 0, w: half, h: half },
1295
+ isLocal: userIds[1] === selfUserId,
1296
+ showName: false,
1297
+ });
1298
+ result.push({
1299
+ userId: userIds[2],
1300
+ frame: { x: 0, y: half, w: half, h: half },
1301
+ isLocal: userIds[2] === selfUserId,
1302
+ showName: false,
1303
+ });
1304
+ result.push({
1305
+ userId: userIds[3],
1306
+ frame: { x: half, y: half, w: half, h: half },
1307
+ isLocal: userIds[3] === selfUserId,
1308
+ showName: false,
1309
+ });
1310
+ }
1311
+ else {
1312
+ // 5+ participants: 3 columns of unit width
1313
+ let currentY = 0;
1314
+ for (let i = 0; i < count; i += 3) {
1315
+ const rowCount = Math.min(3, count - i);
1316
+ for (let j = 0; j < rowCount; j++) {
1317
+ const k = i + j;
1318
+ result.push({
1319
+ userId: userIds[k],
1320
+ frame: { x: j * unit, y: currentY, w: unit, h: unit },
1321
+ isLocal: userIds[k] === selfUserId,
1322
+ showName: false,
1323
+ });
1324
+ }
1325
+ currentY += unit;
1326
+ }
1327
+ }
1328
+ return result;
1329
+ }
1330
+ }
1331
+ // ============================ helpers =================================
1332
+ function layoutTemplateToString(t) {
1333
+ switch (t) {
1334
+ case LayoutTemplate.FLOAT: return 'float';
1335
+ case LayoutTemplate.GRID: return 'grid';
1336
+ default: return 'float';
1337
+ }
1338
+ }
1339
+ function makeEmptyFloatResult() {
1340
+ return {
1341
+ template: LayoutTemplate.FLOAT,
1342
+ canvas: { w: CANVAS_W, h: CANVAS_H },
1343
+ primaryUser: { userId: '', isLocal: false },
1344
+ secondaryUser: null,
1345
+ showRemoteInfo: false,
1346
+ };
1347
+ }
1348
+
1349
+ const moduleEvents = new EventEmitter();
1350
+ const TRTC_EVENT_NAMES = Object.freeze([
1351
+ 'onEnterRoom',
1352
+ 'onExitRoom',
1353
+ 'onSwitchRole',
1354
+ 'onError',
1355
+ 'onWarnning',
1356
+ 'onRemoteUserEnterRoom',
1357
+ 'onRemoteUserLeaveRoom',
1358
+ 'onUserVideoAvailable',
1359
+ 'onUserSubStreamAvailable',
1360
+ 'onUserAudioAvailable',
1361
+ 'onUserVoiceVolume',
1362
+ 'onNetworkQuality',
1363
+ 'onFirstVideoFrame',
1364
+ 'onSendFirstLocalVideoFrame',
1365
+ 'onSendFirstLocalAudioFrame',
1366
+ 'onMicDidReady',
1367
+ ]);
1368
+ function getChatEventName(logical) {
1369
+ const EVENT = (TencentCloudChat && TencentCloudChat.EVENT) || {};
1370
+ switch (logical) {
1371
+ case 'SDK_READY': return EVENT.SDK_READY || 'sdkStateReady';
1372
+ case 'KICKED_OUT': return EVENT.KICKED_OUT || 'kickedOut';
1373
+ case 'USER_SIG_EXPIRED': return EVENT.USER_SIG_EXPIRED || 'userSigExpired';
1374
+ case 'ROOM_CUSTOM_DATA_RECEIVED': return EVENT.ROOM_CUSTOM_DATA_RECEIVED;
1375
+ default: return null;
1376
+ }
1377
+ }
1378
+ class TUICallEngine {
1379
+ constructor(options) {
1380
+ const opts = options || {};
1381
+ this._emitter = new EventEmitter();
1382
+ this._sdkAppId = opts.SDKAppID || opts.sdkAppID || 0;
1383
+ this._userId = '';
1384
+ this._userSig = '';
1385
+ this._isExternalChat = false;
1386
+ this._chatListenersBound = false;
1387
+ this._trtcCloud = null;
1388
+ this._trtcListenersBound = false;
1389
+ this._trtcListenerHandlers = null;
1390
+ this._callInfo = generateCallInfo();
1391
+ this._callsInFlight = false;
1392
+ // Lazily generated on first accept/reject; preserved for the lifetime
1393
+ // of this engine instance so the backend can correlate retries.
1394
+ this._terminalId = '';
1395
+ // View-layout helper. Mirrors the C++ CallViewLayout: caller drives
1396
+ // template / large-view / reversed state via the public setters
1397
+ // below; the engine re-emits layout snapshots on every relevant
1398
+ // participant event (TODO: wire recalculate() into onUserJoin etc.).
1399
+ this._viewLayout = new CallViewLayout();
1400
+ const externalChat = opts.chat || opts.tim;
1401
+ if (externalChat) {
1402
+ this._isExternalChat = true;
1403
+ this._chat = externalChat;
1404
+ }
1405
+ }
1406
+ // ============================ static ============================
1407
+ /**
1408
+ * Create / reuse the singleton instance.
1409
+ * @param {{ SDKAppID?: number, sdkAppID?: number, tim?: any }} options
1410
+ * @returns {TUICallEngine}
1411
+ */
1412
+ static createInstance(options) {
1413
+ if (!TUICallEngine.instance) {
1414
+ TUICallEngine.instance = new TUICallEngine(options);
1415
+ }
1416
+ return TUICallEngine.instance;
1417
+ }
1418
+ static once(event, fn) {
1419
+ if (event !== 'ready') {
1420
+ moduleEvents.once(event, fn);
1421
+ return;
1422
+ }
1423
+ if (typeof fn !== 'function')
1424
+ return;
1425
+ // Always async — callers may rely on it.
1426
+ Promise.resolve().then(() => {
1427
+ try {
1428
+ fn();
1429
+ }
1430
+ catch (err) {
1431
+ // eslint-disable-next-line no-console
1432
+ console.error('[call-engine-wx] ready callback threw:', err);
1433
+ }
1434
+ });
1435
+ }
1436
+ // =========================== lifecycle ===========================
1437
+ async login(params) {
1438
+ const p = params || {};
1439
+ const userID = p.userID;
1440
+ const userSig = p.userSig;
1441
+ const sdkAppId = p.sdkAppID || p.SDKAppID || this._sdkAppId;
1442
+ if (!userID || !userSig) {
1443
+ throw makeError(TUIErrorCode.ERR_FAILED, 'login: userID and userSig are required');
1444
+ }
1445
+ if (!this._sdkAppId && sdkAppId) {
1446
+ this._sdkAppId = sdkAppId;
1447
+ }
1448
+ this._userId = userID;
1449
+ this._userSig = userSig;
1450
+ this._bindChatListenersOnce();
1451
+ try {
1452
+ this._chat = TencentCloudChat.create({ SDKAppID: this._sdkAppId, devMode: true });
1453
+ if (this._isLogined()) {
1454
+ this._emitOnceReady();
1455
+ return;
1456
+ }
1457
+ let res;
1458
+ try {
1459
+ res = await this._chat.login({ userID, userSig });
1460
+ try {
1461
+ this.getTRTCCloudInstance();
1462
+ }
1463
+ catch (_) { }
1464
+ }
1465
+ catch (err) {
1466
+ const code = err && err.code;
1467
+ const message = (err && err.message) || '';
1468
+ if (code === 2024 || code === 2025) {
1469
+ // eslint-disable-next-line no-console
1470
+ console.warn('[call-engine-wx] login: tolerated lite-chat code', code, message);
1471
+ this._emitOnceReady();
1472
+ return;
1473
+ }
1474
+ if (this._chatReadyFired && (/timeout/i.test(message) || code === 'NETWORK_TIMEOUT')) {
1475
+ // eslint-disable-next-line no-console
1476
+ console.warn('[call-engine-wx] login: chat.login() rejected with', message || code, 'AFTER SDK_READY — treating as success');
1477
+ this._emitOnceReady();
1478
+ return;
1479
+ }
1480
+ throw err;
1481
+ }
1482
+ if (res && res.data && res.data.repeatLogin) {
1483
+ this._emitOnceReady();
1484
+ return;
1485
+ }
1486
+ this._emitOnceReady();
1487
+ }
1488
+ catch (err) {
1489
+ const code = (err && err.code) || TUIErrorCode.ERR_FAILED;
1490
+ const message = (err && err.message) || 'login failed';
1491
+ this._emitter.emit(TUICallEvent.ERROR, { code, message });
1492
+ throw makeError(code, message);
1493
+ }
1494
+ }
1495
+ /**
1496
+ * Logout the underlying lite-chat session. If the chat instance was
1497
+ * @returns {Promise<void>}
1498
+ */
1499
+ async logout() {
1500
+ this._clearCallInfo();
1501
+ try {
1502
+ if (this._chat && typeof this._chat.logout === 'function') {
1503
+ await this._chat.logout();
1504
+ }
1505
+ }
1506
+ finally {
1507
+ this._userId = '';
1508
+ this._userSig = '';
1509
+ }
1510
+ }
1511
+ /**
1512
+ * Logout + release listeners + drop singleton. The instance is
1513
+ * @returns {Promise<void>}
1514
+ */
1515
+ async destroyInstance() {
1516
+ TUICallEngine.instance = null;
1517
+ try {
1518
+ await this.logout();
1519
+ }
1520
+ catch (_) { /* ignore */ }
1521
+ this._unbindChatListeners();
1522
+ this._unbindTRTCListeners();
1523
+ this._emitter.removeAllListeners();
1524
+ this._trtcCloud = null;
1525
+ }
1526
+ // ============================ events =============================
1527
+ on(event, fn) {
1528
+ this._emitter.on(event, fn);
1529
+ }
1530
+ off(event, fn) {
1531
+ this._emitter.off(event, fn);
1532
+ }
1533
+ once(event, fn) {
1534
+ this._emitter.once(event, fn);
1535
+ }
1536
+ // ============================ getters ============================
1537
+ getTim() {
1538
+ return this._chat;
1539
+ }
1540
+ getTRTCCloudInstance() {
1541
+ if (this._trtcCloud)
1542
+ return this._trtcCloud;
1543
+ if (!TRTCCloud || typeof TRTCCloud.getTRTCShareInstance !== 'function') {
1544
+ throw makeError(TUIErrorCode.ERR_FAILED, '[call-engine-wx] @tencentcloud/trtc-component-wx is not installed '
1545
+ + 'or does not expose getTRTCShareInstance().');
1546
+ }
1547
+ this._trtcCloud = TRTCCloud.getTRTCShareInstance();
1548
+ this._bindTRTCListenersOnce();
1549
+ return this._trtcCloud;
1550
+ }
1551
+ // ============================ misc ===============================
1552
+ setLogLevel(level) {
1553
+ try {
1554
+ if (this._chat && typeof this._chat.setLogLevel === 'function') {
1555
+ this._chat.setLogLevel(level);
1556
+ }
1557
+ }
1558
+ catch (_) { /* ignore */ }
1559
+ }
1560
+ // ============================ calls API ==============================
1561
+ async calls(params) {
1562
+ console.warn('---> calls 入参 ', JSON.stringify(params));
1563
+ // 【1】state checks
1564
+ if (!this._isLogined() && !this._chatReadyFired) {
1565
+ throw makeError(TUIErrorCode.ERR_FAILED, 'calls: not logged in. Call login() first and wait for SDK_READY.');
1566
+ }
1567
+ if (this._callsInFlight) {
1568
+ throw makeError(TUIErrorCode.ERR_FAILED, 'calls: a previous calls() is still in progress');
1569
+ }
1570
+ if (this._callInfo.status !== CallStatus.NONE) {
1571
+ throw makeError(TUIErrorCode.ERR_FAILED, 'calls: there is an active call already; hang up before starting a new one');
1572
+ }
1573
+ // 【2】param normalisation
1574
+ if (!isArray(params?.userIDList) || params?.userIDList?.length === 0) {
1575
+ throw makeError(TUIErrorCode.ERR_FAILED, 'calls: userIDList cannot be empty');
1576
+ }
1577
+ if (params?.type !== CallMediaType.AUDIO && params?.type !== CallMediaType.VIDEO) {
1578
+ throw makeError(TUIErrorCode.ERR_FAILED, 'calls: invalid mediaType');
1579
+ }
1580
+ const inviter = this._getLoginUserId();
1581
+ if (!inviter) {
1582
+ throw makeError(TUIErrorCode.ERR_FAILED, 'calls: cannot resolve login user id');
1583
+ }
1584
+ this._callInfo = generateCallInfo({
1585
+ ...params,
1586
+ strRoomId: generateRandomStrRoomId(params?.strRoomID),
1587
+ status: CallStatus.WAITING,
1588
+ role: CallRole.CALLER,
1589
+ inviter,
1590
+ });
1591
+ this._callsInFlight = true;
1592
+ let trtcEntered = false;
1593
+ try {
1594
+ // 【3】 enter TRTC room first
1595
+ try {
1596
+ await this._enterTRTCRoom();
1597
+ trtcEntered = true;
1598
+ }
1599
+ catch (err) {
1600
+ this._safeLeaveTRTCRoom();
1601
+ const code = (err && err.code) || TUIErrorCode.ERR_FAILED;
1602
+ const message = (err && err.message) || 'enter TRTC room failed';
1603
+ this._emitter.emit(TUICallEvent.ERROR, { code, message });
1604
+ throw makeError(code, message);
1605
+ }
1606
+ // 【4】send call_engine_srv.start_call ----
1607
+ const resp = await SSOChannel.startCall({
1608
+ chat: this._chat,
1609
+ ...this._callInfo,
1610
+ userIdList: this._callInfo.inviteeList,
1611
+ });
1612
+ if (resp.errCode !== 0) {
1613
+ this._safeLeaveTRTCRoom();
1614
+ trtcEntered = false;
1615
+ this._emitter.emit(TUICallEvent.ERROR, { code: resp.errCode, message: resp.errMsg });
1616
+ throw makeError(resp.errCode, resp.errMsg || 'start_call failed');
1617
+ }
1618
+ this._callInfo.callId = resp.inviteId;
1619
+ this._callInfo.callStartTime = Date.now();
1620
+ let busyCount = 0;
1621
+ for (const r of resp.callResultList) {
1622
+ switch (r.resultCode) {
1623
+ case CallInvitationResultCodeType.INVITED:
1624
+ this._emitter.emit(TUICallEvent.USER_ENTER, r.userId);
1625
+ break;
1626
+ case CallInvitationResultCodeType.SUCCESS:
1627
+ // server already sees this user as Calling (e.g. accepted
1628
+ // on another device of the same account before).
1629
+ this._emitter.emit('onUserInviting', r.userId);
1630
+ break;
1631
+ case CallInvitationResultCodeType.LINE_BUSY:
1632
+ this._emitter.emit('onUserLineBusy', r.userId);
1633
+ busyCount++;
1634
+ break;
1635
+ default:
1636
+ break;
1637
+ }
1638
+ }
1639
+ // ---- 7) all callees are busy → wrap the call up locally ----
1640
+ if (busyCount >= this._callInfo.inviteeList.length) {
1641
+ const lastUser = resp.callResultList[resp.callResultList.length - 1];
1642
+ this._safeLeaveTRTCRoom();
1643
+ trtcEntered = false;
1644
+ this._emitter.emit(TUICallEvent.ON_CALL_NOT_CONNECTED, {
1645
+ callInfo: { ...this._callInfo },
1646
+ userId: lastUser ? lastUser.userId : '',
1647
+ reason: CallEndReason.LINE_BUSY,
1648
+ });
1649
+ this._clearCallInfo();
1650
+ return {
1651
+ inviteId: resp.inviteId,
1652
+ roomId: this._callInfo,
1653
+ callResultList: resp.callResultList,
1654
+ };
1655
+ }
1656
+ // ---- 8) success — kick off the 2s heartbeat loop ----
1657
+ // The backend will mark the call as dropped if it stops hearing
1658
+ // from us. `_clearCallInfo` is the single tear-down point that
1659
+ // stops the loop (hangup / reject / busy / error all funnel
1660
+ // through it).
1661
+ SSOChannel.startHeartBeat({
1662
+ chat: this._chat,
1663
+ callId: this._callInfo.callId,
1664
+ userId: this._callInfo.inviter,
1665
+ onTimeout: (droppedCallId) => {
1666
+ // Only react if this timeout still belongs to the active call.
1667
+ if (this._callInfo.callId !== droppedCallId)
1668
+ return;
1669
+ this._emitter.emit(TUICallEvent.ON_CALL_NOT_CONNECTED, {
1670
+ callInfo: { ...this._callInfo },
1671
+ userId: this._callInfo.inviter,
1672
+ reason: CallEndReason.LINE_BUSY,
1673
+ });
1674
+ this._safeLeaveTRTCRoom();
1675
+ this._clearCallInfo();
1676
+ },
1677
+ });
1678
+ return {
1679
+ inviteId: resp.inviteId,
1680
+ roomId: this._callInfo,
1681
+ callResultList: resp.callResultList,
1682
+ };
1683
+ }
1684
+ catch (err) {
1685
+ // Best-effort cleanup if anything threw between steps 4 and 6.
1686
+ if (trtcEntered)
1687
+ this._safeLeaveTRTCRoom();
1688
+ this._clearCallInfo();
1689
+ throw err;
1690
+ }
1691
+ finally {
1692
+ this._callsInFlight = false;
1693
+ }
1694
+ }
1695
+ /**
1696
+ * Callee accepts the current incoming invitation.
1697
+ */
1698
+ async accept() {
1699
+ if (!this._isLogined() && !this._chatReadyFired) {
1700
+ throw makeError(TUIErrorCode.ERR_FAILED, 'accept: not logged in. Call login() first and wait for SDK_READY.');
1701
+ }
1702
+ if (this._callInfo.role !== CallRole.CALLEE) {
1703
+ throw makeError(TUIErrorCode.ERR_FAILED, 'accept: current role is not CALLEE');
1704
+ }
1705
+ if (this._callInfo.status !== CallStatus.WAITING) {
1706
+ throw makeError(TUIErrorCode.ERR_FAILED, `accept: invalid call status ${this._callInfo.status}; expected WAITING`);
1707
+ }
1708
+ const callId = this._callInfo.callId;
1709
+ if (!callId) {
1710
+ throw makeError(TUIErrorCode.ERR_FAILED, 'accept: no active invitation (callId is empty)');
1711
+ }
1712
+ let trtcEntered = false;
1713
+ try {
1714
+ await this._enterTRTCRoom();
1715
+ trtcEntered = true;
1716
+ // 2) Send handle_call(Accepted).
1717
+ const terminalId = this._getOrCreateTerminalId();
1718
+ const resp = await SSOChannel.accept({
1719
+ chat: this._chat,
1720
+ callId,
1721
+ terminalId,
1722
+ });
1723
+ if (resp.errCode !== 0) {
1724
+ throw makeError(resp.errCode, resp.errMsg || 'handle_call(accept) failed');
1725
+ }
1726
+ // 3) Local state -> CALLING; surface the begin event.
1727
+ this._callInfo.status = CallStatus.CALLING;
1728
+ this._callInfo.callStartTime = Date.now();
1729
+ this._emitter.emit(TUICallEvent.ON_CALL_BEGIN, {
1730
+ callInfo: { ...this._callInfo },
1731
+ });
1732
+ // 4) Heartbeat — same loop the caller side uses.
1733
+ SSOChannel.startHeartBeat({
1734
+ chat: this._chat,
1735
+ callId: this._callInfo.callId,
1736
+ userId: this._getLoginUserId(),
1737
+ onTimeout: (droppedCallId) => {
1738
+ if (this._callInfo.callId !== droppedCallId)
1739
+ return;
1740
+ this._emitter.emit(TUICallEvent.ON_CALL_NOT_CONNECTED, {
1741
+ callInfo: { ...this._callInfo },
1742
+ userId: this._getLoginUserId(),
1743
+ reason: CallEndReason.LINE_BUSY,
1744
+ });
1745
+ this._safeLeaveTRTCRoom();
1746
+ this._clearCallInfo();
1747
+ },
1748
+ });
1749
+ return { callId, roomId: this._callInfo.roomId };
1750
+ }
1751
+ catch (err) {
1752
+ if (trtcEntered)
1753
+ this._safeLeaveTRTCRoom();
1754
+ this._clearCallInfo();
1755
+ const code = (err && err.code) || TUIErrorCode.ERR_FAILED;
1756
+ const message = (err && err.message) || 'accept failed';
1757
+ this._emitter.emit(TUICallEvent.ERROR, { code, message });
1758
+ throw makeError(code, message);
1759
+ }
1760
+ }
1761
+ /**
1762
+ * Callee rejects the current incoming invitation.
1763
+ */
1764
+ async reject() {
1765
+ if (!this._isLogined() && !this._chatReadyFired) {
1766
+ throw makeError(TUIErrorCode.ERR_FAILED, 'reject: not logged in. Call login() first and wait for SDK_READY.');
1767
+ }
1768
+ if (this._callInfo.role !== CallRole.CALLEE) {
1769
+ throw makeError(TUIErrorCode.ERR_FAILED, 'reject: current role is not CALLEE');
1770
+ }
1771
+ if (this._callInfo.status !== CallStatus.WAITING) {
1772
+ throw makeError(TUIErrorCode.ERR_FAILED, `reject: invalid call status ${this._callInfo.status}; expected WAITING`);
1773
+ }
1774
+ const callId = this._callInfo.callId;
1775
+ if (!callId) {
1776
+ throw makeError(TUIErrorCode.ERR_FAILED, 'reject: no active invitation (callId is empty)');
1777
+ }
1778
+ const snapshot = { ...this._callInfo };
1779
+ try {
1780
+ const terminalId = this._getOrCreateTerminalId();
1781
+ const resp = await SSOChannel.reject({
1782
+ chat: this._chat,
1783
+ callId,
1784
+ terminalId,
1785
+ });
1786
+ if (resp.errCode !== 0) {
1787
+ console.warn('[call-engine-wx] reject: backend reported error', resp.errCode, resp.errMsg);
1788
+ }
1789
+ this._emitter.emit(TUICallEvent.ON_CALL_END, {
1790
+ callInfo: snapshot,
1791
+ reason: CallEndReason.REJECT,
1792
+ userId: this._getLoginUserId(),
1793
+ });
1794
+ return { callId };
1795
+ }
1796
+ finally {
1797
+ this._clearCallInfo();
1798
+ }
1799
+ }
1800
+ /**
1801
+ * Hang up an in-progress call. Both caller and callee may invoke
1802
+ * this in either the WAITING state (callee hasn't accepted yet)
1803
+ * or the CALLING state (call is fully established).
1804
+ */
1805
+ async hangup() {
1806
+ if (!this._isLogined() && !this._chatReadyFired) {
1807
+ throw makeError(TUIErrorCode.ERR_FAILED, 'hangup: not logged in. Call login() first and wait for SDK_READY.');
1808
+ }
1809
+ if (this._callInfo.status !== CallStatus.CALLING &&
1810
+ this._callInfo.status !== CallStatus.WAITING) {
1811
+ throw makeError(TUIErrorCode.ERR_FAILED, `hangup: invalid call status ${this._callInfo.status}; expected CALLING or WAITING`);
1812
+ }
1813
+ const callId = this._callInfo.callId;
1814
+ if (!callId) {
1815
+ throw makeError(TUIErrorCode.ERR_FAILED, 'hangup: no active call (callId is empty)');
1816
+ }
1817
+ const isCancel = this._callInfo.role === CallRole.CALLER && this._callInfo.status === CallStatus.WAITING;
1818
+ const snapshot = { ...this._callInfo };
1819
+ try {
1820
+ if (isCancel) {
1821
+ const inviter = this._callInfo.inviter || this._getLoginUserId();
1822
+ const calleeList = Array.isArray(this._callInfo.inviteeList)
1823
+ ? this._callInfo.inviteeList.slice()
1824
+ : [];
1825
+ if (calleeList.length > 0) {
1826
+ const resp = await SSOChannel.cancel({
1827
+ chat: this._chat,
1828
+ inviter,
1829
+ callId,
1830
+ calleeList,
1831
+ });
1832
+ if (resp.errCode !== 0) {
1833
+ console.warn('[call-engine-wx] hangup(cancel): backend reported error', resp.errCode, resp.errMsg);
1834
+ }
1835
+ }
1836
+ }
1837
+ else {
1838
+ const terminalId = this._getOrCreateTerminalId();
1839
+ const resp = await SSOChannel.hangup({
1840
+ chat: this._chat,
1841
+ callId,
1842
+ terminalId,
1843
+ });
1844
+ if (resp.errCode !== 0) {
1845
+ console.warn('[call-engine-wx] hangup: backend reported error', resp.errCode, resp.errMsg);
1846
+ }
1847
+ }
1848
+ this._safeLeaveTRTCRoom();
1849
+ this._emitter.emit(TUICallEvent.ON_CALL_END, {
1850
+ callInfo: snapshot,
1851
+ reason: isCancel ? CallEndReason.CANCELED : CallEndReason.HANGUP,
1852
+ userId: this._getLoginUserId(),
1853
+ });
1854
+ return { callId };
1855
+ }
1856
+ finally {
1857
+ this._clearCallInfo();
1858
+ }
1859
+ }
1860
+ // ============================ view layout interface ========================
1861
+ /**
1862
+ * Switch the layout template (FLOAT / GRID). Also resets the
1863
+ * @param {LayoutTemplate} template Target template.
1864
+ */
1865
+ setLayoutTemplate(template) {
1866
+ this._viewLayout.setLayoutTemplate(template);
1867
+ this._recalcAndEmitLayout();
1868
+ }
1869
+ /**
1870
+ * Pin a participant to the large view. Calling with the same
1871
+ * userId twice clears the pin (sticky toggle).
1872
+ * @param {string} userId Participant userId. Pass '' to clear.
1873
+ */
1874
+ setLargeViewUser(userId) {
1875
+ this._viewLayout.setLargeViewUser(userId);
1876
+ this._recalcAndEmitLayout();
1877
+ }
1878
+ /**
1879
+ * Flip the primary / secondary roles in FLOAT layout
1880
+ * (no-op semantics-wise for GRID, kept for parity with C++).
1881
+ */
1882
+ toggleViewReversed() {
1883
+ this._viewLayout.toggleViewReversed();
1884
+ this._recalcAndEmitLayout();
1885
+ }
1886
+ /**
1887
+ * Build a `CallStateInput` snapshot from the engine's current
1888
+ * `_callInfo` + login userId. Self-contained — does NOT mutate
1889
+ * any engine state.
1890
+ * @private
1891
+ */
1892
+ _buildLayoutState() {
1893
+ const selfId = this._getLoginUserId() || '';
1894
+ const info = this._callInfo || {};
1895
+ const inviter = info.inviter || '';
1896
+ const inviteeList = Array.isArray(info.inviteeList) ? info.inviteeList : [];
1897
+ const selfStatus = typeof info.status === 'number'
1898
+ ? info.status
1899
+ : CallParticipantStatus.NONE;
1900
+ const seen = new Set();
1901
+ const allParticipants = [];
1902
+ const push = (id, status) => {
1903
+ if (!id || seen.has(id))
1904
+ return;
1905
+ seen.add(id);
1906
+ allParticipants.push({ id, status });
1907
+ };
1908
+ push(selfId, selfStatus);
1909
+ if (inviter)
1910
+ push(inviter, selfStatus);
1911
+ for (const u of inviteeList)
1912
+ push(u, selfStatus);
1913
+ const mediaType = typeof info.mediaType === 'number' ? info.mediaType : CallMediaType.VIDEO;
1914
+ return {
1915
+ selfId,
1916
+ selfStatus,
1917
+ mediaType: mediaType,
1918
+ allParticipants,
1919
+ inviter,
1920
+ inviteeList,
1921
+ };
1922
+ }
1923
+ /**
1924
+ * Recalculate the layout from the engine's current state. If the
1925
+ * resulting snapshot differs from the previous one, emit
1926
+ * `viewLayoutChanged` with the `toJsonObject()` payload.
1927
+ * @private
1928
+ */
1929
+ _recalcAndEmitLayout() {
1930
+ try {
1931
+ const changed = this._viewLayout.recalculate(this._buildLayoutState());
1932
+ if (changed) {
1933
+ this._emitter.emit(TUICallEvent.VIEW_LAYOUT_CHANGED, this._viewLayout.toJsonObject());
1934
+ }
1935
+ }
1936
+ catch (err) {
1937
+ // A layout-emit failure must never break the caller's flow.
1938
+ // eslint-disable-next-line no-console
1939
+ console.warn('[call-engine-wx] _recalcAndEmitLayout failed:', err && err.message);
1940
+ }
1941
+ }
1942
+ /**
1943
+ * Turn on the local camera preview.
1944
+ * @param {string} videoViewDomID DOM id of the host preview node.
1945
+ * @param {boolean} [isFrontCamera] true = front, false = rear,
1946
+ * undefined = keep SDK default (-1).
1947
+ * @returns {Promise<unknown>}
1948
+ */
1949
+ async openCamera(videoViewDomID, isFrontCamera) {
1950
+ let camera;
1951
+ if (typeof isFrontCamera === 'undefined') {
1952
+ camera = -1;
1953
+ }
1954
+ else {
1955
+ camera = isFrontCamera ? 1 : 0;
1956
+ }
1957
+ if (!isFunction(this?._trtcCloud?.startLocalPreview))
1958
+ return;
1959
+ return this._trtcCloud.startLocalPreview(videoViewDomID, camera);
1960
+ }
1961
+ async closeCamera() {
1962
+ if (!isFunction(this?._trtcCloud?.stopLocalPreview))
1963
+ return undefined;
1964
+ return this._trtcCloud.stopLocalPreview();
1965
+ }
1966
+ /**
1967
+ * Switch between the front and rear camera (mobile only).
1968
+ * @param {boolean} [isFrontCamera] target camera. true = front,
1969
+ * false = rear. If omitted, toggle current.
1970
+ * @returns {Promise<unknown>}
1971
+ */
1972
+ async switchCamera(isFrontCamera) {
1973
+ if (!isFunction(this?._trtcCloud?.switchCamera))
1974
+ return;
1975
+ await this._trtcCloud.switchCamera(!!isFrontCamera);
1976
+ }
1977
+ async openMicrophone() {
1978
+ // this._logger.info(`openMicrophone.start`, { type: 'api' });
1979
+ if (!isFunction(this?._trtcCloud?.startLocalAudio))
1980
+ return;
1981
+ await this._trtcCloud.startLocalAudio();
1982
+ }
1983
+ async closeMicrophone() {
1984
+ // this._logger.info(`closeMicrophone.start`, { type: 'api' });
1985
+ if (!isFunction(this?._trtcCloud?.stopLocalAudio))
1986
+ return;
1987
+ await this._trtcCloud.stopLocalAudio();
1988
+ }
1989
+ /**
1990
+ * Switch the audio playback output between SPEAKER (loudspeaker)
1991
+ * and EAR (earpiece). Mirrors the legacy WASM SDK shape so app code
1992
+ * can stay the same:
1993
+ * @param {number} device One of `AudioPlayBackDevice` (SPEAKER=0, EAR=1).
1994
+ * @returns {Promise<unknown>}
1995
+ */
1996
+ async selectAudioPlaybackDevice(device) {
1997
+ if (!isFunction(this?._trtcCloud?.setAudioRoute))
1998
+ return;
1999
+ const route = device === AudioPlayBackDevice.EAR || device === 1
2000
+ ? AudioPlayBackDevice.EAR
2001
+ : AudioPlayBackDevice.SPEAKER;
2002
+ await this._trtcCloud.setAudioRoute(route);
2003
+ }
2004
+ /**
2005
+ * Pull a remote user's video stream into the matching <TRTCPlayer>.
2006
+ * @param {{ userId: string, view: string, streamType?: number }} params
2007
+ * @returns {Promise<unknown>}
2008
+ */
2009
+ async startRemoteView(params) {
2010
+ if (!this._trtcCloud || typeof this._trtcCloud.startRemoteView !== 'function') {
2011
+ return undefined;
2012
+ }
2013
+ const { userId, view, streamType = 0 } = params || {};
2014
+ return this._trtcCloud.startRemoteView(userId, view, streamType);
2015
+ }
2016
+ /**
2017
+ * Stop pulling a remote user's stream.
2018
+ * Mirrors trtc-cloud-wx's `stopRemoteView(userId, streamType)`.
2019
+ * @param {{ userId: string, streamType?: number }} params
2020
+ * @returns {Promise<unknown>}
2021
+ */
2022
+ async stopRemoteView(params) {
2023
+ if (!this._trtcCloud || typeof this._trtcCloud.stopRemoteView !== 'function') {
2024
+ return undefined;
2025
+ }
2026
+ const { userId, streamType = 0 } = params || {};
2027
+ return this._trtcCloud.stopRemoteView(userId, streamType);
2028
+ }
2029
+ // ============================ internal ==========================
2030
+ _bindChatListenersOnce() {
2031
+ if (this._chatListenersBound || !this._chat || typeof this._chat.on !== 'function')
2032
+ return;
2033
+ const readyName = getChatEventName('SDK_READY');
2034
+ const kickName = getChatEventName('KICKED_OUT');
2035
+ const sigExpName = getChatEventName('USER_SIG_EXPIRED');
2036
+ const roomCustomDataReceivedName = getChatEventName('ROOM_CUSTOM_DATA_RECEIVED');
2037
+ this._onChatReady = () => {
2038
+ this._chatReadyFired = true;
2039
+ this._emitOnceReady();
2040
+ };
2041
+ this._onChatKickedOut = (payload) => {
2042
+ this._emitter.emit(TUICallEvent.KICKED_OUT, payload || {});
2043
+ };
2044
+ this._onChatUserSigExpired = () => {
2045
+ this._emitter.emit(TUICallEvent.onUserSigExpired, {});
2046
+ };
2047
+ this._onChatRoomCustomDataReceived = (payload) => {
2048
+ const { Data: data = {}, Head = {} } = JSON.parse(payload.data);
2049
+ if (Head?.Command === 'call_engine_srv.invite_user_notify') {
2050
+ const { CallInfo: callInfo = {} } = data;
2051
+ let roomId = { intRoomId: 0, strRoomId: '' };
2052
+ if (callInfo.RoomIdType === 1) {
2053
+ roomId.intRoomId = Number(callInfo.RoomId);
2054
+ }
2055
+ else if (callInfo.RoomIdType === 2) {
2056
+ roomId.strRoomId = callInfo.RoomId;
2057
+ }
2058
+ else {
2059
+ return;
2060
+ }
2061
+ this._callInfo = generateCallInfo({
2062
+ userIDList: data?.CalleeList_Account || [],
2063
+ callId: data?.CallInfo?.CallId || '',
2064
+ inviter: data?.Caller_Account || '',
2065
+ roomId: roomId.intRoomId || 0,
2066
+ strRoomId: roomId.strRoomId || '',
2067
+ groupId: data?.CallInfo?.GroupId || '',
2068
+ type: data?.CallInfo?.CallMediaType,
2069
+ status: CallStatus.WAITING,
2070
+ role: CallRole.CALLEE,
2071
+ });
2072
+ this._emitter.emit(TUICallEvent.ON_CALL_RECEIVED, {
2073
+ callId: this._callInfo.callId,
2074
+ inviter: this._callInfo.inviter,
2075
+ inviteeList: this._callInfo.inviteeList,
2076
+ groupId: this._callInfo.groupId,
2077
+ mediaType: this._callInfo.mediaType,
2078
+ roomId: this._callInfo.roomId,
2079
+ userData: data?.UserData || '',
2080
+ callInfo: { ...this._callInfo },
2081
+ });
2082
+ }
2083
+ };
2084
+ if (readyName)
2085
+ this._chat.on(readyName, this._onChatReady);
2086
+ if (kickName)
2087
+ this._chat.on(kickName, this._onChatKickedOut);
2088
+ if (sigExpName)
2089
+ this._chat.on(sigExpName, this._onChatUserSigExpired);
2090
+ if (roomCustomDataReceivedName)
2091
+ this._chat.on(roomCustomDataReceivedName, this._onChatRoomCustomDataReceived);
2092
+ this._chatListenersBound = true;
2093
+ }
2094
+ _unbindChatListeners() {
2095
+ if (!this._chatListenersBound || !this._chat || typeof this._chat.off !== 'function')
2096
+ return;
2097
+ const readyName = getChatEventName('SDK_READY');
2098
+ const kickName = getChatEventName('KICKED_OUT');
2099
+ const sigExpName = getChatEventName('USER_SIG_EXPIRED');
2100
+ const roomCustomDataReceivedName = getChatEventName('ROOM_CUSTOM_DATA_RECEIVED');
2101
+ try {
2102
+ if (readyName && this._onChatReady)
2103
+ this._chat.off(readyName, this._onChatReady);
2104
+ }
2105
+ catch (_) { /* ignore */ }
2106
+ try {
2107
+ if (kickName && this._onChatKickedOut)
2108
+ this._chat.off(kickName, this._onChatKickedOut);
2109
+ }
2110
+ catch (_) { /* ignore */ }
2111
+ try {
2112
+ if (sigExpName && this._onChatUserSigExpired)
2113
+ this._chat.off(sigExpName, this._onChatUserSigExpired);
2114
+ }
2115
+ catch (_) { /* ignore */ }
2116
+ try {
2117
+ if (roomCustomDataReceivedName && this._onChatRoomCustomDataReceived)
2118
+ this._chat.off(roomCustomDataReceivedName, this._onChatRoomCustomDataReceived);
2119
+ }
2120
+ catch (_) { /* ignore */ }
2121
+ this._chatListenersBound = false;
2122
+ }
2123
+ // ============================ TRTC events ========================
2124
+ _bindTRTCListenersOnce() {
2125
+ if (this._trtcListenersBound || !this._trtcCloud)
2126
+ return;
2127
+ const trtc = this._trtcCloud;
2128
+ if (typeof trtc.on !== 'function')
2129
+ return;
2130
+ /** @type {Record<string, (...args: any[]) => void>} */
2131
+ const handlers = {};
2132
+ const make = (name) => (...args) => {
2133
+ try {
2134
+ this._emitter.emit(name, ...args);
2135
+ }
2136
+ catch (e) {
2137
+ // eslint-disable-next-line no-console
2138
+ console.warn('[call-engine-wx] listener for', name, 'threw:', e);
2139
+ }
2140
+ };
2141
+ for (const name of TRTC_EVENT_NAMES) {
2142
+ let fn;
2143
+ if (name === 'onRemoteUserEnterRoom') {
2144
+ const forward = make(name);
2145
+ fn = (...args) => {
2146
+ try {
2147
+ if (this._callInfo &&
2148
+ this._callInfo.callId &&
2149
+ this._callInfo.status === CallStatus.WAITING) {
2150
+ this._callInfo.status = CallStatus.CALLING;
2151
+ if (!this._callInfo.callStartTime) {
2152
+ this._callInfo.callStartTime = Date.now();
2153
+ }
2154
+ this._emitter.emit(TUICallEvent.ON_CALL_BEGIN, {
2155
+ callInfo: { ...this._callInfo },
2156
+ });
2157
+ }
2158
+ }
2159
+ catch (e) {
2160
+ // eslint-disable-next-line no-console
2161
+ console.warn('[call-engine-wx] ON_CALL_BEGIN gate threw:', e);
2162
+ }
2163
+ forward(...args);
2164
+ };
2165
+ }
2166
+ else {
2167
+ fn = make(name);
2168
+ }
2169
+ handlers[name] = fn;
2170
+ try {
2171
+ trtc.on(name, fn);
2172
+ }
2173
+ catch (_) { /* ignore */ }
2174
+ }
2175
+ this._trtcListenerHandlers = handlers;
2176
+ this._trtcListenersBound = true;
2177
+ }
2178
+ _unbindTRTCListeners() {
2179
+ if (!this._trtcListenersBound)
2180
+ return;
2181
+ const trtc = this._trtcCloud;
2182
+ const handlers = this._trtcListenerHandlers || {};
2183
+ if (trtc && typeof trtc.off === 'function') {
2184
+ for (const name of Object.keys(handlers)) {
2185
+ try {
2186
+ trtc.off(name, handlers[name]);
2187
+ }
2188
+ catch (_) { /* ignore */ }
2189
+ }
2190
+ }
2191
+ this._trtcListenerHandlers = null;
2192
+ this._trtcListenersBound = false;
2193
+ }
2194
+ _emitOnceReady() {
2195
+ if (this._readyEmitted)
2196
+ return;
2197
+ this._readyEmitted = true;
2198
+ this._emitter.emit(TUICallEvent.SDK_READY, { name: 'sdk_ready' });
2199
+ this._fetchPendingInvitation().catch((err) => {
2200
+ // eslint-disable-next-line no-console
2201
+ console.warn('[call-engine-wx] _fetchPendingInvitation: unexpected error', err);
2202
+ });
2203
+ }
2204
+ // 登录后拉取通话信息, 避免登录前收到邀请
2205
+ async _fetchPendingInvitation() {
2206
+ if (!this._chat || !this._isLogined())
2207
+ return;
2208
+ if (this._callInfo && this._callInfo.callId)
2209
+ return; // already in a call
2210
+ const userId = this._getLoginUserId();
2211
+ if (!userId)
2212
+ return;
2213
+ let resp;
2214
+ try {
2215
+ resp = await SSOChannel.getInvitation({ chat: this._chat, userId });
2216
+ }
2217
+ catch (err) {
2218
+ // eslint-disable-next-line no-console
2219
+ console.warn('[call-engine-wx] getInvitation request threw:', err);
2220
+ return;
2221
+ }
2222
+ if (!resp || resp.errCode !== 0)
2223
+ return;
2224
+ if (!resp.data)
2225
+ return;
2226
+ try {
2227
+ const replayed = {
2228
+ data: JSON.stringify({
2229
+ Data: resp.data,
2230
+ Head: { Command: 'call_engine_srv.invite_user_notify' },
2231
+ }),
2232
+ };
2233
+ if (isFunction(this._onChatRoomCustomDataReceived)) {
2234
+ this._onChatRoomCustomDataReceived(replayed);
2235
+ }
2236
+ }
2237
+ catch (err) {
2238
+ // eslint-disable-next-line no-console
2239
+ console.warn('[call-engine-wx] replay pending invitation failed:', err);
2240
+ }
2241
+ }
2242
+ _isLogined() {
2243
+ try {
2244
+ if (!this._chat)
2245
+ return false;
2246
+ if (typeof this._chat.getLoginUser === 'function') {
2247
+ return !!this._chat.getLoginUser();
2248
+ }
2249
+ }
2250
+ catch (_) { /* ignore */ }
2251
+ return false;
2252
+ }
2253
+ _getLoginUserId() {
2254
+ if (this._userId)
2255
+ return this._userId;
2256
+ if (isFunction(this?._chat?.getLoginUser))
2257
+ return this._chat.getLoginUser() || '';
2258
+ return '';
2259
+ }
2260
+ // 支持数字和字符串房间号进房, 数字房间号进房为了和低版本互通
2261
+ async _enterTRTCRoom() {
2262
+ try {
2263
+ SSOChannel.stopHeartBeat();
2264
+ }
2265
+ catch (_) { /* ignore */ }
2266
+ if (this._callInfo.callType === CallMediaType.UNKNOWN)
2267
+ throw makeError(-1, 'call type is none');
2268
+ const { roomId } = this._callInfo;
2269
+ if (roomId.intRoomId === 0 && roomId.strRoomId === '')
2270
+ throw makeError(-1, 'room id is empty');
2271
+ const params = {
2272
+ sdkAppId: this._sdkAppId,
2273
+ userId: this._getLoginUserId(),
2274
+ userSig: this._userSig,
2275
+ role: 20, // call 都是主播角色
2276
+ };
2277
+ if (roomId.intRoomId > 0) {
2278
+ params.roomId = roomId.intRoomId;
2279
+ }
2280
+ else {
2281
+ params.strRoomId = roomId.strRoomId;
2282
+ }
2283
+ const scene = this._callInfo.mediaType === CallMediaType.VIDEO ? 0 : 2; // 0 - video; 2 - audio
2284
+ const trtc = this.getTRTCCloudInstance();
2285
+ const ENTER_ROOM_TIMEOUT_MS = 10000;
2286
+ await new Promise((resolve, reject) => {
2287
+ let settled = false;
2288
+ let timer = null;
2289
+ const cleanup = () => {
2290
+ if (timer) {
2291
+ clearTimeout(timer);
2292
+ timer = null;
2293
+ }
2294
+ try {
2295
+ this._emitter.off('onEnterRoom', onEnterRoom);
2296
+ }
2297
+ catch (_) { /* ignore */ }
2298
+ };
2299
+ const onEnterRoom = (result) => {
2300
+ if (settled)
2301
+ return;
2302
+ settled = true;
2303
+ cleanup();
2304
+ if (typeof result === 'number' && result > 0) {
2305
+ resolve();
2306
+ }
2307
+ else {
2308
+ reject(makeError(typeof result === 'number' ? result : -1, `enterRoom failed: ${result}`));
2309
+ }
2310
+ };
2311
+ this._emitter.on('onEnterRoom', onEnterRoom);
2312
+ timer = setTimeout(() => {
2313
+ if (settled)
2314
+ return;
2315
+ settled = true;
2316
+ cleanup();
2317
+ reject(makeError(-1, 'enterRoom timeout'));
2318
+ }, ENTER_ROOM_TIMEOUT_MS);
2319
+ try {
2320
+ const ret = trtc.enterRoom(params, scene);
2321
+ if (ret && typeof ret.catch === 'function') {
2322
+ ret.catch((err) => {
2323
+ if (settled)
2324
+ return;
2325
+ settled = true;
2326
+ cleanup();
2327
+ reject(err);
2328
+ });
2329
+ }
2330
+ }
2331
+ catch (err) {
2332
+ if (settled)
2333
+ return;
2334
+ settled = true;
2335
+ cleanup();
2336
+ reject(err);
2337
+ }
2338
+ });
2339
+ }
2340
+ _safeLeaveTRTCRoom() {
2341
+ try {
2342
+ if (this._trtcCloud && typeof this._trtcCloud.exitRoom === 'function') {
2343
+ this._trtcCloud.exitRoom();
2344
+ }
2345
+ }
2346
+ catch (_) { /* ignore */ }
2347
+ }
2348
+ _clearCallInfo() {
2349
+ try {
2350
+ SSOChannel.stopHeartBeat();
2351
+ }
2352
+ catch (_) { /* ignore */ }
2353
+ this._callInfo = generateCallInfo();
2354
+ }
2355
+ // Return a stable per-engine terminal id, generating it on first use.
2356
+ _getOrCreateTerminalId() {
2357
+ if (!this._terminalId) {
2358
+ this._terminalId = generateTerminalId();
2359
+ }
2360
+ return this._terminalId;
2361
+ }
2362
+ }
2363
+ TUICallEngine.instance = null;
2364
+ // ---------------------- module-private helpers ---------------------
2365
+ function makeError(code, message) {
2366
+ const err = new Error(message);
2367
+ err.code = code;
2368
+ return err;
2369
+ }
2370
+
2371
+ export { AudioPlayBackDevice, CallEndReason, CallInvitationRespondedType, CallInvitationResultCodeType, CallMediaType, CallRole, CallScene, CallStatus, CameraPosition, IOSOfflinePushType, LOG_LEVEL, LayoutTemplate, TUICallEngine, TUICallEvent, TUIErrorCode, TUICallEngine as default };
2372
+ //# sourceMappingURL=index.esm.mjs.map