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