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.
- package/README.md +127 -0
- package/dist/index.cjs.js +2380 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +342 -0
- package/dist/index.esm.mjs +2372 -0
- package/dist/index.esm.mjs.map +1 -0
- package/dist/index.mp.js +2 -0
- package/dist/index.umd.js +5924 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/index.umd.min.js +3 -0
- package/dist/index.umd.min.js.map +1 -0
- package/package.json +77 -0
|
@@ -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
|