@softtynet/zca-js 2.3.4 → 2.3.5
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 +47 -11
- package/dist/apis/call/addUserGroupCall.d.ts +11 -0
- package/dist/apis/call/addUserGroupCall.js +48 -0
- package/dist/apis/call/answerAckVoiceCall.d.ts +5 -0
- package/dist/apis/call/answerAckVoiceCall.js +38 -0
- package/dist/apis/call/answerGroupCall.d.ts +10 -0
- package/dist/apis/call/answerGroupCall.js +45 -0
- package/dist/apis/call/answerVoiceCall.d.ts +11 -0
- package/dist/apis/call/answerVoiceCall.js +50 -0
- package/dist/apis/call/broadcastGroupCallState.d.ts +10 -0
- package/dist/apis/call/broadcastGroupCallState.js +45 -0
- package/dist/apis/call/cancelGroupCall.d.ts +10 -0
- package/dist/apis/call/cancelGroupCall.js +45 -0
- package/dist/apis/call/cancelVoiceCall.d.ts +1 -1
- package/dist/apis/call/cancelVoiceCall.js +5 -1
- package/dist/apis/call/createController.d.ts +7 -0
- package/dist/apis/call/createController.js +9 -0
- package/dist/apis/call/createRtcSession.d.ts +8 -0
- package/dist/apis/call/createRtcSession.js +10 -0
- package/dist/apis/call/endGroupCall.d.ts +10 -0
- package/dist/apis/call/endGroupCall.js +45 -0
- package/dist/apis/call/finishGroupCall.d.ts +12 -0
- package/dist/apis/call/finishGroupCall.js +47 -0
- package/dist/apis/call/getGroupCallState.d.ts +3 -0
- package/dist/apis/call/getGroupCallState.js +38 -0
- package/dist/apis/call/holdVoiceCall.d.ts +5 -0
- package/dist/apis/call/holdVoiceCall.js +34 -0
- package/dist/apis/call/joinGroupCall.d.ts +11 -0
- package/dist/apis/call/joinGroupCall.js +46 -0
- package/dist/apis/call/logEndVoiceCall.d.ts +8 -0
- package/dist/apis/call/logEndVoiceCall.js +46 -0
- package/dist/apis/call/notifyGroupCall.d.ts +9 -0
- package/dist/apis/call/notifyGroupCall.js +44 -0
- package/dist/apis/call/pingGroupCall.d.ts +10 -0
- package/dist/apis/call/pingGroupCall.js +45 -0
- package/dist/apis/call/requestAudioToVideoCall.d.ts +5 -0
- package/dist/apis/call/requestAudioToVideoCall.js +37 -0
- package/dist/apis/call/requestGroupCall.d.ts +9 -0
- package/dist/apis/call/requestGroupCall.js +46 -0
- package/dist/apis/call/requestShareScreenCall.d.ts +5 -0
- package/dist/apis/call/requestShareScreenCall.js +37 -0
- package/dist/apis/call/requestVoiceCall.d.ts +10 -0
- package/dist/apis/call/requestVoiceCall.js +47 -0
- package/dist/apis/call/ringRingGroupCall.d.ts +10 -0
- package/dist/apis/call/ringRingGroupCall.js +45 -0
- package/dist/apis/call/ringRingVoiceCall.d.ts +5 -0
- package/dist/apis/call/ringRingVoiceCall.js +38 -0
- package/dist/apis/call/sendRequestGroupCall.d.ts +11 -0
- package/dist/apis/call/sendRequestGroupCall.js +48 -0
- package/dist/apis/call/sendRequestVoiceCall.d.ts +11 -0
- package/dist/apis/call/sendRequestVoiceCall.js +51 -0
- package/dist/apis/call/sendVoiceCall.d.ts +8 -1
- package/dist/apis/call/sendVoiceCall.js +10 -8
- package/dist/apis/call/shareScreenGroupCall.d.ts +10 -0
- package/dist/apis/call/shareScreenGroupCall.js +45 -0
- package/dist/apis/call/unholdVoiceCall.d.ts +5 -0
- package/dist/apis/call/unholdVoiceCall.js +34 -0
- package/dist/apis/call/updateCallMediaState.d.ts +7 -0
- package/dist/apis/call/updateCallMediaState.js +61 -0
- package/dist/apis.d.ts +81 -0
- package/dist/apis.js +135 -0
- package/dist/call-engine/ZRTPPacketCodec.d.ts +262 -0
- package/dist/call-engine/ZRTPPacketCodec.js +506 -0
- package/dist/call-engine/ZaloCallController.d.ts +134 -0
- package/dist/call-engine/ZaloCallController.js +638 -0
- package/dist/call-engine/ZaloCallTransport.d.ts +83 -0
- package/dist/call-engine/ZaloCallTransport.js +233 -0
- package/dist/call-engine/ZaloRtcSession.d.ts +609 -0
- package/dist/call-engine/ZaloRtcSession.js +2931 -0
- package/dist/call-engine/ZaloSrtpSession.d.ts +80 -0
- package/dist/call-engine/ZaloSrtpSession.js +278 -0
- package/dist/call-engine/index.d.ts +18 -0
- package/dist/call-engine/index.js +24 -0
- package/dist/cjs/apis/call/addUserGroupCall.cjs +53 -0
- package/dist/cjs/apis/call/answerAckVoiceCall.cjs +43 -0
- package/dist/cjs/apis/call/answerGroupCall.cjs +50 -0
- package/dist/cjs/apis/call/answerVoiceCall.cjs +55 -0
- package/dist/cjs/apis/call/broadcastGroupCallState.cjs +50 -0
- package/dist/cjs/apis/call/cancelGroupCall.cjs +50 -0
- package/dist/cjs/apis/call/cancelVoiceCall.cjs +5 -1
- package/dist/cjs/apis/call/createController.cjs +14 -0
- package/dist/cjs/apis/call/createRtcSession.cjs +15 -0
- package/dist/cjs/apis/call/endGroupCall.cjs +50 -0
- package/dist/cjs/apis/call/finishGroupCall.cjs +52 -0
- package/dist/cjs/apis/call/getGroupCallState.cjs +43 -0
- package/dist/cjs/apis/call/holdVoiceCall.cjs +39 -0
- package/dist/cjs/apis/call/joinGroupCall.cjs +51 -0
- package/dist/cjs/apis/call/logEndVoiceCall.cjs +51 -0
- package/dist/cjs/apis/call/notifyGroupCall.cjs +49 -0
- package/dist/cjs/apis/call/pingGroupCall.cjs +50 -0
- package/dist/cjs/apis/call/requestAudioToVideoCall.cjs +42 -0
- package/dist/cjs/apis/call/requestGroupCall.cjs +51 -0
- package/dist/cjs/apis/call/requestShareScreenCall.cjs +42 -0
- package/dist/cjs/apis/call/requestVoiceCall.cjs +52 -0
- package/dist/cjs/apis/call/ringRingGroupCall.cjs +50 -0
- package/dist/cjs/apis/call/ringRingVoiceCall.cjs +43 -0
- package/dist/cjs/apis/call/sendRequestGroupCall.cjs +53 -0
- package/dist/cjs/apis/call/sendRequestVoiceCall.cjs +56 -0
- package/dist/cjs/apis/call/sendVoiceCall.cjs +10 -8
- package/dist/cjs/apis/call/shareScreenGroupCall.cjs +50 -0
- package/dist/cjs/apis/call/unholdVoiceCall.cjs +39 -0
- package/dist/cjs/apis/call/updateCallMediaState.cjs +66 -0
- package/dist/cjs/apis.cjs +135 -0
- package/dist/cjs/call-engine/ZRTPPacketCodec.cjs +525 -0
- package/dist/cjs/call-engine/ZaloCallController.cjs +643 -0
- package/dist/cjs/call-engine/ZaloCallTransport.cjs +239 -0
- package/dist/cjs/call-engine/ZaloRtcSession.cjs +2937 -0
- package/dist/cjs/call-engine/ZaloSrtpSession.cjs +287 -0
- package/dist/cjs/constants/protocol.cjs +1 -1
- package/dist/cjs/index.cjs +61 -11
- package/dist/cjs/models/CallEvent.cjs +95 -1
- package/dist/cjs/realtime/handlers/control.handler.cjs +17 -3
- package/dist/cjs/realtime/listener.cjs +19 -0
- package/dist/constants/protocol.d.ts +1 -1
- package/dist/constants/protocol.js +1 -1
- package/dist/index.d.ts +32 -4
- package/dist/index.js +2 -0
- package/dist/models/CallEvent.d.ts +15 -0
- package/dist/models/CallEvent.js +95 -1
- package/dist/realtime/handlers/control.handler.js +17 -3
- package/dist/realtime/listener.d.ts +5 -0
- package/dist/realtime/listener.js +19 -0
- package/package.json +2 -1
|
@@ -0,0 +1,2931 @@
|
|
|
1
|
+
import dgram from "node:dgram";
|
|
2
|
+
import EventEmitter from "node:events";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { CallType } from "../apis/call/sendVoiceCall.js";
|
|
5
|
+
import { ZaloApiError } from "../Errors/ZaloApiError.js";
|
|
6
|
+
import { CallEventType } from "../models/CallEvent.js";
|
|
7
|
+
import { DEFAULT_ZALO_AUDIO_CODEC, ZRTCMediaSubCommand, ZRTPControlCommand, ZRTPForwardSubCommand, ZRTPLegacyMediaType, ZRTCRole, buildEndCallRequest, buildInitCallRequest, buildPingRequest, buildPingResponse, buildRelayPingRequest, buildRequestForwardPacket, buildZrtcExtendData, parseZRTPPacket, uidToUint32, wrapLegacyMediaPacket, wrapZrtcMediaPacket, } from "./ZRTPPacketCodec.js";
|
|
8
|
+
import { ZaloSrtpSession, buildRtpPacket, generateSrtpMasterKeyBase64, parseRtpPacket, } from "./ZaloSrtpSession.js";
|
|
9
|
+
function startsWithAnnexBStartCode(buf) {
|
|
10
|
+
if (!buf || buf.length < 3)
|
|
11
|
+
return false;
|
|
12
|
+
let zeroCount = 0;
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < buf.length && buf[i] === 0x00) {
|
|
15
|
+
zeroCount++;
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
return zeroCount >= 2 && i < buf.length && buf[i] === 0x01;
|
|
19
|
+
}
|
|
20
|
+
function stripLeadingAnnexBStartCode(buf) {
|
|
21
|
+
if (!buf || buf.length === 0)
|
|
22
|
+
return buf;
|
|
23
|
+
let zeroCount = 0;
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < buf.length && buf[i] === 0x00) {
|
|
26
|
+
zeroCount++;
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
if (zeroCount >= 2 && i < buf.length && buf[i] === 0x01) {
|
|
30
|
+
return buf.subarray(i + 1);
|
|
31
|
+
}
|
|
32
|
+
return buf;
|
|
33
|
+
}
|
|
34
|
+
function splitAnnexBNals(buf) {
|
|
35
|
+
const nals = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
let nalStart = -1;
|
|
38
|
+
while (i < buf.length) {
|
|
39
|
+
if (buf[i] === 0x00 && i + 2 < buf.length && buf[i + 1] === 0x00) {
|
|
40
|
+
let j = i;
|
|
41
|
+
while (j < buf.length && buf[j] === 0x00) {
|
|
42
|
+
j++;
|
|
43
|
+
}
|
|
44
|
+
if (j < buf.length && buf[j] === 0x01 && j - i >= 2) {
|
|
45
|
+
if (nalStart !== -1 && i > nalStart) {
|
|
46
|
+
nals.push(buf.subarray(nalStart, i));
|
|
47
|
+
}
|
|
48
|
+
nalStart = j + 1;
|
|
49
|
+
i = j + 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
if (nalStart !== -1 && nalStart < buf.length) {
|
|
56
|
+
nals.push(buf.subarray(nalStart));
|
|
57
|
+
}
|
|
58
|
+
else if (nals.length === 0 && buf.length > 0) {
|
|
59
|
+
nals.push(buf);
|
|
60
|
+
}
|
|
61
|
+
return nals;
|
|
62
|
+
}
|
|
63
|
+
function tryParseAggregatedNals(buf, startOffset) {
|
|
64
|
+
if (!buf || startOffset >= buf.length)
|
|
65
|
+
return null;
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let offset = startOffset;
|
|
68
|
+
while (offset + 2 <= buf.length) {
|
|
69
|
+
const nalSize = buf.readUInt16BE(offset);
|
|
70
|
+
offset += 2;
|
|
71
|
+
if (nalSize <= 0 || offset + nalSize > buf.length) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
chunks.push(buf.subarray(offset, offset + nalSize));
|
|
75
|
+
offset += nalSize;
|
|
76
|
+
}
|
|
77
|
+
return offset === buf.length && chunks.length > 0 ? chunks : null;
|
|
78
|
+
}
|
|
79
|
+
function isZaloVideoPayloadType(pt) {
|
|
80
|
+
return pt === 97 || pt === 98 || pt === 121 || pt === 122 || pt === 125 || pt === 126;
|
|
81
|
+
}
|
|
82
|
+
function isZaloKeyframePayloadType(pt) {
|
|
83
|
+
return pt === 98 || pt === 121 || pt === 126;
|
|
84
|
+
}
|
|
85
|
+
function parseEndpoint(addr) {
|
|
86
|
+
if (!addr)
|
|
87
|
+
return { address: "", host: "127.0.0.1", port: 0 };
|
|
88
|
+
const idx = addr.lastIndexOf(":");
|
|
89
|
+
if (idx === -1)
|
|
90
|
+
return { address: addr, host: addr, port: 0 };
|
|
91
|
+
const host = addr.slice(0, idx);
|
|
92
|
+
const port = Number.parseInt(addr.slice(idx + 1), 10) || 0;
|
|
93
|
+
return { address: addr, host, port };
|
|
94
|
+
}
|
|
95
|
+
function safeJsonParseRecord(value) {
|
|
96
|
+
if (!value)
|
|
97
|
+
return {};
|
|
98
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
if (typeof value === "string") {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(value);
|
|
104
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Ignore invalid JSON string
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Giải mã chuẩn xác sự kiện cuộc gọi đến (`act: "request"` 1-1 hoặc `act: "group_request"` Nhóm)
|
|
116
|
+
* khớp 100% với `zrtc_call::ZMessageDeserializer::_parseActRequest` (`0x10001c824`),
|
|
117
|
+
* `_parseActRequestDetail` (`0x10001dfa0`), `_parseActRequestGroup` (`0x10001ffc4`) và
|
|
118
|
+
* `ZCallInfo::_prepareCallConfig` (`0x1000cd588`).
|
|
119
|
+
*/
|
|
120
|
+
export function parseIncomingCallEvent(callEvent, localUid) {
|
|
121
|
+
const rawData = "data" in callEvent && typeof callEvent.data === "object" && callEvent.data !== null
|
|
122
|
+
? callEvent.data
|
|
123
|
+
: callEvent;
|
|
124
|
+
const act = "act" in callEvent && typeof callEvent.act === "string"
|
|
125
|
+
? callEvent.act
|
|
126
|
+
: String(rawData.act ?? "");
|
|
127
|
+
const parsedParams = rawData.params !== undefined
|
|
128
|
+
? safeJsonParseRecord(rawData.params)
|
|
129
|
+
: rawData.data !== undefined
|
|
130
|
+
? safeJsonParseRecord(rawData.data)
|
|
131
|
+
: rawData;
|
|
132
|
+
const extendRaw = parsedParams.extendData !== undefined
|
|
133
|
+
? safeJsonParseRecord(parsedParams.extendData)
|
|
134
|
+
: rawData.extendData !== undefined
|
|
135
|
+
? safeJsonParseRecord(rawData.extendData)
|
|
136
|
+
: parsedParams;
|
|
137
|
+
const callSetting = safeJsonParseRecord(rawData.callSetting ?? parsedParams.callSetting);
|
|
138
|
+
const isGroupCall = act.startsWith("group_") ||
|
|
139
|
+
Boolean(rawData.groupId) ||
|
|
140
|
+
Boolean(rawData.groupNoiseId) ||
|
|
141
|
+
Boolean(rawData.hostCall) ||
|
|
142
|
+
Number(rawData.callType) === CallType.GroupAudio ||
|
|
143
|
+
Number(rawData.callType) === CallType.GroupVideo;
|
|
144
|
+
const callId = Number(rawData.callId ??
|
|
145
|
+
rawData.call_id ??
|
|
146
|
+
rawData.id ??
|
|
147
|
+
parsedParams.callId ??
|
|
148
|
+
parsedParams.call_id ??
|
|
149
|
+
parsedParams.id ??
|
|
150
|
+
extendRaw.callId ??
|
|
151
|
+
extendRaw.id ??
|
|
152
|
+
0);
|
|
153
|
+
const callerId = String(rawData.uidN ??
|
|
154
|
+
rawData.callerId ??
|
|
155
|
+
parsedParams.uidN ??
|
|
156
|
+
parsedParams.callerId ??
|
|
157
|
+
rawData.uidFrom ??
|
|
158
|
+
rawData.fromId ??
|
|
159
|
+
parsedParams.uidFrom ??
|
|
160
|
+
parsedParams.fromId ??
|
|
161
|
+
rawData.hostCall ??
|
|
162
|
+
"");
|
|
163
|
+
const hostCall = String(rawData.hostCall ??
|
|
164
|
+
parsedParams.hostCall ??
|
|
165
|
+
rawData.groupNoiseId ??
|
|
166
|
+
rawData.groupId ??
|
|
167
|
+
callerId);
|
|
168
|
+
const groupId = isGroupCall
|
|
169
|
+
? String(rawData.groupNoiseId ?? rawData.groupId ?? parsedParams.groupId ?? hostCall)
|
|
170
|
+
: "";
|
|
171
|
+
const groupName = String(rawData.groupName ?? parsedParams.groupName ?? "");
|
|
172
|
+
const groupAvatar = String(rawData.groupAvatar ?? parsedParams.groupAvatar ?? "");
|
|
173
|
+
const callerName = String(parsedParams.Dname ??
|
|
174
|
+
rawData.Dname ??
|
|
175
|
+
parsedParams.callerName ??
|
|
176
|
+
rawData.callerName ??
|
|
177
|
+
parsedParams.name ??
|
|
178
|
+
rawData.name ??
|
|
179
|
+
groupName ??
|
|
180
|
+
callerId);
|
|
181
|
+
const callerAvatar = String(parsedParams.avatar ??
|
|
182
|
+
rawData.avatar ??
|
|
183
|
+
parsedParams.callerAvatar ??
|
|
184
|
+
rawData.callerAvatar ??
|
|
185
|
+
groupAvatar ??
|
|
186
|
+
"");
|
|
187
|
+
// Trong ZCallInfo::_prepareCallConfig (0x1000cd5c4):
|
|
188
|
+
// - Khi nhận cuộc gọi (Callee): localUid32 = uidTo (hoặc receiverId), partnerUid32 = uidFrom (hoặc fromId)
|
|
189
|
+
const fromUid32 = Number(rawData.uidTo ??
|
|
190
|
+
rawData.receiverId ??
|
|
191
|
+
parsedParams.uidTo ??
|
|
192
|
+
parsedParams.receiverId ??
|
|
193
|
+
0) || uidToUint32(localUid ?? "");
|
|
194
|
+
const toUid32 = Number(rawData.uidFrom ?? rawData.fromId ?? parsedParams.uidFrom ?? parsedParams.fromId ?? 0) || uidToUint32(callerId);
|
|
195
|
+
const serversList = Array.isArray(parsedParams.servers)
|
|
196
|
+
? parsedParams.servers
|
|
197
|
+
: Array.isArray(rawData.servers)
|
|
198
|
+
? rawData.servers
|
|
199
|
+
: [];
|
|
200
|
+
const firstServersEntry = serversList[0];
|
|
201
|
+
const serverAddrList = Array.isArray(extendRaw.serverAddr)
|
|
202
|
+
? extendRaw.serverAddr
|
|
203
|
+
: Array.isArray(parsedParams.serverAddr)
|
|
204
|
+
? parsedParams.serverAddr
|
|
205
|
+
: [];
|
|
206
|
+
const firstServerAddr = serverAddrList[0];
|
|
207
|
+
const rtpArray = Array.isArray(extendRaw.rtp)
|
|
208
|
+
? extendRaw.rtp
|
|
209
|
+
: Array.isArray(parsedParams.rtp)
|
|
210
|
+
? parsedParams.rtp
|
|
211
|
+
: [];
|
|
212
|
+
const rtcpArray = Array.isArray(extendRaw.rtcp)
|
|
213
|
+
? extendRaw.rtcp
|
|
214
|
+
: Array.isArray(parsedParams.rtcp)
|
|
215
|
+
? parsedParams.rtcp
|
|
216
|
+
: [];
|
|
217
|
+
const rtpAddr = String(rawData.rtpAddress ??
|
|
218
|
+
rawData.rtpIP ??
|
|
219
|
+
rawData.rtpServer ??
|
|
220
|
+
parsedParams.rtpAddress ??
|
|
221
|
+
parsedParams.rtpIP ??
|
|
222
|
+
parsedParams.rtpServer ??
|
|
223
|
+
firstServersEntry?.rtpaddr ??
|
|
224
|
+
firstServersEntry?.rtp ??
|
|
225
|
+
firstServerAddr?.rtp ??
|
|
226
|
+
firstServerAddr?.rtpaddr ??
|
|
227
|
+
(rtpArray[0]?.ip && rtpArray[0]?.port ? `${rtpArray[0].ip}:${rtpArray[0].port}` : ""));
|
|
228
|
+
const rtcpFromArray = rtcpArray[0]?.ip && rtcpArray[0]?.port ? `${rtcpArray[0].ip}:${rtcpArray[0].port}` : "";
|
|
229
|
+
const rtcpAddr = String(rawData.rtcpAddress ??
|
|
230
|
+
rawData.rtcpIP ??
|
|
231
|
+
rawData.rtcpServer ??
|
|
232
|
+
parsedParams.rtcpAddress ??
|
|
233
|
+
parsedParams.rtcpIP ??
|
|
234
|
+
parsedParams.rtcpServer ??
|
|
235
|
+
firstServersEntry?.rtcpaddr ??
|
|
236
|
+
firstServersEntry?.rtcp ??
|
|
237
|
+
firstServerAddr?.rtcp ??
|
|
238
|
+
firstServerAddr?.rtcpaddr ??
|
|
239
|
+
(rtcpFromArray || rtpAddr));
|
|
240
|
+
const rtpEp = parseEndpoint(rtpAddr);
|
|
241
|
+
const rtcpEp = parseEndpoint(rtcpAddr || rtpAddr);
|
|
242
|
+
const rawCallType = Number(extendRaw.callType ?? parsedParams.callType ?? rawData.callType ?? 0);
|
|
243
|
+
const isVideo = rawCallType === 1 ||
|
|
244
|
+
rawCallType === CallType.Video ||
|
|
245
|
+
rawCallType === CallType.GroupVideo ||
|
|
246
|
+
Boolean(extendRaw.video);
|
|
247
|
+
const callType = isGroupCall
|
|
248
|
+
? isVideo
|
|
249
|
+
? CallType.GroupVideo
|
|
250
|
+
: CallType.GroupAudio
|
|
251
|
+
: isVideo
|
|
252
|
+
? CallType.Video
|
|
253
|
+
: CallType.Audio;
|
|
254
|
+
const sessId = String(rawData.session ??
|
|
255
|
+
rawData.sessId ??
|
|
256
|
+
callSetting.session ??
|
|
257
|
+
parsedParams.sessId ??
|
|
258
|
+
parsedParams.session ??
|
|
259
|
+
extendRaw.sessId ??
|
|
260
|
+
(isGroupCall
|
|
261
|
+
? `${hostCall || callerId}_${callId}_501`
|
|
262
|
+
: `${callerId}_${callId}_401`));
|
|
263
|
+
const rawPartners = Array.isArray(rawData.partners)
|
|
264
|
+
? rawData.partners
|
|
265
|
+
: Array.isArray(rawData.partnerInfo)
|
|
266
|
+
? rawData.partnerInfo
|
|
267
|
+
: Array.isArray(parsedParams.partners)
|
|
268
|
+
? parsedParams.partners
|
|
269
|
+
: [];
|
|
270
|
+
const partners = rawPartners
|
|
271
|
+
.map((p) => {
|
|
272
|
+
if (typeof p === "string" || typeof p === "number")
|
|
273
|
+
return String(p);
|
|
274
|
+
if (p && typeof p === "object") {
|
|
275
|
+
const obj = p;
|
|
276
|
+
return String(obj.id ?? obj.uid ?? obj.userId ?? "");
|
|
277
|
+
}
|
|
278
|
+
return "";
|
|
279
|
+
})
|
|
280
|
+
.filter(Boolean);
|
|
281
|
+
const codec = typeof parsedParams.codec === "string" && parsedParams.codec !== "[]"
|
|
282
|
+
? parsedParams.codec
|
|
283
|
+
: typeof rawData.codec === "string" && rawData.codec !== "[]"
|
|
284
|
+
? rawData.codec
|
|
285
|
+
: DEFAULT_ZALO_AUDIO_CODEC;
|
|
286
|
+
return {
|
|
287
|
+
callId,
|
|
288
|
+
callerId,
|
|
289
|
+
callerName,
|
|
290
|
+
callerAvatar,
|
|
291
|
+
fromUid32,
|
|
292
|
+
toUid32,
|
|
293
|
+
sessId,
|
|
294
|
+
callType,
|
|
295
|
+
isVideo,
|
|
296
|
+
isGroupCall,
|
|
297
|
+
groupId,
|
|
298
|
+
groupName,
|
|
299
|
+
groupAvatar,
|
|
300
|
+
hostCall,
|
|
301
|
+
partners,
|
|
302
|
+
rtpAddress: rtpEp.address,
|
|
303
|
+
rtpHost: rtpEp.host,
|
|
304
|
+
rtpPort: rtpEp.port,
|
|
305
|
+
rtcpAddress: rtcpEp.address,
|
|
306
|
+
rtcpHost: rtcpEp.host,
|
|
307
|
+
rtcpPort: rtcpEp.port,
|
|
308
|
+
codec,
|
|
309
|
+
srtpKey: typeof extendRaw.srtpKey === "string" ? extendRaw.srtpKey : undefined,
|
|
310
|
+
srtpMode: extendRaw.srtpMode !== undefined ? Number(extendRaw.srtpMode) : undefined,
|
|
311
|
+
zrtpKey: typeof parsedParams.zrtpKey === "string"
|
|
312
|
+
? parsedParams.zrtpKey
|
|
313
|
+
: typeof extendRaw.zrtpKey === "string"
|
|
314
|
+
? extendRaw.zrtpKey
|
|
315
|
+
: undefined,
|
|
316
|
+
extendData: extendRaw,
|
|
317
|
+
rawEvent: callEvent,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Khung Opus silence / DTX 20ms chuẩn (TOC `0xf8` = Silk-only WB 20ms mono + `0xff 0xfe`)
|
|
322
|
+
*/
|
|
323
|
+
const OPUS_SILENCE_20MS_FRAME = Buffer.from([0xf8, 0xff, 0xfe]);
|
|
324
|
+
/**
|
|
325
|
+
* Khung H.264 Baseline Profile Level 3.0 16x16 hợp lệ (SPS `0x67` + PPS `0x68` + IDR Slice `0x65`)
|
|
326
|
+
* giúp bộ giải mã phần cứng đối phương render đủ 15fps ngay cả khi chưa bật camera ngoài
|
|
327
|
+
* (`CallController::_checkEventLossVideoAndReason` @ `0x100182734`).
|
|
328
|
+
*/
|
|
329
|
+
const H264_KEEPALIVE_ANNEXB_FRAME = Buffer.from("000000016742c01ed90f8888840000030004000003003c8f162e480000000168cb83cb20000000016588840033ff", "hex");
|
|
330
|
+
/**
|
|
331
|
+
* Bộ máy xử lý phiên gọi Thoại & Video thuần TypeScript / Node.js (`node:dgram` UDP + `ZRTPPacket` + `SRTP`).
|
|
332
|
+
*
|
|
333
|
+
* Hoạt động độc lập 100% trên Worker, Linux Server, Docker, hoặc Desktop mà **không cần cài đặt Zalo PC**:
|
|
334
|
+
* - Thực hiện trọn vẹn luồng Signaling 401 -> Bắt tay UDP `0x01 0x7e` (`cmd=11/12`) -> Nhận `routingToken` -> Signaling 416 / 402 -> Duy trì UDP Ping (`cmd=2/5/32`) & RTCP SR/RR.
|
|
335
|
+
* - Hỗ trợ chế độ **Direct Mobile Media Handoff** (`signalingOnly: true` / `handoffUdpToExternal()`): Worker chỉ tạo/nhận cuộc gọi qua HTTPS/WebSocket rồi trả gói `mobileDirectHandoff` cho Mobile App tự kết nối UDP trực tiếp tới máy chủ Relay của Zalo mà không cần đi qua trung gian.
|
|
336
|
+
* - Hỗ trợ đầy đủ cả **Chiều gọi đi (Outgoing)** lẫn **Chiều gọi đến (Incoming 1-1 & Nhóm)**: giải mã chuẩn `act: "request"` / `"group_request"`, báo đổ chuông (`ringIncomingCall`), bắt máy (`acceptIncomingCall` / `acceptIncomingGroupCall`), và từ chối (`rejectIncomingCall`).
|
|
337
|
+
*/
|
|
338
|
+
export class ZaloRtcSession extends EventEmitter {
|
|
339
|
+
api;
|
|
340
|
+
state = "idle";
|
|
341
|
+
udpSocket = null;
|
|
342
|
+
pingTimer = null;
|
|
343
|
+
groupPingTimer = null;
|
|
344
|
+
audioKeepaliveTimer = null;
|
|
345
|
+
videoKeepaliveTimer = null;
|
|
346
|
+
rtcpTimer = null;
|
|
347
|
+
ringingTimeoutTimer = null;
|
|
348
|
+
startedAtMs = 0;
|
|
349
|
+
activeStartedAtMs = 0;
|
|
350
|
+
callId = 0;
|
|
351
|
+
sessId = "";
|
|
352
|
+
isCaller = true;
|
|
353
|
+
partnerId = "";
|
|
354
|
+
groupId = "";
|
|
355
|
+
groupPartners = [];
|
|
356
|
+
fromUid32 = 0;
|
|
357
|
+
toUid32 = 0;
|
|
358
|
+
callType = CallType.Audio;
|
|
359
|
+
isCameraOn = false;
|
|
360
|
+
isMicMuted = false;
|
|
361
|
+
isHold = false;
|
|
362
|
+
isScreenSharing = false;
|
|
363
|
+
rtpEndpoint = { address: "", host: "127.0.0.1", port: 0 };
|
|
364
|
+
rtcpEndpoint = { address: "", host: "127.0.0.1", port: 0 };
|
|
365
|
+
routingToken = 0;
|
|
366
|
+
udpHandshakeVerified = false;
|
|
367
|
+
hasReceivedPartnerMedia = false;
|
|
368
|
+
hasSentAnswerAck = false;
|
|
369
|
+
localUdpPort = 0;
|
|
370
|
+
zrtpKey;
|
|
371
|
+
codec = DEFAULT_ZALO_AUDIO_CODEC;
|
|
372
|
+
extendData = {};
|
|
373
|
+
serverInfo;
|
|
374
|
+
controlSeq = 1;
|
|
375
|
+
forwardPingSeq = 0;
|
|
376
|
+
manualControlSeq = 1;
|
|
377
|
+
audioSeq = Math.floor(Math.random() * 30000);
|
|
378
|
+
audioTimestamp = Math.floor(Math.random() * 100000);
|
|
379
|
+
audioSsrc = (Math.floor(Math.random() * 0x7fffffff) + 1) >>> 0;
|
|
380
|
+
audioPacketCount = 0;
|
|
381
|
+
audioOctetCount = 0;
|
|
382
|
+
lastExternalAudioSentAtMs = 0;
|
|
383
|
+
videoSeq = Math.floor(Math.random() * 30000);
|
|
384
|
+
videoTimestamp = Math.floor(Math.random() * 100000);
|
|
385
|
+
videoSsrc = (Math.floor(Math.random() * 0x7fffffff) + 1) >>> 0;
|
|
386
|
+
videoPacketCount = 0;
|
|
387
|
+
videoOctetCount = 0;
|
|
388
|
+
lastExternalVideoSentAtMs = 0;
|
|
389
|
+
remoteAudioSsrc = 0;
|
|
390
|
+
remoteAudioMaxSeq = 0;
|
|
391
|
+
remoteAudioSeqCycles = 0;
|
|
392
|
+
lastRemoteAudioSrMiddleNtp = 0;
|
|
393
|
+
lastRemoteAudioSrReceivedAtMs = 0;
|
|
394
|
+
remoteVideoSsrc = 0;
|
|
395
|
+
remoteVideoMaxSeq = 0;
|
|
396
|
+
remoteVideoSeqCycles = 0;
|
|
397
|
+
lastRemoteVideoSrMiddleNtp = 0;
|
|
398
|
+
lastRemoteVideoSrReceivedAtMs = 0;
|
|
399
|
+
localSpsNal = null;
|
|
400
|
+
localPpsNal = null;
|
|
401
|
+
remoteSpsNal = null;
|
|
402
|
+
remotePpsNal = null;
|
|
403
|
+
fuaFragments = [];
|
|
404
|
+
currentAccessUnitNals = [];
|
|
405
|
+
currentAccessUnitTs = -1;
|
|
406
|
+
currentAccessUnitHasIdr = false;
|
|
407
|
+
srtpSession = null;
|
|
408
|
+
options;
|
|
409
|
+
boundCallEventListener;
|
|
410
|
+
constructor(api, options = {}) {
|
|
411
|
+
super();
|
|
412
|
+
this.api = api;
|
|
413
|
+
this.options = {
|
|
414
|
+
packetMode: options.packetMode ?? 1,
|
|
415
|
+
autoKeepaliveMedia: options.autoKeepaliveMedia ?? true,
|
|
416
|
+
signalingOnly: options.signalingOnly ?? false,
|
|
417
|
+
enableSrtp: options.enableSrtp ?? false,
|
|
418
|
+
srtpSuite: options.srtpSuite ?? "AES_CM_128_HMAC_SHA1_80",
|
|
419
|
+
srtpMasterKeyBase64: options.srtpMasterKeyBase64,
|
|
420
|
+
udpHandshakeTimeoutMs: options.udpHandshakeTimeoutMs ?? 1500,
|
|
421
|
+
pingIntervalMs: options.pingIntervalMs ?? 1000,
|
|
422
|
+
autoAttachListener: options.autoAttachListener ?? true,
|
|
423
|
+
localUdpPort: options.localUdpPort ?? 0,
|
|
424
|
+
ringingTimeoutMs: options.ringingTimeoutMs ?? 60000,
|
|
425
|
+
};
|
|
426
|
+
if (this.options.enableSrtp) {
|
|
427
|
+
const keyB64 = this.options.srtpMasterKeyBase64 ?? generateSrtpMasterKeyBase64();
|
|
428
|
+
this.srtpSession = new ZaloSrtpSession(keyB64, this.options.srtpSuite);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
getState() {
|
|
432
|
+
return this.state;
|
|
433
|
+
}
|
|
434
|
+
getSrtpSession() {
|
|
435
|
+
return this.srtpSession;
|
|
436
|
+
}
|
|
437
|
+
getCallDurationSeconds() {
|
|
438
|
+
if (!this.activeStartedAtMs)
|
|
439
|
+
return 0;
|
|
440
|
+
return Math.max(0, Math.floor((Date.now() - this.activeStartedAtMs) / 1000));
|
|
441
|
+
}
|
|
442
|
+
getMediaState() {
|
|
443
|
+
return {
|
|
444
|
+
callId: this.callId,
|
|
445
|
+
callType: this.callType,
|
|
446
|
+
isCameraOn: this.isCameraOn,
|
|
447
|
+
isMicMuted: this.isMicMuted,
|
|
448
|
+
isHold: this.isHold,
|
|
449
|
+
isScreenSharing: this.isScreenSharing,
|
|
450
|
+
durationSeconds: this.getCallDurationSeconds(),
|
|
451
|
+
groupId: this.groupId,
|
|
452
|
+
groupPartners: this.groupPartners,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
getLocalAudioSsrc() {
|
|
456
|
+
return (this.fromUid32 || this.audioSsrc) >>> 0;
|
|
457
|
+
}
|
|
458
|
+
getLocalVideoSsrc() {
|
|
459
|
+
return (this.fromUid32 || this.videoSsrc) >>> 0;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Tạo bộ thông số + các gói tin nhị phân `ZRTPPacket` đã đóng gói sẵn dạng hex
|
|
463
|
+
* để ứng dụng Mobile có thể mở socket UDP kết nối **trực tiếp** tới máy chủ Relay của Zalo (`rtpHost:rtpPort`)
|
|
464
|
+
* mà không cần đẩy luồng dữ liệu âm thanh/hình ảnh qua Worker trung gian.
|
|
465
|
+
*/
|
|
466
|
+
getMobileDirectHandoffBundle(routingTokenOverride) {
|
|
467
|
+
const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
|
|
468
|
+
const token = ((routingTokenOverride ?? this.routingToken) || this.callId) >>> 0;
|
|
469
|
+
const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
|
|
470
|
+
const toUid = this.toUid32 || uidToUint32(this.partnerId);
|
|
471
|
+
const isVideo = this.callType === CallType.Video ||
|
|
472
|
+
this.callType === CallType.GroupVideo ||
|
|
473
|
+
Number(this.extendData?.callType) === 1;
|
|
474
|
+
const initCallPkt = buildInitCallRequest({
|
|
475
|
+
isCaller: this.isCaller,
|
|
476
|
+
seqNum: 0,
|
|
477
|
+
headerCallId: this.callId,
|
|
478
|
+
callId: this.callId,
|
|
479
|
+
fromUid,
|
|
480
|
+
toUid,
|
|
481
|
+
flags: 1,
|
|
482
|
+
subFlags: toUid,
|
|
483
|
+
sessId: this.sessId,
|
|
484
|
+
});
|
|
485
|
+
const relayPingPkt = buildRelayPingRequest({
|
|
486
|
+
seqNum: 0,
|
|
487
|
+
callId: 0,
|
|
488
|
+
fromUid,
|
|
489
|
+
routingToken: token,
|
|
490
|
+
sessId: this.sessId,
|
|
491
|
+
});
|
|
492
|
+
const pingPkt = buildPingRequest({
|
|
493
|
+
seqNum: 1,
|
|
494
|
+
callId: this.callId,
|
|
495
|
+
fromUid,
|
|
496
|
+
toUid,
|
|
497
|
+
elapsedMs: 1000,
|
|
498
|
+
});
|
|
499
|
+
const manualCtrlPayload = Buffer.alloc(20);
|
|
500
|
+
manualCtrlPayload.writeInt32LE(isVideo ? 15 : 0, 0);
|
|
501
|
+
manualCtrlPayload.writeInt32LE(0, 4);
|
|
502
|
+
manualCtrlPayload.writeInt32LE(0, 8);
|
|
503
|
+
manualCtrlPayload.writeInt32LE(50, 12);
|
|
504
|
+
manualCtrlPayload.writeInt32LE(isVideo ? 30 : 0, 16);
|
|
505
|
+
const manualCtrlPkt = buildRequestForwardPacket({
|
|
506
|
+
seqNum: 0,
|
|
507
|
+
seqId: 1,
|
|
508
|
+
fromUid,
|
|
509
|
+
routingToken: token,
|
|
510
|
+
subCmd: ZRTPForwardSubCommand.VideoControl,
|
|
511
|
+
payload: manualCtrlPayload,
|
|
512
|
+
});
|
|
513
|
+
const cameraOnPkt = buildRequestForwardPacket({
|
|
514
|
+
seqNum: 0,
|
|
515
|
+
seqId: 1,
|
|
516
|
+
fromUid,
|
|
517
|
+
routingToken: token,
|
|
518
|
+
subCmd: ZRTPForwardSubCommand.OnCamera,
|
|
519
|
+
payload: "",
|
|
520
|
+
});
|
|
521
|
+
const cameraOffPkt = buildRequestForwardPacket({
|
|
522
|
+
seqNum: 0,
|
|
523
|
+
seqId: 1,
|
|
524
|
+
fromUid,
|
|
525
|
+
routingToken: token,
|
|
526
|
+
subCmd: ZRTPForwardSubCommand.OffCamera,
|
|
527
|
+
payload: "",
|
|
528
|
+
});
|
|
529
|
+
const endCallPkt = buildEndCallRequest({
|
|
530
|
+
seqNum: 0,
|
|
531
|
+
callId: this.callId,
|
|
532
|
+
fromUid,
|
|
533
|
+
toUid,
|
|
534
|
+
routingToken: token,
|
|
535
|
+
flags: 0,
|
|
536
|
+
});
|
|
537
|
+
const audioRtpPrefix = (this.options.packetMode === 1
|
|
538
|
+
? wrapLegacyMediaPacket(ZRTPLegacyMediaType.AudioRtp, token, Buffer.alloc(0))
|
|
539
|
+
: wrapZrtcMediaPacket(ZRTCMediaSubCommand.AudioRtp, token, Buffer.alloc(0), role)).toString("hex");
|
|
540
|
+
const audioRtcpPrefix = (this.options.packetMode === 1
|
|
541
|
+
? wrapLegacyMediaPacket(ZRTPLegacyMediaType.AudioRtcp, token, Buffer.alloc(0))
|
|
542
|
+
: wrapZrtcMediaPacket(ZRTCMediaSubCommand.AudioRtcp, token, Buffer.alloc(0), role)).toString("hex");
|
|
543
|
+
const videoRtpPrefix = (this.options.packetMode === 1
|
|
544
|
+
? wrapLegacyMediaPacket(ZRTPLegacyMediaType.VideoRtp, token, Buffer.alloc(0))
|
|
545
|
+
: wrapZrtcMediaPacket(ZRTCMediaSubCommand.VideoRtp, token, Buffer.alloc(0), role)).toString("hex");
|
|
546
|
+
const videoRtcpPrefix = (this.options.packetMode === 1
|
|
547
|
+
? wrapLegacyMediaPacket(ZRTPLegacyMediaType.VideoRtcp, token, Buffer.alloc(0))
|
|
548
|
+
: wrapZrtcMediaPacket(ZRTCMediaSubCommand.VideoRtcp, token, Buffer.alloc(0), role)).toString("hex");
|
|
549
|
+
return {
|
|
550
|
+
callId: this.callId,
|
|
551
|
+
sessId: this.sessId,
|
|
552
|
+
isCaller: this.isCaller,
|
|
553
|
+
partnerId: this.partnerId,
|
|
554
|
+
fromUid32: fromUid,
|
|
555
|
+
toUid32: toUid,
|
|
556
|
+
callType: this.callType,
|
|
557
|
+
rtpHost: this.rtpEndpoint.host,
|
|
558
|
+
rtpPort: this.rtpEndpoint.port,
|
|
559
|
+
rtcpHost: this.rtcpEndpoint.host,
|
|
560
|
+
rtcpPort: this.rtcpEndpoint.port,
|
|
561
|
+
packetMode: this.options.packetMode,
|
|
562
|
+
routingToken: token,
|
|
563
|
+
codec: this.codec,
|
|
564
|
+
srtpMasterKeyBase64: this.srtpSession?.masterKeyBase64,
|
|
565
|
+
zrtpKey: this.zrtpKey,
|
|
566
|
+
initCallPacketHex: initCallPkt.toString("hex"),
|
|
567
|
+
relayPingPacketHex: relayPingPkt.toString("hex"),
|
|
568
|
+
pingPacketHex: pingPkt.toString("hex"),
|
|
569
|
+
manualControlPacketHex: manualCtrlPkt.toString("hex"),
|
|
570
|
+
cameraOnPacketHex: cameraOnPkt.toString("hex"),
|
|
571
|
+
cameraOffPacketHex: cameraOffPkt.toString("hex"),
|
|
572
|
+
endCallPacketHex: endCallPkt.toString("hex"),
|
|
573
|
+
audioRtpPrefixHex: audioRtpPrefix,
|
|
574
|
+
audioRtcpPrefixHex: audioRtcpPrefix,
|
|
575
|
+
videoRtpPrefixHex: videoRtpPrefix,
|
|
576
|
+
videoRtcpPrefixHex: videoRtcpPrefix,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Đặt lại toàn bộ trạng thái định tuyến UDP, bộ đếm RTP/RTCP, bộ đệm giải mã H.264 FU-A/SPS/PPS
|
|
581
|
+
* và khóa SRTP trước khi bắt đầu hoặc chấp nhận một cuộc gọi mới trên cùng một instance `ZaloRtcSession`.
|
|
582
|
+
*/
|
|
583
|
+
resetSessionStateForNewCall(preserveRingingCallId = 0) {
|
|
584
|
+
const isContinuingSameRingingCall = preserveRingingCallId > 0 &&
|
|
585
|
+
this.callId === preserveRingingCallId &&
|
|
586
|
+
this.state === "ringing";
|
|
587
|
+
this.cleanupResources();
|
|
588
|
+
this.routingToken = 0;
|
|
589
|
+
this.udpHandshakeVerified = false;
|
|
590
|
+
this.hasReceivedPartnerMedia = false;
|
|
591
|
+
this.hasSentAnswerAck = false;
|
|
592
|
+
this.localUdpPort = 0;
|
|
593
|
+
this.startedAtMs = 0;
|
|
594
|
+
this.activeStartedAtMs = 0;
|
|
595
|
+
this.controlSeq = 1;
|
|
596
|
+
this.forwardPingSeq = 0;
|
|
597
|
+
this.manualControlSeq = 1;
|
|
598
|
+
this.audioSeq = Math.floor(Math.random() * 30000);
|
|
599
|
+
this.audioTimestamp = Math.floor(Math.random() * 100000);
|
|
600
|
+
this.audioPacketCount = 0;
|
|
601
|
+
this.audioOctetCount = 0;
|
|
602
|
+
this.lastExternalAudioSentAtMs = 0;
|
|
603
|
+
this.videoSeq = Math.floor(Math.random() * 30000);
|
|
604
|
+
this.videoTimestamp = Math.floor(Math.random() * 100000);
|
|
605
|
+
this.videoPacketCount = 0;
|
|
606
|
+
this.videoOctetCount = 0;
|
|
607
|
+
this.lastExternalVideoSentAtMs = 0;
|
|
608
|
+
this.remoteAudioSsrc = 0;
|
|
609
|
+
this.remoteAudioMaxSeq = 0;
|
|
610
|
+
this.remoteAudioSeqCycles = 0;
|
|
611
|
+
this.lastRemoteAudioSrMiddleNtp = 0;
|
|
612
|
+
this.lastRemoteAudioSrReceivedAtMs = 0;
|
|
613
|
+
this.remoteVideoSsrc = 0;
|
|
614
|
+
this.remoteVideoMaxSeq = 0;
|
|
615
|
+
this.remoteVideoSeqCycles = 0;
|
|
616
|
+
this.lastRemoteVideoSrMiddleNtp = 0;
|
|
617
|
+
this.lastRemoteVideoSrReceivedAtMs = 0;
|
|
618
|
+
this.localSpsNal = null;
|
|
619
|
+
this.localPpsNal = null;
|
|
620
|
+
this.remoteSpsNal = null;
|
|
621
|
+
this.remotePpsNal = null;
|
|
622
|
+
this.clearIncomingVideoAssemblyBuffers();
|
|
623
|
+
if (this.options.enableSrtp) {
|
|
624
|
+
const keyB64 = this.options.srtpMasterKeyBase64 ?? generateSrtpMasterKeyBase64();
|
|
625
|
+
this.srtpSession = new ZaloSrtpSession(keyB64, this.options.srtpSuite);
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
this.srtpSession = null;
|
|
629
|
+
}
|
|
630
|
+
if (!isContinuingSameRingingCall) {
|
|
631
|
+
this.state = "idle";
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
clearIncomingVideoAssemblyBuffers() {
|
|
635
|
+
this.fuaFragments = [];
|
|
636
|
+
this.currentAccessUnitNals = [];
|
|
637
|
+
this.currentAccessUnitTs = -1;
|
|
638
|
+
this.currentAccessUnitHasIdr = false;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Tái kích hoạt (Re-activate / Re-bind) kết nối UDP và luồng Media/RTCP cho phiên gọi hiện tại:
|
|
642
|
+
* - Dùng khi chuyển vùng mạng (Wi-Fi <-> 4G/5G), khi socket UDP bị gián đoạn, hoặc khi muốn thu hồi lại
|
|
643
|
+
* quyền xử lý UDP từ Mobile về lại Worker/Desktop sau khi đã gọi `signalingOnly` / `handoffUdpToExternal()`.
|
|
644
|
+
* - Tự động mở cổng UDP mới, gửi lại gói bắt tay `0x01 0x7e` (`cmd=11` Caller hoặc `cmd=12` Callee),
|
|
645
|
+
* khởi động lại vòng lặp UDP Ping + Media Keepalive + RTCP SR/RR, đồng bộ lại trạng thái Mic/Camera
|
|
646
|
+
* và bắn `RTCP PLI` để khôi phục hình ảnh H.264 ngay lập tức.
|
|
647
|
+
*/
|
|
648
|
+
async reconnectUdp(options = {}) {
|
|
649
|
+
if (!this.callId || this.rtpEndpoint.port <= 0) {
|
|
650
|
+
throw new ZaloApiError("Cannot reconnect UDP: no active call endpoint");
|
|
651
|
+
}
|
|
652
|
+
if (this.pingTimer) {
|
|
653
|
+
clearInterval(this.pingTimer);
|
|
654
|
+
this.pingTimer = null;
|
|
655
|
+
}
|
|
656
|
+
this.stopMediaKeepaliveLoop();
|
|
657
|
+
if (this.udpSocket) {
|
|
658
|
+
try {
|
|
659
|
+
this.udpSocket.removeAllListeners();
|
|
660
|
+
this.udpSocket.close();
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
// Ignore
|
|
664
|
+
}
|
|
665
|
+
this.udpSocket = null;
|
|
666
|
+
}
|
|
667
|
+
this.localUdpPort = 0;
|
|
668
|
+
this.clearIncomingVideoAssemblyBuffers();
|
|
669
|
+
await this.ensureUdpSocket();
|
|
670
|
+
if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
|
|
671
|
+
await this.performUdpInitHandshake(this.isCaller);
|
|
672
|
+
}
|
|
673
|
+
else if (!this.routingToken) {
|
|
674
|
+
this.routingToken = this.callId >>> 0;
|
|
675
|
+
}
|
|
676
|
+
this.startUdpPingLoop();
|
|
677
|
+
this.startMediaKeepaliveLoop();
|
|
678
|
+
void this.sendMicState(!this.isMicMuted).catch(() => { });
|
|
679
|
+
if (this.callType === CallType.Video ||
|
|
680
|
+
this.callType === CallType.GroupVideo ||
|
|
681
|
+
Number(this.extendData?.callType) === 1) {
|
|
682
|
+
void this.sendCameraState(this.isCameraOn).catch(() => { });
|
|
683
|
+
void this.sendRtcpPli().catch(() => { });
|
|
684
|
+
}
|
|
685
|
+
const params = this.getConnectionParams();
|
|
686
|
+
this.emit("connection_params", params);
|
|
687
|
+
return params;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Ngắt toàn bộ socket UDP và các vòng lặp Ping/Media cục bộ trên Worker
|
|
691
|
+
* (nhưng vẫn giữ nguyên kết nối Signaling WebSocket để lắng nghe `answer`, `end`, `cancel`, `audio_to_video`),
|
|
692
|
+
* nhằm bàn giao hoàn toàn kênh UDP cho ứng dụng Mobile kết nối trực tiếp tới máy chủ Relay Zalo
|
|
693
|
+
* mà không bị xung đột NAT (NAT flip-flopping).
|
|
694
|
+
*/
|
|
695
|
+
handoffUdpToExternal(routingTokenOverride) {
|
|
696
|
+
if (routingTokenOverride !== undefined && routingTokenOverride > 0) {
|
|
697
|
+
this.routingToken = routingTokenOverride >>> 0;
|
|
698
|
+
}
|
|
699
|
+
if (this.pingTimer) {
|
|
700
|
+
clearInterval(this.pingTimer);
|
|
701
|
+
this.pingTimer = null;
|
|
702
|
+
}
|
|
703
|
+
this.stopMediaKeepaliveLoop();
|
|
704
|
+
if (this.udpSocket) {
|
|
705
|
+
try {
|
|
706
|
+
this.udpSocket.removeAllListeners();
|
|
707
|
+
this.udpSocket.close();
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Ignore
|
|
711
|
+
}
|
|
712
|
+
this.udpSocket = null;
|
|
713
|
+
}
|
|
714
|
+
this.localUdpPort = 0;
|
|
715
|
+
return this.getMobileDirectHandoffBundle(routingTokenOverride);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Xuất toàn bộ thông số kết nối cuộc gọi & UDP `ZRTPPacket` sau khi thương lượng xong
|
|
719
|
+
* để truyền về Mobile App hoặc Media Bridge.
|
|
720
|
+
*/
|
|
721
|
+
getConnectionParams() {
|
|
722
|
+
const handoff = this.getMobileDirectHandoffBundle();
|
|
723
|
+
return {
|
|
724
|
+
callId: this.callId,
|
|
725
|
+
sessId: this.sessId,
|
|
726
|
+
isCaller: this.isCaller,
|
|
727
|
+
partnerId: this.partnerId,
|
|
728
|
+
fromId: this.fromUid32,
|
|
729
|
+
toId: this.toUid32,
|
|
730
|
+
callType: this.callType,
|
|
731
|
+
rtpAddress: this.rtpEndpoint.address,
|
|
732
|
+
rtpHost: this.rtpEndpoint.host,
|
|
733
|
+
rtpPort: this.rtpEndpoint.port,
|
|
734
|
+
rtcpAddress: this.rtcpEndpoint.address,
|
|
735
|
+
rtcpHost: this.rtcpEndpoint.host,
|
|
736
|
+
rtcpPort: this.rtcpEndpoint.port,
|
|
737
|
+
routingToken: handoff.routingToken,
|
|
738
|
+
udpHandshakeVerified: this.udpHandshakeVerified,
|
|
739
|
+
localUdpPort: this.localUdpPort,
|
|
740
|
+
packetMode: this.options.packetMode,
|
|
741
|
+
codec: this.codec,
|
|
742
|
+
extendData: this.extendData,
|
|
743
|
+
zrtpKey: this.zrtpKey,
|
|
744
|
+
srtpMasterKeyBase64: this.srtpSession?.masterKeyBase64,
|
|
745
|
+
audioZrtcHeaderHex: handoff.audioRtpPrefixHex,
|
|
746
|
+
videoZrtcHeaderHex: handoff.videoRtpPrefixHex,
|
|
747
|
+
serverInfo: this.serverInfo,
|
|
748
|
+
mobileDirectHandoff: handoff,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Khởi tạo cuộc gọi đi (Caller) 1-1 hoàn chỉnh:
|
|
753
|
+
* 1. Gọi `/api/voicecall/requestcall` (401) lấy `sessId`, `rtpaddr`, `rtcpaddr`, `zrtpKey`
|
|
754
|
+
* 2. Mở socket UDP và gửi gói bắt tay `0x01 0x7e` (`cmd=11`) tới `rtpaddr` để nhận `routingToken` (bỏ qua nếu `signalingOnly: true`)
|
|
755
|
+
* 3. Gọi `/api/voicecall/request` (416) kèm codec Opus chuẩn và `extendData` để máy đối phương đổ chuông
|
|
756
|
+
* 4. Duy trì nhịp tim UDP Ping (`0x01 0x7e`, `cmd=2/5/32`) và sẵn sàng truyền/nhận RTP/SRTP (nếu không bật `signalingOnly`)
|
|
757
|
+
*/
|
|
758
|
+
async startOutgoingCall(calleeId, options = {}) {
|
|
759
|
+
if (!calleeId)
|
|
760
|
+
throw new ZaloApiError("Missing calleeId");
|
|
761
|
+
this.resetSessionStateForNewCall();
|
|
762
|
+
const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
|
|
763
|
+
this.isCaller = true;
|
|
764
|
+
this.partnerId = String(calleeId);
|
|
765
|
+
this.groupId = "";
|
|
766
|
+
this.groupPartners = [];
|
|
767
|
+
this.callType = options.callType ?? CallType.Audio;
|
|
768
|
+
this.isCameraOn =
|
|
769
|
+
(this.callType === CallType.Video || this.callType === CallType.GroupVideo) &&
|
|
770
|
+
!options.offCameraAtStart;
|
|
771
|
+
this.isMicMuted = false;
|
|
772
|
+
this.isHold = false;
|
|
773
|
+
this.isScreenSharing = false;
|
|
774
|
+
this.activeStartedAtMs = 0;
|
|
775
|
+
this.callId = options.callId ?? Math.floor(Math.random() * 2_000_000_000);
|
|
776
|
+
this.codec = options.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
|
|
777
|
+
this.udpHandshakeVerified = false;
|
|
778
|
+
this.hasReceivedPartnerMedia = false;
|
|
779
|
+
this.hasSentAnswerAck = false;
|
|
780
|
+
this.setState("allocating");
|
|
781
|
+
if (this.options.autoAttachListener) {
|
|
782
|
+
this.attachCallEventListener();
|
|
783
|
+
}
|
|
784
|
+
// Bước 1: Xin cấp phát phiên & địa chỉ máy chủ Relay UDP (Signal 401)
|
|
785
|
+
const serverInfo = await this.api.call.requestVoiceCall(this.partnerId, {
|
|
786
|
+
callType: this.callType,
|
|
787
|
+
callId: this.callId,
|
|
788
|
+
codec: this.codec,
|
|
789
|
+
});
|
|
790
|
+
this.applyServerInfo(serverInfo);
|
|
791
|
+
// Bước 2: Mở UDP Socket & thực hiện bắt tay ZRTP InitCall (0x01 0x7e cmd=11)
|
|
792
|
+
// (Nếu chạy chế độ signalingOnly cho Mobile tự kết nối UDP trực tiếp thì bỏ qua bước mở socket tại Worker)
|
|
793
|
+
if (!isSignalingOnly) {
|
|
794
|
+
await this.ensureUdpSocket();
|
|
795
|
+
if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
|
|
796
|
+
this.setState("handshaking_udp");
|
|
797
|
+
await this.performUdpInitHandshake(true);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
this.routingToken = this.callId >>> 0;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
this.routingToken = this.callId >>> 0;
|
|
805
|
+
}
|
|
806
|
+
// Bước 3: Gửi tín hiệu đổ chuông 416 kèm codec Opus & ZRTC extendData chuẩn
|
|
807
|
+
const isVideo = this.callType === CallType.Video || this.callType === CallType.GroupVideo;
|
|
808
|
+
const negotiateVideoPipeline = isVideo || Boolean(options.allowVideoUpgrade) || Boolean(options.offCameraAtStart);
|
|
809
|
+
this.extendData = {
|
|
810
|
+
...buildZrtcExtendData({
|
|
811
|
+
rtpAddress: this.rtpEndpoint.address,
|
|
812
|
+
rtcpAddress: this.rtcpEndpoint.address,
|
|
813
|
+
isVideo: negotiateVideoPipeline,
|
|
814
|
+
packetMode: 2,
|
|
815
|
+
srtpMode: this.srtpSession ? 1 : 0,
|
|
816
|
+
srtpKey: this.srtpSession?.masterKeyBase64,
|
|
817
|
+
}),
|
|
818
|
+
rtcpDiff: 0,
|
|
819
|
+
rtcp: [{ ip: this.rtcpEndpoint.host, port: this.rtcpEndpoint.port }],
|
|
820
|
+
rtp: [{ ip: this.rtpEndpoint.host, port: this.rtpEndpoint.port }],
|
|
821
|
+
status: 0,
|
|
822
|
+
};
|
|
823
|
+
await this.api.call.sendRequestVoiceCall(this.partnerId, {
|
|
824
|
+
callId: this.callId,
|
|
825
|
+
session: this.sessId,
|
|
826
|
+
rtpAddress: this.rtpEndpoint.address,
|
|
827
|
+
rtcpAddress: this.rtcpEndpoint.address,
|
|
828
|
+
codec: this.codec,
|
|
829
|
+
extendData: this.extendData,
|
|
830
|
+
subCommand: 3,
|
|
831
|
+
});
|
|
832
|
+
// Nếu khởi tạo cuộc gọi với Camera tắt ban đầu (gọi Thoại có allowVideoUpgrade hoặc Video tắt Cam),
|
|
833
|
+
// đồng bộ ngay trạng thái OffCamera (cmd=32 subCmd=8 + /api/voicecall/conf isOnCam=0)
|
|
834
|
+
// khớp với ZCallMainWindowVideo_v2::updateIncallState (0x1000410c0).
|
|
835
|
+
if (negotiateVideoPipeline && !this.isCameraOn) {
|
|
836
|
+
if (!isSignalingOnly) {
|
|
837
|
+
void this.sendCameraState(false).catch(() => { });
|
|
838
|
+
}
|
|
839
|
+
void this.api.call
|
|
840
|
+
.updateCallMediaState(this.partnerId, this.callId, {
|
|
841
|
+
isOnCam: 0,
|
|
842
|
+
isOnMic: -1,
|
|
843
|
+
data: "",
|
|
844
|
+
})
|
|
845
|
+
.catch(() => { });
|
|
846
|
+
}
|
|
847
|
+
// Bước 4: Bắt đầu gửi UDP Ping & Media Keepalive định kỳ giữ phiên Relay (nếu không bật signalingOnly)
|
|
848
|
+
if (!isSignalingOnly) {
|
|
849
|
+
this.startUdpPingLoop();
|
|
850
|
+
this.startMediaKeepaliveLoop();
|
|
851
|
+
}
|
|
852
|
+
this.setState("ringing");
|
|
853
|
+
const params = this.getConnectionParams();
|
|
854
|
+
this.emit("connection_params", params);
|
|
855
|
+
return params;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Giải mã sự kiện cuộc gọi đến (`act: "request"` hoặc `"group_request"`) và gửi ngay tín hiệu
|
|
859
|
+
* báo máy đang đổ chuông (`ringRingVoiceCall` `11302` / `ringRingGroupCall` `12439`) tới phía người gọi,
|
|
860
|
+
* đồng thời chuyển phiên sang trạng thái `"ringing"` trong khi chờ người dùng bấm Nghe máy hoặc Từ chối.
|
|
861
|
+
* Khớp với `zrtc_call::ZCallInfo::receiveIncomingCall` (`0x1000ccbbc`).
|
|
862
|
+
*/
|
|
863
|
+
async ringIncomingCall(callEvent) {
|
|
864
|
+
const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
|
|
865
|
+
if (!info.callerId || !info.callId) {
|
|
866
|
+
throw new ZaloApiError("Invalid incoming call event: missing callerId or callId");
|
|
867
|
+
}
|
|
868
|
+
this.resetSessionStateForNewCall();
|
|
869
|
+
this.isCaller = false;
|
|
870
|
+
this.callId = info.callId;
|
|
871
|
+
this.partnerId = info.isGroupCall ? info.hostCall || info.callerId : info.callerId;
|
|
872
|
+
this.groupId = info.groupId;
|
|
873
|
+
this.groupPartners = info.partners;
|
|
874
|
+
this.fromUid32 = info.fromUid32;
|
|
875
|
+
this.toUid32 = info.toUid32;
|
|
876
|
+
this.sessId = info.sessId;
|
|
877
|
+
this.callType = info.callType;
|
|
878
|
+
this.isCameraOn = info.isVideo;
|
|
879
|
+
this.rtpEndpoint = {
|
|
880
|
+
address: info.rtpAddress,
|
|
881
|
+
host: info.rtpHost,
|
|
882
|
+
port: info.rtpPort,
|
|
883
|
+
};
|
|
884
|
+
this.rtcpEndpoint = {
|
|
885
|
+
address: info.rtcpAddress,
|
|
886
|
+
host: info.rtcpHost,
|
|
887
|
+
port: info.rtcpPort,
|
|
888
|
+
};
|
|
889
|
+
this.codec = info.codec;
|
|
890
|
+
this.zrtpKey = info.zrtpKey;
|
|
891
|
+
if (this.options.autoAttachListener) {
|
|
892
|
+
this.attachCallEventListener();
|
|
893
|
+
}
|
|
894
|
+
if (info.isGroupCall) {
|
|
895
|
+
await this.api.call.ringRingGroupCall(this.callId, {
|
|
896
|
+
hostCall: this.partnerId,
|
|
897
|
+
callType: this.callType,
|
|
898
|
+
partners: this.groupPartners,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
await this.api.call.ringRingVoiceCall(this.partnerId, this.callId);
|
|
903
|
+
}
|
|
904
|
+
this.setState("ringing");
|
|
905
|
+
this.emit("incoming_call", info);
|
|
906
|
+
return info;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Chấp nhận cuộc gọi đến (Callee) từ sự kiện `call_event` (`act: "request"` hoặc `"group_request"`):
|
|
910
|
+
* - Tự động giải mã chuẩn xác `params.id` (`callId`), `uidN` (`callerId`), `uidFrom` (`toUid32`), `uidTo` (`fromUid32`), `rtpIP`/`rtcpIP`/`servers`
|
|
911
|
+
* - Nếu là cuộc gọi Nhóm (`isGroupCall`), tự động uỷ quyền sang `acceptIncomingGroupCall`
|
|
912
|
+
* - Với cuộc gọi 1-1:
|
|
913
|
+
* 1. Gửi `/api/voicecall/ringring` (407) (nếu chưa gửi)
|
|
914
|
+
* 2. Nếu sự kiện thiếu địa chỉ Relay UDP, tự động gọi dự phòng `requestVoiceCall` (401) như `_trySendAcceptSignal` (`0x1000c2c9c`)
|
|
915
|
+
* 3. Mở socket UDP và gửi gói bắt tay `0x01 0x7e` (`cmd=12`) tới `rtpaddr` (bỏ qua nếu `signalingOnly: true`)
|
|
916
|
+
* 4. Gửi `/api/voicecall/answer` (402) để kết nối âm thanh/hình ảnh 2 chiều
|
|
917
|
+
*/
|
|
918
|
+
async acceptIncomingCall(callEvent, options = {}) {
|
|
919
|
+
const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
|
|
920
|
+
if (info.isGroupCall) {
|
|
921
|
+
return this.acceptIncomingGroupCall(callEvent, options);
|
|
922
|
+
}
|
|
923
|
+
if (!info.callerId || !info.callId) {
|
|
924
|
+
throw new ZaloApiError("Invalid incoming call event: missing callerId or callId");
|
|
925
|
+
}
|
|
926
|
+
this.resetSessionStateForNewCall(info.callId);
|
|
927
|
+
const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
|
|
928
|
+
this.isCaller = false;
|
|
929
|
+
this.partnerId = info.callerId;
|
|
930
|
+
this.groupId = "";
|
|
931
|
+
this.groupPartners = [];
|
|
932
|
+
this.callId = info.callId;
|
|
933
|
+
this.fromUid32 = info.fromUid32;
|
|
934
|
+
this.toUid32 = info.toUid32;
|
|
935
|
+
this.sessId = info.sessId;
|
|
936
|
+
this.codec = options.codec ?? info.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
|
|
937
|
+
this.zrtpKey = info.zrtpKey;
|
|
938
|
+
this.hasReceivedPartnerMedia = false;
|
|
939
|
+
this.hasSentAnswerAck = false;
|
|
940
|
+
this.isScreenSharing = false;
|
|
941
|
+
this.activeStartedAtMs = 0;
|
|
942
|
+
this.rtpEndpoint = {
|
|
943
|
+
address: info.rtpAddress,
|
|
944
|
+
host: info.rtpHost,
|
|
945
|
+
port: info.rtpPort,
|
|
946
|
+
};
|
|
947
|
+
this.rtcpEndpoint = {
|
|
948
|
+
address: info.rtcpAddress,
|
|
949
|
+
host: info.rtcpHost,
|
|
950
|
+
port: info.rtcpPort,
|
|
951
|
+
};
|
|
952
|
+
this.callType = info.callType;
|
|
953
|
+
this.isCameraOn = this.callType === CallType.Video && !options.offCameraAtStart;
|
|
954
|
+
this.isMicMuted = false;
|
|
955
|
+
this.isHold = false;
|
|
956
|
+
if (typeof info.srtpKey === "string" && info.srtpKey.length >= 40) {
|
|
957
|
+
this.srtpSession = new ZaloSrtpSession(info.srtpKey, this.options.srtpSuite);
|
|
958
|
+
}
|
|
959
|
+
else if (info.srtpMode === 0 && !this.options.enableSrtp) {
|
|
960
|
+
this.srtpSession = null;
|
|
961
|
+
}
|
|
962
|
+
if (this.options.autoAttachListener) {
|
|
963
|
+
this.attachCallEventListener();
|
|
964
|
+
}
|
|
965
|
+
// Bước 1: Báo máy đang đổ chuông (Signal 407)
|
|
966
|
+
if (!options.skipRingRing) {
|
|
967
|
+
await this.api.call.ringRingVoiceCall(this.partnerId, this.callId);
|
|
968
|
+
}
|
|
969
|
+
// Dự phòng theo _trySendAcceptSignal (0x1000c2c9c): nếu sự kiện thiếu rtpPort, xin cấp phát qua Signal 401
|
|
970
|
+
if (this.rtpEndpoint.port <= 0) {
|
|
971
|
+
try {
|
|
972
|
+
const fallbackServer = await this.api.call.requestVoiceCall(this.partnerId, {
|
|
973
|
+
callId: this.callId,
|
|
974
|
+
callType: this.callType,
|
|
975
|
+
codec: this.codec,
|
|
976
|
+
});
|
|
977
|
+
this.applyServerInfo(fallbackServer);
|
|
978
|
+
}
|
|
979
|
+
catch {
|
|
980
|
+
// Ignore if mock/offline environment does not require fallback
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
// Bước 2: Mở UDP Socket & bắt tay Callee (0x01 0x7e cmd=12)
|
|
984
|
+
if (!isSignalingOnly) {
|
|
985
|
+
await this.ensureUdpSocket();
|
|
986
|
+
if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
|
|
987
|
+
this.setState("handshaking_udp");
|
|
988
|
+
await this.performUdpInitHandshake(false);
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
this.routingToken = this.callId >>> 0;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
this.routingToken = this.callId >>> 0;
|
|
996
|
+
}
|
|
997
|
+
// Bước 3: Gửi tín hiệu bắt máy (Signal 402)
|
|
998
|
+
const isVideo = this.callType === CallType.Video;
|
|
999
|
+
const negotiateVideoPipeline = isVideo || Boolean(options.allowVideoUpgrade) || Boolean(options.offCameraAtStart);
|
|
1000
|
+
this.extendData = buildZrtcExtendData({
|
|
1001
|
+
rtpAddress: this.rtpEndpoint.address,
|
|
1002
|
+
rtcpAddress: this.rtcpEndpoint.address,
|
|
1003
|
+
isVideo: negotiateVideoPipeline,
|
|
1004
|
+
packetMode: 2,
|
|
1005
|
+
srtpMode: this.srtpSession ? 1 : 0,
|
|
1006
|
+
srtpKey: this.srtpSession?.masterKeyBase64,
|
|
1007
|
+
});
|
|
1008
|
+
if (!options.skipAnswerSignal) {
|
|
1009
|
+
await this.api.call.answerVoiceCall(this.partnerId, this.callId, {
|
|
1010
|
+
sessionId: this.sessId,
|
|
1011
|
+
rtpAddress: this.rtpEndpoint.address,
|
|
1012
|
+
rtcpAddress: this.rtcpEndpoint.address,
|
|
1013
|
+
codec: this.codec,
|
|
1014
|
+
extendData: this.extendData,
|
|
1015
|
+
status: 0,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
if (negotiateVideoPipeline && !this.isCameraOn) {
|
|
1019
|
+
if (!isSignalingOnly) {
|
|
1020
|
+
void this.sendCameraState(false).catch(() => { });
|
|
1021
|
+
}
|
|
1022
|
+
void this.api.call
|
|
1023
|
+
.updateCallMediaState(this.partnerId, this.callId, {
|
|
1024
|
+
isOnCam: 0,
|
|
1025
|
+
isOnMic: -1,
|
|
1026
|
+
data: "",
|
|
1027
|
+
})
|
|
1028
|
+
.catch(() => { });
|
|
1029
|
+
}
|
|
1030
|
+
if (!isSignalingOnly) {
|
|
1031
|
+
this.startUdpPingLoop();
|
|
1032
|
+
this.startMediaKeepaliveLoop();
|
|
1033
|
+
}
|
|
1034
|
+
this.setState("active");
|
|
1035
|
+
const params = this.getConnectionParams();
|
|
1036
|
+
this.emit("connection_params", params);
|
|
1037
|
+
this.emit("active", params);
|
|
1038
|
+
return params;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Từ chối cuộc gọi đến (1-1 hoặc Nhóm) từ sự kiện `call_event` (`act: "request"` / `"group_request"`):
|
|
1042
|
+
* - Với 1-1: gọi `/api/voicecall/cancel` (`11305`) và `/api/voicecall/endcall` (`11306`)
|
|
1043
|
+
* - Với Nhóm: gọi `/api/voicecall/group/cancel` (`12437`) và `/api/voicecall/group/endcall` (`12438`)
|
|
1044
|
+
*/
|
|
1045
|
+
async rejectIncomingCall(callEvent, options = {}) {
|
|
1046
|
+
const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
|
|
1047
|
+
const callerId = info.callerId || info.hostCall;
|
|
1048
|
+
const callId = info.callId;
|
|
1049
|
+
if (!callerId || !callId) {
|
|
1050
|
+
throw new ZaloApiError("Invalid incoming call event: missing callerId or callId");
|
|
1051
|
+
}
|
|
1052
|
+
this.callId = callId;
|
|
1053
|
+
this.partnerId = callerId;
|
|
1054
|
+
if (info.isGroupCall) {
|
|
1055
|
+
const hostCall = info.hostCall || info.groupId || callerId;
|
|
1056
|
+
this.groupId = hostCall;
|
|
1057
|
+
await this.api.call
|
|
1058
|
+
.cancelGroupCall(callId, {
|
|
1059
|
+
hostCall,
|
|
1060
|
+
callType: info.callType,
|
|
1061
|
+
partners: info.partners,
|
|
1062
|
+
})
|
|
1063
|
+
.catch(() => { });
|
|
1064
|
+
await this.api.call
|
|
1065
|
+
.endGroupCall(callId, {
|
|
1066
|
+
hostCall,
|
|
1067
|
+
callType: info.callType,
|
|
1068
|
+
partners: info.partners,
|
|
1069
|
+
})
|
|
1070
|
+
.catch(() => { });
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
await this.api.call
|
|
1074
|
+
.cancelVoiceCall(callerId, callId, {
|
|
1075
|
+
callType: info.callType,
|
|
1076
|
+
})
|
|
1077
|
+
.catch(() => { });
|
|
1078
|
+
await this.api.call
|
|
1079
|
+
.endVoiceCall(callerId, callId, {
|
|
1080
|
+
status: options.status ?? 3,
|
|
1081
|
+
})
|
|
1082
|
+
.catch(() => { });
|
|
1083
|
+
}
|
|
1084
|
+
this.setState("ended");
|
|
1085
|
+
this.cleanupResources();
|
|
1086
|
+
this.emit("ended", "rejected");
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Chấp nhận lời mời gọi Nhóm đến từ sự kiện `call_event` (`act: "group_request"`):
|
|
1090
|
+
* 1. Gửi `/api/voicecall/group/ringring` (`12439`, signal `506`)
|
|
1091
|
+
* 2. Mở socket UDP và bắt tay `InitCallCallee` (`0x01 0x7e`, `cmd=12`) (bỏ qua nếu `signalingOnly: true`)
|
|
1092
|
+
* 3. Gửi `/api/voicecall/group/answer` (`12436`, signal `503`)
|
|
1093
|
+
*/
|
|
1094
|
+
async acceptIncomingGroupCall(callEvent, options = {}) {
|
|
1095
|
+
const info = parseIncomingCallEvent(callEvent, this.api.getContext().uid);
|
|
1096
|
+
const hostCall = info.hostCall || info.groupId || info.callerId;
|
|
1097
|
+
const callId = info.callId;
|
|
1098
|
+
if (!hostCall || !callId) {
|
|
1099
|
+
throw new ZaloApiError("Invalid incoming group call event: missing hostCall or callId");
|
|
1100
|
+
}
|
|
1101
|
+
this.resetSessionStateForNewCall(callId);
|
|
1102
|
+
const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
|
|
1103
|
+
this.isCaller = false;
|
|
1104
|
+
this.callId = callId;
|
|
1105
|
+
this.groupId = info.groupId || hostCall;
|
|
1106
|
+
this.partnerId = hostCall;
|
|
1107
|
+
this.groupPartners = info.partners;
|
|
1108
|
+
this.fromUid32 = info.fromUid32;
|
|
1109
|
+
this.toUid32 = info.toUid32;
|
|
1110
|
+
this.sessId = info.sessId;
|
|
1111
|
+
this.codec = options.codec ?? info.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
|
|
1112
|
+
this.zrtpKey = info.zrtpKey;
|
|
1113
|
+
this.hasReceivedPartnerMedia = false;
|
|
1114
|
+
this.hasSentAnswerAck = false;
|
|
1115
|
+
this.isScreenSharing = false;
|
|
1116
|
+
this.activeStartedAtMs = 0;
|
|
1117
|
+
this.rtpEndpoint = {
|
|
1118
|
+
address: info.rtpAddress,
|
|
1119
|
+
host: info.rtpHost,
|
|
1120
|
+
port: info.rtpPort,
|
|
1121
|
+
};
|
|
1122
|
+
this.rtcpEndpoint = {
|
|
1123
|
+
address: info.rtcpAddress,
|
|
1124
|
+
host: info.rtcpHost,
|
|
1125
|
+
port: info.rtcpPort,
|
|
1126
|
+
};
|
|
1127
|
+
this.callType =
|
|
1128
|
+
info.callType === CallType.GroupVideo || info.isVideo
|
|
1129
|
+
? CallType.GroupVideo
|
|
1130
|
+
: CallType.GroupAudio;
|
|
1131
|
+
this.isCameraOn = this.callType === CallType.GroupVideo && !options.offCameraAtStart;
|
|
1132
|
+
this.isMicMuted = false;
|
|
1133
|
+
this.isHold = false;
|
|
1134
|
+
if (this.options.autoAttachListener) {
|
|
1135
|
+
this.attachCallEventListener();
|
|
1136
|
+
}
|
|
1137
|
+
if (!options.skipRingRing) {
|
|
1138
|
+
await this.api.call.ringRingGroupCall(this.callId, {
|
|
1139
|
+
hostCall: this.partnerId,
|
|
1140
|
+
callType: this.callType,
|
|
1141
|
+
partners: this.groupPartners,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
if (!isSignalingOnly) {
|
|
1145
|
+
await this.ensureUdpSocket();
|
|
1146
|
+
if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
|
|
1147
|
+
this.setState("handshaking_udp");
|
|
1148
|
+
await this.performUdpInitHandshake(false);
|
|
1149
|
+
}
|
|
1150
|
+
else {
|
|
1151
|
+
this.routingToken = this.callId >>> 0;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
this.routingToken = this.callId >>> 0;
|
|
1156
|
+
}
|
|
1157
|
+
const isVideo = this.callType === CallType.GroupVideo;
|
|
1158
|
+
this.extendData = buildZrtcExtendData({
|
|
1159
|
+
rtpAddress: this.rtpEndpoint.address,
|
|
1160
|
+
rtcpAddress: this.rtcpEndpoint.address,
|
|
1161
|
+
isVideo: isVideo || (options.allowVideoUpgrade ?? true),
|
|
1162
|
+
packetMode: 2,
|
|
1163
|
+
srtpMode: this.srtpSession ? 1 : 0,
|
|
1164
|
+
srtpKey: this.srtpSession?.masterKeyBase64,
|
|
1165
|
+
});
|
|
1166
|
+
if (!options.skipAnswerSignal) {
|
|
1167
|
+
await this.api.call.answerGroupCall(this.callId, {
|
|
1168
|
+
hostCall: this.partnerId,
|
|
1169
|
+
callType: this.callType,
|
|
1170
|
+
partners: this.groupPartners,
|
|
1171
|
+
data: this.extendData,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
if (!isSignalingOnly) {
|
|
1175
|
+
this.startUdpPingLoop();
|
|
1176
|
+
this.startMediaKeepaliveLoop();
|
|
1177
|
+
}
|
|
1178
|
+
this.startGroupPingLoop();
|
|
1179
|
+
this.setState("active");
|
|
1180
|
+
const params = this.getConnectionParams();
|
|
1181
|
+
this.emit("connection_params", params);
|
|
1182
|
+
this.emit("active", params);
|
|
1183
|
+
return params;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Khởi tạo cuộc gọi nhóm (Group Audio `CallType.GroupAudio = 5` hoặc Group Video `CallType.GroupVideo = 6`):
|
|
1187
|
+
* 1. Gọi `/api/voicecall/group/requestcall` (501) xin cấp phát máy chủ Relay UDP
|
|
1188
|
+
* 2. Mở socket UDP và bắt tay `0x01 0x7e` (`cmd=11`) (bỏ qua nếu `signalingOnly: true`)
|
|
1189
|
+
* 3. Gọi `/api/voicecall/group/request` (513) mời danh sách `partners` tham gia
|
|
1190
|
+
* 4. Duy trì nhịp tim UDP + `/api/voicecall/group/ping` (509) và luồng RTP/RTCP
|
|
1191
|
+
*/
|
|
1192
|
+
async startGroupCall(groupId, options) {
|
|
1193
|
+
if (!groupId)
|
|
1194
|
+
throw new ZaloApiError("Missing groupId");
|
|
1195
|
+
if (!options?.partners || options.partners.length === 0) {
|
|
1196
|
+
throw new ZaloApiError("Missing group call partners");
|
|
1197
|
+
}
|
|
1198
|
+
this.resetSessionStateForNewCall();
|
|
1199
|
+
const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
|
|
1200
|
+
this.isCaller = true;
|
|
1201
|
+
this.groupId = String(groupId);
|
|
1202
|
+
this.partnerId = String(groupId);
|
|
1203
|
+
this.groupPartners = options.partners.map(String);
|
|
1204
|
+
this.callType = options.callType ?? CallType.GroupAudio;
|
|
1205
|
+
this.isCameraOn =
|
|
1206
|
+
(this.callType === CallType.GroupVideo || this.callType === CallType.Video) &&
|
|
1207
|
+
!options.offCameraAtStart;
|
|
1208
|
+
this.isMicMuted = false;
|
|
1209
|
+
this.isHold = false;
|
|
1210
|
+
this.isScreenSharing = false;
|
|
1211
|
+
this.activeStartedAtMs = 0;
|
|
1212
|
+
this.callId = options.callId ?? Math.floor(Math.random() * 2_000_000_000);
|
|
1213
|
+
this.codec = options.codec ?? DEFAULT_ZALO_AUDIO_CODEC;
|
|
1214
|
+
this.udpHandshakeVerified = false;
|
|
1215
|
+
this.hasReceivedPartnerMedia = false;
|
|
1216
|
+
this.hasSentAnswerAck = false;
|
|
1217
|
+
this.setState("allocating");
|
|
1218
|
+
if (this.options.autoAttachListener) {
|
|
1219
|
+
this.attachCallEventListener();
|
|
1220
|
+
}
|
|
1221
|
+
const isVideo = this.callType === CallType.GroupVideo || this.callType === CallType.Video;
|
|
1222
|
+
const reqRes = await this.api.call.requestGroupCall(this.groupId, {
|
|
1223
|
+
callId: this.callId,
|
|
1224
|
+
typeRequest: this.callType,
|
|
1225
|
+
partners: this.groupPartners,
|
|
1226
|
+
data: { codec: this.codec, isVideo: isVideo ? 1 : 0 },
|
|
1227
|
+
});
|
|
1228
|
+
const serverInfo = (typeof reqRes?.data === "string"
|
|
1229
|
+
? JSON.parse(reqRes.data)
|
|
1230
|
+
: (reqRes?.data ?? reqRes));
|
|
1231
|
+
this.applyServerInfo(serverInfo);
|
|
1232
|
+
if (!isSignalingOnly) {
|
|
1233
|
+
await this.ensureUdpSocket();
|
|
1234
|
+
if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
|
|
1235
|
+
this.setState("handshaking_udp");
|
|
1236
|
+
await this.performUdpInitHandshake(true);
|
|
1237
|
+
}
|
|
1238
|
+
else {
|
|
1239
|
+
this.routingToken = this.callId >>> 0;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
this.routingToken = this.callId >>> 0;
|
|
1244
|
+
}
|
|
1245
|
+
this.extendData = {
|
|
1246
|
+
...buildZrtcExtendData({
|
|
1247
|
+
rtpAddress: this.rtpEndpoint.address,
|
|
1248
|
+
rtcpAddress: this.rtcpEndpoint.address,
|
|
1249
|
+
isVideo,
|
|
1250
|
+
packetMode: 2,
|
|
1251
|
+
srtpMode: this.srtpSession ? 1 : 0,
|
|
1252
|
+
srtpKey: this.srtpSession?.masterKeyBase64,
|
|
1253
|
+
}),
|
|
1254
|
+
rtcpDiff: 0,
|
|
1255
|
+
rtcp: [{ ip: this.rtcpEndpoint.host, port: this.rtcpEndpoint.port }],
|
|
1256
|
+
rtp: [{ ip: this.rtpEndpoint.host, port: this.rtpEndpoint.port }],
|
|
1257
|
+
status: 0,
|
|
1258
|
+
};
|
|
1259
|
+
await this.api.call.sendRequestGroupCall(this.groupId, {
|
|
1260
|
+
callId: this.callId,
|
|
1261
|
+
callType: this.callType,
|
|
1262
|
+
session: this.sessId,
|
|
1263
|
+
partners: this.groupPartners,
|
|
1264
|
+
data: this.extendData,
|
|
1265
|
+
});
|
|
1266
|
+
if (!isSignalingOnly) {
|
|
1267
|
+
this.startUdpPingLoop();
|
|
1268
|
+
this.startMediaKeepaliveLoop();
|
|
1269
|
+
}
|
|
1270
|
+
this.startGroupPingLoop();
|
|
1271
|
+
this.setState("active");
|
|
1272
|
+
const params = this.getConnectionParams();
|
|
1273
|
+
this.emit("connection_params", params);
|
|
1274
|
+
this.emit("active", params);
|
|
1275
|
+
return params;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Tham gia cuộc gọi nhóm đang diễn ra (`/api/voicecall/group/join`, command `12043`).
|
|
1279
|
+
*/
|
|
1280
|
+
async joinActiveGroupCall(callId, options) {
|
|
1281
|
+
if (!callId)
|
|
1282
|
+
throw new ZaloApiError("Missing callId");
|
|
1283
|
+
if (!options?.hostCall)
|
|
1284
|
+
throw new ZaloApiError("Missing hostCall");
|
|
1285
|
+
this.resetSessionStateForNewCall();
|
|
1286
|
+
const isSignalingOnly = Boolean(options.signalingOnly ?? this.options.signalingOnly);
|
|
1287
|
+
this.isCaller = false;
|
|
1288
|
+
this.callId = callId;
|
|
1289
|
+
this.groupId = String(options.groupId ?? options.hostCall);
|
|
1290
|
+
this.partnerId = String(options.hostCall);
|
|
1291
|
+
this.groupPartners = (options.partners ?? []).map(String);
|
|
1292
|
+
this.callType = options.callType ?? CallType.GroupAudio;
|
|
1293
|
+
this.isCameraOn = this.callType === CallType.GroupVideo || this.callType === CallType.Video;
|
|
1294
|
+
this.isMicMuted = false;
|
|
1295
|
+
this.isHold = false;
|
|
1296
|
+
this.isScreenSharing = false;
|
|
1297
|
+
this.activeStartedAtMs = 0;
|
|
1298
|
+
this.sessId = options.session ?? `${this.partnerId}_${this.callId}_501`;
|
|
1299
|
+
if (this.options.autoAttachListener) {
|
|
1300
|
+
this.attachCallEventListener();
|
|
1301
|
+
}
|
|
1302
|
+
const isVideo = this.callType === CallType.GroupVideo || this.callType === CallType.Video;
|
|
1303
|
+
const joinRes = await this.api.call.joinGroupCall(this.callId, {
|
|
1304
|
+
hostCall: String(options.hostCall),
|
|
1305
|
+
audioState: this.isMicMuted ? 0 : 1,
|
|
1306
|
+
videoState: isVideo ? 1 : 0,
|
|
1307
|
+
data: { codec: this.codec },
|
|
1308
|
+
});
|
|
1309
|
+
const serverInfo = (typeof joinRes?.data === "string"
|
|
1310
|
+
? JSON.parse(joinRes.data)
|
|
1311
|
+
: (joinRes?.data ?? joinRes));
|
|
1312
|
+
if (options.rtpAddress) {
|
|
1313
|
+
this.rtpEndpoint = parseEndpoint(options.rtpAddress);
|
|
1314
|
+
this.rtcpEndpoint = parseEndpoint(options.rtcpAddress || options.rtpAddress);
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
this.applyServerInfo(serverInfo);
|
|
1318
|
+
}
|
|
1319
|
+
if (!isSignalingOnly) {
|
|
1320
|
+
await this.ensureUdpSocket();
|
|
1321
|
+
if (!options.skipUdpHandshake && this.rtpEndpoint.port > 0) {
|
|
1322
|
+
this.setState("handshaking_udp");
|
|
1323
|
+
await this.performUdpInitHandshake(false);
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
this.routingToken = this.callId >>> 0;
|
|
1327
|
+
}
|
|
1328
|
+
this.startUdpPingLoop();
|
|
1329
|
+
this.startMediaKeepaliveLoop();
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
this.routingToken = this.callId >>> 0;
|
|
1333
|
+
}
|
|
1334
|
+
this.startGroupPingLoop();
|
|
1335
|
+
this.setState("active");
|
|
1336
|
+
const params = this.getConnectionParams();
|
|
1337
|
+
this.emit("connection_params", params);
|
|
1338
|
+
this.emit("active", params);
|
|
1339
|
+
return params;
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Mời thêm thành viên vào cuộc gọi nhóm đang diễn ra (`/api/voicecall/group/adduser`, command `12435`).
|
|
1343
|
+
*/
|
|
1344
|
+
async addMembersToGroupCall(partners) {
|
|
1345
|
+
if (!this.callId)
|
|
1346
|
+
throw new ZaloApiError("No active group call");
|
|
1347
|
+
const cleanPartners = partners.map(String).filter(Boolean);
|
|
1348
|
+
for (const p of cleanPartners) {
|
|
1349
|
+
if (!this.groupPartners.includes(p))
|
|
1350
|
+
this.groupPartners.push(p);
|
|
1351
|
+
}
|
|
1352
|
+
return this.api.call.addUserGroupCall(this.callId, {
|
|
1353
|
+
hostCall: this.groupId || this.partnerId,
|
|
1354
|
+
session: this.sessId,
|
|
1355
|
+
callType: this.callType,
|
|
1356
|
+
partners: cleanPartners,
|
|
1357
|
+
data: this.extendData,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Bật / tắt Camera trong cuộc gọi Video (đồng bộ cả gói UDP `cmd=32, subCmd=7/8` và HTTP `/api/voicecall/conf` hoặc `/api/voicecall/group/broadcast`).
|
|
1362
|
+
*/
|
|
1363
|
+
async setCameraEnabled(enabled) {
|
|
1364
|
+
const wasAudioCall = this.callType === CallType.Audio || this.callType === CallType.GroupAudio;
|
|
1365
|
+
this.isCameraOn = enabled;
|
|
1366
|
+
if (enabled && this.callType === CallType.Audio) {
|
|
1367
|
+
this.callType = CallType.Video;
|
|
1368
|
+
}
|
|
1369
|
+
else if (enabled && this.callType === CallType.GroupAudio) {
|
|
1370
|
+
this.callType = CallType.GroupVideo;
|
|
1371
|
+
}
|
|
1372
|
+
if (enabled) {
|
|
1373
|
+
this.startMediaKeepaliveLoop();
|
|
1374
|
+
}
|
|
1375
|
+
await this.sendCameraState(enabled).catch(() => { });
|
|
1376
|
+
if (this.groupId && this.callId > 0) {
|
|
1377
|
+
await this.api.call
|
|
1378
|
+
.broadcastGroupCallState(this.callId, {
|
|
1379
|
+
hostCall: this.groupId,
|
|
1380
|
+
audioState: this.isMicMuted ? 0 : 1,
|
|
1381
|
+
videoState: enabled ? 1 : 0,
|
|
1382
|
+
})
|
|
1383
|
+
.catch(() => { });
|
|
1384
|
+
}
|
|
1385
|
+
else if (this.partnerId && this.callId > 0) {
|
|
1386
|
+
if (enabled && wasAudioCall) {
|
|
1387
|
+
await this.api.call
|
|
1388
|
+
.requestAudioToVideoCall(this.partnerId, this.callId, { status: 1 })
|
|
1389
|
+
.catch(() => { });
|
|
1390
|
+
}
|
|
1391
|
+
await this.api.call
|
|
1392
|
+
.updateCallMediaState(this.partnerId, this.callId, {
|
|
1393
|
+
isOnCam: enabled ? 1 : 0,
|
|
1394
|
+
isOnMic: -1,
|
|
1395
|
+
data: "",
|
|
1396
|
+
})
|
|
1397
|
+
.catch(() => { });
|
|
1398
|
+
}
|
|
1399
|
+
if (enabled) {
|
|
1400
|
+
void this.sendRtcpPli().catch(() => { });
|
|
1401
|
+
}
|
|
1402
|
+
this.emit("media_state_change", {
|
|
1403
|
+
isLocal: true,
|
|
1404
|
+
isCameraOn: this.isCameraOn,
|
|
1405
|
+
isMicMuted: this.isMicMuted,
|
|
1406
|
+
isHold: this.isHold,
|
|
1407
|
+
isScreenSharing: this.isScreenSharing,
|
|
1408
|
+
callType: this.callType,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Bật / tắt Microphone trong cuộc gọi (đồng bộ cả gói UDP `cmd=32, subCmd=5/6` và HTTP `/api/voicecall/conf` hoặc `/api/voicecall/group/broadcast`).
|
|
1413
|
+
*/
|
|
1414
|
+
async setMicMuted(muted) {
|
|
1415
|
+
this.isMicMuted = muted;
|
|
1416
|
+
await this.sendMicState(!muted).catch(() => { });
|
|
1417
|
+
if (this.groupId && this.callId > 0) {
|
|
1418
|
+
await this.api.call
|
|
1419
|
+
.broadcastGroupCallState(this.callId, {
|
|
1420
|
+
hostCall: this.groupId,
|
|
1421
|
+
audioState: muted ? 0 : 1,
|
|
1422
|
+
videoState: this.isCameraOn ? 1 : 0,
|
|
1423
|
+
})
|
|
1424
|
+
.catch(() => { });
|
|
1425
|
+
}
|
|
1426
|
+
else if (this.partnerId && this.callId > 0) {
|
|
1427
|
+
await this.api.call
|
|
1428
|
+
.updateCallMediaState(this.partnerId, this.callId, {
|
|
1429
|
+
isOnCam: -1,
|
|
1430
|
+
isOnMic: muted ? 0 : 1,
|
|
1431
|
+
data: "",
|
|
1432
|
+
})
|
|
1433
|
+
.catch(() => { });
|
|
1434
|
+
}
|
|
1435
|
+
this.emit("media_state_change", {
|
|
1436
|
+
isLocal: true,
|
|
1437
|
+
isCameraOn: this.isCameraOn,
|
|
1438
|
+
isMicMuted: this.isMicMuted,
|
|
1439
|
+
isHold: this.isHold,
|
|
1440
|
+
isScreenSharing: this.isScreenSharing,
|
|
1441
|
+
callType: this.callType,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Tạm giữ (`hold`) hoặc tiếp tục (`unhold`) cuộc gọi 1-1 (`/api/voicecall/holdreq` & `/api/voicecall/unholdreq`).
|
|
1446
|
+
*/
|
|
1447
|
+
async setHoldState(isHold, isResponse = false) {
|
|
1448
|
+
this.isHold = isHold;
|
|
1449
|
+
if (this.partnerId) {
|
|
1450
|
+
if (isHold) {
|
|
1451
|
+
await this.api.call.holdVoiceCall(this.partnerId, { isResponse }).catch(() => { });
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
await this.api.call.unholdVoiceCall(this.partnerId, { isResponse }).catch(() => { });
|
|
1455
|
+
this.clearIncomingVideoAssemblyBuffers();
|
|
1456
|
+
void this.sendMicState(!this.isMicMuted).catch(() => { });
|
|
1457
|
+
void this.sendCameraState(this.isCameraOn).catch(() => { });
|
|
1458
|
+
void this.sendRtcpPli().catch(() => { });
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
this.emit("media_state_change", {
|
|
1462
|
+
isLocal: true,
|
|
1463
|
+
isCameraOn: this.isCameraOn,
|
|
1464
|
+
isMicMuted: this.isMicMuted,
|
|
1465
|
+
isHold: this.isHold,
|
|
1466
|
+
isScreenSharing: this.isScreenSharing,
|
|
1467
|
+
callType: this.callType,
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Bật / tắt chia sẻ màn hình trong cuộc gọi 1-1 (`/api/voicecall/requestsharescreen`, command `12064`)
|
|
1472
|
+
* hoặc cuộc gọi Nhóm (`/api/voicecall/group/sharescreen`, command `12044`).
|
|
1473
|
+
*/
|
|
1474
|
+
async setScreenShareEnabled(enabled) {
|
|
1475
|
+
this.isScreenSharing = enabled;
|
|
1476
|
+
if (this.groupId && this.callId > 0) {
|
|
1477
|
+
await this.api.call
|
|
1478
|
+
.shareScreenGroupCall(this.callId, {
|
|
1479
|
+
hostCall: this.groupId,
|
|
1480
|
+
status: enabled ? 1 : 0,
|
|
1481
|
+
audioState: this.isMicMuted ? 0 : 1,
|
|
1482
|
+
videoState: this.isCameraOn || enabled ? 1 : 0,
|
|
1483
|
+
})
|
|
1484
|
+
.catch(() => { });
|
|
1485
|
+
}
|
|
1486
|
+
else if (this.partnerId && this.callId > 0) {
|
|
1487
|
+
await this.api.call
|
|
1488
|
+
.requestShareScreenCall(this.partnerId, this.callId, {
|
|
1489
|
+
status: enabled ? 1 : 0,
|
|
1490
|
+
})
|
|
1491
|
+
.catch(() => { });
|
|
1492
|
+
}
|
|
1493
|
+
this.emit("media_state_change", {
|
|
1494
|
+
isLocal: true,
|
|
1495
|
+
isCameraOn: this.isCameraOn,
|
|
1496
|
+
isMicMuted: this.isMicMuted,
|
|
1497
|
+
isHold: this.isHold,
|
|
1498
|
+
isScreenSharing: this.isScreenSharing,
|
|
1499
|
+
callType: this.callType,
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Chuyển đổi cuộc gọi Thoại (Audio) đang diễn ra lên cuộc gọi Video (`/api/voicecall/requestaudio2video`, command `12056`):
|
|
1504
|
+
* - `status = 1`: Gửi yêu cầu bật Video
|
|
1505
|
+
* - `status = 2`: Đồng ý yêu cầu bật Video từ đối phương
|
|
1506
|
+
* Tự động kích hoạt luồng Video RTP/RTCP, tín hiệu UDP `OnCamera` (`cmd=32, subCmd=7`) & HTTP `/api/voicecall/conf` (`isOnCam=1, isOnMic=-1`) ngay lập tức.
|
|
1507
|
+
*/
|
|
1508
|
+
async upgradeAudioToVideoCall(status = 1) {
|
|
1509
|
+
this.callType = this.groupId ? CallType.GroupVideo : CallType.Video;
|
|
1510
|
+
this.isCameraOn = true;
|
|
1511
|
+
this.startMediaKeepaliveLoop();
|
|
1512
|
+
await this.sendCameraState(true).catch(() => { });
|
|
1513
|
+
if (Date.now() - this.lastExternalVideoSentAtMs >= 300) {
|
|
1514
|
+
await this.sendH264AnnexBFrame(H264_KEEPALIVE_ANNEXB_FRAME, {
|
|
1515
|
+
timestampStep: 6000,
|
|
1516
|
+
isKeepalive: true,
|
|
1517
|
+
}).catch(() => { });
|
|
1518
|
+
}
|
|
1519
|
+
if (this.partnerId && this.callId > 0 && !this.groupId) {
|
|
1520
|
+
await this.api.call
|
|
1521
|
+
.requestAudioToVideoCall(this.partnerId, this.callId, { status })
|
|
1522
|
+
.catch(() => { });
|
|
1523
|
+
await this.api.call
|
|
1524
|
+
.updateCallMediaState(this.partnerId, this.callId, {
|
|
1525
|
+
isOnCam: 1,
|
|
1526
|
+
isOnMic: -1,
|
|
1527
|
+
data: "",
|
|
1528
|
+
})
|
|
1529
|
+
.catch(() => { });
|
|
1530
|
+
}
|
|
1531
|
+
else if (this.groupId && this.callId > 0) {
|
|
1532
|
+
await this.api.call
|
|
1533
|
+
.broadcastGroupCallState(this.callId, {
|
|
1534
|
+
hostCall: this.groupId,
|
|
1535
|
+
audioState: this.isMicMuted ? 0 : 1,
|
|
1536
|
+
videoState: 1,
|
|
1537
|
+
})
|
|
1538
|
+
.catch(() => { });
|
|
1539
|
+
}
|
|
1540
|
+
this.emit("media_state_change", {
|
|
1541
|
+
isLocal: true,
|
|
1542
|
+
isCameraOn: this.isCameraOn,
|
|
1543
|
+
isMicMuted: this.isMicMuted,
|
|
1544
|
+
isHold: this.isHold,
|
|
1545
|
+
isScreenSharing: this.isScreenSharing,
|
|
1546
|
+
callType: this.callType,
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Gửi trực tiếp một khung âm thanh Opus (mặc định 20ms @ 16kHz = 320 samples)
|
|
1551
|
+
* qua đường truyền UDP của cuộc gọi.
|
|
1552
|
+
* Tự động đóng gói RTP 12-byte header -> (Tuỳ chọn) Mã hoá SRTP -> Bọc `ZRTPPacket` header.
|
|
1553
|
+
*/
|
|
1554
|
+
async sendAudioFrame(opusPayload, options = {}) {
|
|
1555
|
+
if (!options.isKeepalive) {
|
|
1556
|
+
this.lastExternalAudioSentAtMs = Date.now();
|
|
1557
|
+
}
|
|
1558
|
+
const seq = options.sequenceNumber ?? (this.audioSeq = (this.audioSeq + 1) & 0xffff);
|
|
1559
|
+
const samples = options.samples ?? 320;
|
|
1560
|
+
const ts = options.timestamp ?? (this.audioTimestamp = (this.audioTimestamp + samples) >>> 0);
|
|
1561
|
+
const ssrc = options.ssrc ?? this.getLocalAudioSsrc();
|
|
1562
|
+
this.audioPacketCount = (this.audioPacketCount + 1) >>> 0;
|
|
1563
|
+
this.audioOctetCount = (this.audioOctetCount + opusPayload.length) >>> 0;
|
|
1564
|
+
const rtpPacket = buildRtpPacket({
|
|
1565
|
+
payloadType: options.payloadType ?? 112,
|
|
1566
|
+
sequenceNumber: seq,
|
|
1567
|
+
timestamp: ts,
|
|
1568
|
+
ssrc,
|
|
1569
|
+
marker: options.marker,
|
|
1570
|
+
payload: opusPayload,
|
|
1571
|
+
});
|
|
1572
|
+
await this.sendAudioRtp(rtpPacket);
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Gửi một gói tin chuẩn WebRTC RTP (Audio) qua đường truyền UDP Zalo.
|
|
1576
|
+
*/
|
|
1577
|
+
async sendAudioRtp(rtpPacket) {
|
|
1578
|
+
const protectedRtp = this.srtpSession ? this.srtpSession.protectRtp(rtpPacket) : rtpPacket;
|
|
1579
|
+
const token = (this.routingToken || this.callId) >>> 0;
|
|
1580
|
+
const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
|
|
1581
|
+
const framed = this.options.packetMode === 1
|
|
1582
|
+
? wrapLegacyMediaPacket(ZRTPLegacyMediaType.AudioRtp, token, protectedRtp)
|
|
1583
|
+
: wrapZrtcMediaPacket(ZRTCMediaSubCommand.AudioRtp, token, protectedRtp, role);
|
|
1584
|
+
await this.sendRawUdp(framed, this.rtpEndpoint.host, this.rtpEndpoint.port);
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Gửi trực tiếp một khung hình ảnh H.264 NAL / RTP payload (mặc định 90kHz clock, payloadType=97)
|
|
1588
|
+
* qua đường truyền UDP của cuộc gọi Video.
|
|
1589
|
+
*/
|
|
1590
|
+
async sendVideoFrame(h264Payload, options = {}) {
|
|
1591
|
+
if (!options.isKeepalive) {
|
|
1592
|
+
this.lastExternalVideoSentAtMs = Date.now();
|
|
1593
|
+
}
|
|
1594
|
+
const seq = options.sequenceNumber ?? (this.videoSeq = (this.videoSeq + 1) & 0xffff);
|
|
1595
|
+
const step = options.timestampStep ?? 3750; // ~24fps @ 90kHz
|
|
1596
|
+
const ts = options.timestamp ?? (this.videoTimestamp = (this.videoTimestamp + step) >>> 0);
|
|
1597
|
+
const ssrc = options.ssrc ?? this.getLocalVideoSsrc();
|
|
1598
|
+
this.videoPacketCount = (this.videoPacketCount + 1) >>> 0;
|
|
1599
|
+
this.videoOctetCount = (this.videoOctetCount + h264Payload.length) >>> 0;
|
|
1600
|
+
const rtpPacket = buildRtpPacket({
|
|
1601
|
+
payloadType: options.payloadType ?? 97,
|
|
1602
|
+
sequenceNumber: seq,
|
|
1603
|
+
timestamp: ts,
|
|
1604
|
+
ssrc,
|
|
1605
|
+
marker: options.marker ?? true,
|
|
1606
|
+
payload: h264Payload,
|
|
1607
|
+
});
|
|
1608
|
+
await this.sendVideoRtp(rtpPacket);
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Đóng gói một khung hình H.264 dạng Annex-B (`00 00 00 01 <NAL>`) theo chuẩn RFC 6184:
|
|
1612
|
+
* - Tự động tách các NAL unit, lưu cache SPS (`type=7`) / PPS (`type=8`) và đóng gói STAP-A (`0x78`) trước IDR (`type=5`)
|
|
1613
|
+
* - Không cho phép khung keepalive 16x16 ghi đè `localSpsNal` / `localPpsNal` của camera thực
|
|
1614
|
+
* - Tự động phân mảnh các NAL lớn (> 1150 bytes) thành các gói RTP **FU-A (`type=28`)** cùng `timestamp` 90kHz
|
|
1615
|
+
* - Đặt cờ `marker = true` ở gói RTP cuối cùng của khung hình để bộ giải mã đối phương hiển thị ngay.
|
|
1616
|
+
*/
|
|
1617
|
+
async sendH264AnnexBFrame(annexB, options = {}) {
|
|
1618
|
+
const rawNals = splitAnnexBNals(annexB).filter((n) => n.length > 0);
|
|
1619
|
+
if (rawNals.length === 0)
|
|
1620
|
+
return;
|
|
1621
|
+
if (!options.isKeepalive) {
|
|
1622
|
+
this.lastExternalVideoSentAtMs = Date.now();
|
|
1623
|
+
}
|
|
1624
|
+
let inlineSps = null;
|
|
1625
|
+
let inlinePps = null;
|
|
1626
|
+
let hasIdr = false;
|
|
1627
|
+
const vclAndSeiNals = [];
|
|
1628
|
+
for (const nal of rawNals) {
|
|
1629
|
+
const nalType = nal[0] & 0x1f;
|
|
1630
|
+
if (nalType === 7) {
|
|
1631
|
+
inlineSps = Buffer.from(nal);
|
|
1632
|
+
if (!options.isKeepalive || !this.localSpsNal) {
|
|
1633
|
+
this.localSpsNal = inlineSps;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
else if (nalType === 8) {
|
|
1637
|
+
inlinePps = Buffer.from(nal);
|
|
1638
|
+
if (!options.isKeepalive || !this.localPpsNal) {
|
|
1639
|
+
this.localPpsNal = inlinePps;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
else if (nalType === 9) {
|
|
1643
|
+
// Bỏ qua Access Unit Delimiter (AUD, type=9) để tương thích phần cứng VideoToolbox iOS
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
else {
|
|
1647
|
+
if (nalType === 5)
|
|
1648
|
+
hasIdr = true;
|
|
1649
|
+
vclAndSeiNals.push(nal);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
const spsToUse = inlineSps ?? (hasIdr ? this.localSpsNal : null);
|
|
1653
|
+
const ppsToUse = inlinePps ?? (hasIdr ? this.localPpsNal : null);
|
|
1654
|
+
const step = options.timestampStep ?? 6000; // ~15fps @ 90kHz
|
|
1655
|
+
const frameTs = options.timestamp ?? (this.videoTimestamp = (this.videoTimestamp + step) >>> 0);
|
|
1656
|
+
const maxPayloadSize = 1150;
|
|
1657
|
+
const rtpPayloads = [];
|
|
1658
|
+
// Gộp SPS + PPS vào một gói RFC 6184 STAP-A (type=24, 0x78) duy nhất trước IDR
|
|
1659
|
+
// khớp với RtpPacketizerH264::PacketizeStapA (0x1003db010)
|
|
1660
|
+
if (spsToUse && ppsToUse && spsToUse.length + ppsToUse.length + 5 <= maxPayloadSize) {
|
|
1661
|
+
const stapA = Buffer.allocUnsafe(1 + 2 + spsToUse.length + 2 + ppsToUse.length);
|
|
1662
|
+
stapA[0] = 0x78; // F=0, NRI=3 (0x60), Type=24 (STAP-A)
|
|
1663
|
+
stapA.writeUInt16BE(spsToUse.length, 1);
|
|
1664
|
+
spsToUse.copy(stapA, 3);
|
|
1665
|
+
const ppsOffset = 3 + spsToUse.length;
|
|
1666
|
+
stapA.writeUInt16BE(ppsToUse.length, ppsOffset);
|
|
1667
|
+
ppsToUse.copy(stapA, ppsOffset + 2);
|
|
1668
|
+
rtpPayloads.push(stapA);
|
|
1669
|
+
}
|
|
1670
|
+
else {
|
|
1671
|
+
if (spsToUse)
|
|
1672
|
+
rtpPayloads.push(spsToUse);
|
|
1673
|
+
if (ppsToUse)
|
|
1674
|
+
rtpPayloads.push(ppsToUse);
|
|
1675
|
+
}
|
|
1676
|
+
for (const nal of vclAndSeiNals) {
|
|
1677
|
+
if (nal.length <= maxPayloadSize) {
|
|
1678
|
+
rtpPayloads.push(nal);
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
// RFC 6184 FU-A (type = 28) fragmentation
|
|
1682
|
+
const nalHeader = nal[0];
|
|
1683
|
+
const fnri = nalHeader & 0xe0;
|
|
1684
|
+
const nalType = nalHeader & 0x1f;
|
|
1685
|
+
const fuIndicator = fnri | 28;
|
|
1686
|
+
const body = nal.subarray(1);
|
|
1687
|
+
let offset = 0;
|
|
1688
|
+
while (offset < body.length) {
|
|
1689
|
+
const chunkLen = Math.min(maxPayloadSize, body.length - offset);
|
|
1690
|
+
const isFirst = offset === 0;
|
|
1691
|
+
const isLast = offset + chunkLen >= body.length;
|
|
1692
|
+
const fuHeader = (isFirst ? 0x80 : 0) | (isLast ? 0x40 : 0) | nalType;
|
|
1693
|
+
const frag = Buffer.allocUnsafe(2 + chunkLen);
|
|
1694
|
+
frag[0] = fuIndicator;
|
|
1695
|
+
frag[1] = fuHeader;
|
|
1696
|
+
body.copy(frag, 2, offset, offset + chunkLen);
|
|
1697
|
+
rtpPayloads.push(frag);
|
|
1698
|
+
offset += chunkLen;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
// Trong RTPSender::SendOutgoingData (0x1003ed76c) & RTPReceiverVideo::ParseRtpPacket (0x1003e8b4c):
|
|
1703
|
+
// Zalo quy định:
|
|
1704
|
+
// - Keyframe H.264 (SPS+PPS+IDR, frameType=3) BẮT BUỘC gửi trên payloadType = 98 (0x62)
|
|
1705
|
+
// - Delta frame H.264 (P-slice, frameType=4) gửi trên payloadType = 97 (0x61)
|
|
1706
|
+
const resolvedPayloadType = options.payloadType ?? (hasIdr ? 98 : 97);
|
|
1707
|
+
for (let i = 0; i < rtpPayloads.length; i++) {
|
|
1708
|
+
const isLastPacket = i === rtpPayloads.length - 1;
|
|
1709
|
+
await this.sendVideoFrame(rtpPayloads[i], {
|
|
1710
|
+
payloadType: resolvedPayloadType,
|
|
1711
|
+
timestamp: frameTs,
|
|
1712
|
+
ssrc: options.ssrc ?? this.getLocalVideoSsrc(),
|
|
1713
|
+
marker: isLastPacket,
|
|
1714
|
+
isKeepalive: options.isKeepalive,
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Gửi tín hiệu bật/tắt Camera (`cmd = 32, subCmd = 7` `OnCamera` / `subCmd = 8` `OffCamera`)
|
|
1720
|
+
* khớp với `CallController::vidSendSignalOffCamera` (`0x10016fbe4`).
|
|
1721
|
+
* Phát lặp 3 lần (0ms, 60ms, 150ms) để chống mất gói UDP.
|
|
1722
|
+
*/
|
|
1723
|
+
async sendCameraState(isCameraOn, reason = 0) {
|
|
1724
|
+
if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
|
|
1725
|
+
return;
|
|
1726
|
+
const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
|
|
1727
|
+
const token = (this.routingToken || this.callId) >>> 0;
|
|
1728
|
+
const payload = Buffer.alloc(4);
|
|
1729
|
+
payload.writeInt32LE(reason | 0, 0);
|
|
1730
|
+
const pkt = buildRequestForwardPacket({
|
|
1731
|
+
seqNum: 0,
|
|
1732
|
+
seqId: 0,
|
|
1733
|
+
fromUid,
|
|
1734
|
+
routingToken: token,
|
|
1735
|
+
subCmd: isCameraOn ? ZRTPForwardSubCommand.OnCamera : ZRTPForwardSubCommand.OffCamera,
|
|
1736
|
+
payload,
|
|
1737
|
+
});
|
|
1738
|
+
await this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port);
|
|
1739
|
+
for (const delayMs of [60, 150]) {
|
|
1740
|
+
const t = setTimeout(() => {
|
|
1741
|
+
if (this.udpSocket && this.rtpEndpoint.port > 0 && this.state !== "ended") {
|
|
1742
|
+
void this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
1743
|
+
}
|
|
1744
|
+
}, delayMs);
|
|
1745
|
+
if (typeof t.unref === "function")
|
|
1746
|
+
t.unref();
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Gửi tín hiệu bật/tắt Microphone (`cmd = 32, subCmd = 5` `OnMic` / `subCmd = 6` `OffMic`)
|
|
1751
|
+
* qua máy chủ Relay UDP.
|
|
1752
|
+
*/
|
|
1753
|
+
async sendMicState(isMicOn) {
|
|
1754
|
+
if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
|
|
1755
|
+
return;
|
|
1756
|
+
const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
|
|
1757
|
+
const token = (this.routingToken || this.callId) >>> 0;
|
|
1758
|
+
const payload = Buffer.alloc(4);
|
|
1759
|
+
payload.writeInt32LE(0, 0);
|
|
1760
|
+
const pkt = buildRequestForwardPacket({
|
|
1761
|
+
seqNum: 0,
|
|
1762
|
+
seqId: 0,
|
|
1763
|
+
fromUid,
|
|
1764
|
+
routingToken: token,
|
|
1765
|
+
subCmd: isMicOn ? ZRTPForwardSubCommand.OnMic : ZRTPForwardSubCommand.OffMic,
|
|
1766
|
+
payload,
|
|
1767
|
+
});
|
|
1768
|
+
await this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port);
|
|
1769
|
+
for (const delayMs of [60, 150]) {
|
|
1770
|
+
const t = setTimeout(() => {
|
|
1771
|
+
if (this.udpSocket && this.rtpEndpoint.port > 0 && this.state !== "ended") {
|
|
1772
|
+
void this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
1773
|
+
}
|
|
1774
|
+
}, delayMs);
|
|
1775
|
+
if (typeof t.unref === "function")
|
|
1776
|
+
t.unref();
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Gửi gói RTCP PLI (Picture Loss Indication — `PT=206, FMT=1`) yêu cầu máy đối phương
|
|
1781
|
+
* gửi ngay khung hình khoá H.264 IDR kèm SPS/PPS.
|
|
1782
|
+
*/
|
|
1783
|
+
async sendRtcpPli() {
|
|
1784
|
+
if (!this.remoteVideoSsrc)
|
|
1785
|
+
return;
|
|
1786
|
+
const pli = Buffer.alloc(12);
|
|
1787
|
+
pli.writeUInt8(0x81, 0); // V=2, FMT=1 (PLI)
|
|
1788
|
+
pli.writeUInt8(206, 1); // PT=206 (PSFB)
|
|
1789
|
+
pli.writeUInt16BE(2, 2); // length = 2 words (12 bytes)
|
|
1790
|
+
pli.writeUInt32BE(this.getLocalVideoSsrc(), 4);
|
|
1791
|
+
pli.writeUInt32BE(this.remoteVideoSsrc >>> 0, 8);
|
|
1792
|
+
await this.sendRtcp(pli, true);
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Gửi một gói tin chuẩn WebRTC RTP (Video H.264) qua đường truyền UDP Zalo.
|
|
1796
|
+
*/
|
|
1797
|
+
async sendVideoRtp(rtpPacket) {
|
|
1798
|
+
const protectedRtp = this.srtpSession ? this.srtpSession.protectRtp(rtpPacket) : rtpPacket;
|
|
1799
|
+
const token = (this.routingToken || this.callId) >>> 0;
|
|
1800
|
+
const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
|
|
1801
|
+
const framed = this.options.packetMode === 1
|
|
1802
|
+
? wrapLegacyMediaPacket(ZRTPLegacyMediaType.VideoRtp, token, protectedRtp)
|
|
1803
|
+
: wrapZrtcMediaPacket(ZRTCMediaSubCommand.VideoRtp, token, protectedRtp, role);
|
|
1804
|
+
await this.sendRawUdp(framed, this.rtpEndpoint.host, this.rtpEndpoint.port);
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Gửi gói tin điều khiển chất lượng RTCP (Audio hoặc Video) qua đường truyền UDP Zalo.
|
|
1808
|
+
*/
|
|
1809
|
+
async sendRtcp(rtcpPacket, isVideo = false) {
|
|
1810
|
+
const protectedRtcp = this.srtpSession && Number(this.extendData?.srtcp) === 1
|
|
1811
|
+
? this.srtpSession.protectRtcp(rtcpPacket)
|
|
1812
|
+
: rtcpPacket;
|
|
1813
|
+
const token = (this.routingToken || this.callId) >>> 0;
|
|
1814
|
+
const role = this.isCaller ? ZRTCRole.Caller : ZRTCRole.Callee;
|
|
1815
|
+
const framed = this.options.packetMode === 1
|
|
1816
|
+
? wrapLegacyMediaPacket(isVideo ? ZRTPLegacyMediaType.VideoRtcp : ZRTPLegacyMediaType.AudioRtcp, token, protectedRtcp)
|
|
1817
|
+
: wrapZrtcMediaPacket(isVideo ? ZRTCMediaSubCommand.VideoRtcp : ZRTCMediaSubCommand.AudioRtcp, token, protectedRtcp, role);
|
|
1818
|
+
const target = this.rtcpEndpoint.port > 0 ? this.rtcpEndpoint : this.rtpEndpoint;
|
|
1819
|
+
await this.sendRawUdp(framed, target.host, target.port);
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Kết thúc cuộc gọi hiện tại:
|
|
1823
|
+
* 1. Gửi gói UDP `RequestEndCall` (`0x01 0x7e`, `cmd=3`, 25 bytes) 3 lần tới máy chủ Relay (`_sendRequestZRTPEndCall` @ `0x10016a880`)
|
|
1824
|
+
* 2. Giải phóng toàn bộ tài nguyên UDP socket & bộ định thời
|
|
1825
|
+
* 3. Gửi tín hiệu HTTP `/api/voicecall/cancel` (`11305`) và `/api/voicecall/endcall` (`11306`) hoặc `/api/voicecall/group/end` (`12046`)
|
|
1826
|
+
* cho mọi thao tác ngắt máy từ phía local (`local_end`, `user_hangup`, `new_call`, ...).
|
|
1827
|
+
*/
|
|
1828
|
+
async endCall(reason = "local_end") {
|
|
1829
|
+
if (this.state === "ended")
|
|
1830
|
+
return;
|
|
1831
|
+
const prevState = this.state;
|
|
1832
|
+
this.setState("ended");
|
|
1833
|
+
if (this.udpSocket && this.rtpEndpoint.port > 0 && this.callId > 0) {
|
|
1834
|
+
try {
|
|
1835
|
+
const endPkt = buildEndCallRequest({
|
|
1836
|
+
seqNum: 0,
|
|
1837
|
+
callId: this.callId,
|
|
1838
|
+
fromUid: this.fromUid32 || uidToUint32(this.api.getContext().uid),
|
|
1839
|
+
toUid: this.toUid32 || uidToUint32(this.partnerId),
|
|
1840
|
+
routingToken: (this.routingToken || this.callId) >>> 0,
|
|
1841
|
+
flags: 0,
|
|
1842
|
+
});
|
|
1843
|
+
for (let i = 0; i < 3; i++) {
|
|
1844
|
+
await this.sendRawUdp(endPkt, this.rtpEndpoint.host, this.rtpEndpoint.port);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
catch {
|
|
1848
|
+
// Ignore UDP send error on teardown
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
this.cleanupResources();
|
|
1852
|
+
const isRemoteReason = reason === "remote_end" ||
|
|
1853
|
+
reason === "remote_cancel" ||
|
|
1854
|
+
reason === "end" ||
|
|
1855
|
+
reason === "cancel" ||
|
|
1856
|
+
reason === "rejected" ||
|
|
1857
|
+
reason === "busy";
|
|
1858
|
+
if (this.callId > 0 && !isRemoteReason) {
|
|
1859
|
+
try {
|
|
1860
|
+
if (this.groupId ||
|
|
1861
|
+
this.callType === CallType.GroupAudio ||
|
|
1862
|
+
this.callType === CallType.GroupVideo) {
|
|
1863
|
+
if (prevState === "ringing" && !this.hasReceivedPartnerMedia && this.isCaller) {
|
|
1864
|
+
await this.api.call
|
|
1865
|
+
.cancelGroupCall(this.callId, {
|
|
1866
|
+
hostCall: this.groupId || this.partnerId,
|
|
1867
|
+
callType: this.callType,
|
|
1868
|
+
partners: this.groupPartners,
|
|
1869
|
+
})
|
|
1870
|
+
.catch(() => { });
|
|
1871
|
+
}
|
|
1872
|
+
await this.api.call
|
|
1873
|
+
.endGroupCall(this.callId, {
|
|
1874
|
+
hostCall: this.groupId || this.partnerId,
|
|
1875
|
+
callType: this.callType,
|
|
1876
|
+
partners: this.groupPartners,
|
|
1877
|
+
})
|
|
1878
|
+
.catch(() => { });
|
|
1879
|
+
}
|
|
1880
|
+
else if (this.partnerId) {
|
|
1881
|
+
const wasStillRinging = (prevState === "ringing" ||
|
|
1882
|
+
prevState === "handshaking_udp" ||
|
|
1883
|
+
prevState === "allocating") &&
|
|
1884
|
+
!this.hasReceivedPartnerMedia;
|
|
1885
|
+
if (wasStillRinging && this.isCaller) {
|
|
1886
|
+
await this.api.call
|
|
1887
|
+
.cancelVoiceCall(this.partnerId, this.callId, {
|
|
1888
|
+
callType: this.callType,
|
|
1889
|
+
})
|
|
1890
|
+
.catch(() => { });
|
|
1891
|
+
// Dự phòng trường hợp máy đối phương vừa bắt máy nhưng sự kiện WS chưa kịp về:
|
|
1892
|
+
await this.api.call.endVoiceCall(this.partnerId, this.callId).catch(() => { });
|
|
1893
|
+
}
|
|
1894
|
+
else {
|
|
1895
|
+
await this.api.call.endVoiceCall(this.partnerId, this.callId).catch(() => { });
|
|
1896
|
+
}
|
|
1897
|
+
if (this.activeStartedAtMs > 0) {
|
|
1898
|
+
const durationSec = Math.max(1, Math.floor((Date.now() - this.activeStartedAtMs) / 1000));
|
|
1899
|
+
void this.api.call
|
|
1900
|
+
.logEndVoiceCall(this.partnerId, this.callId, {
|
|
1901
|
+
duration: durationSec,
|
|
1902
|
+
status: 0,
|
|
1903
|
+
role: this.isCaller,
|
|
1904
|
+
})
|
|
1905
|
+
.catch(() => { });
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
// Ignore signaling error if already ended
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
this.emit("ended", reason);
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Xử lý sự kiện `call_event` từ WebSocket (hoặc truyền thủ công).
|
|
1917
|
+
*/
|
|
1918
|
+
async handleCallEvent(event) {
|
|
1919
|
+
const rawParams = event.data.params !== undefined
|
|
1920
|
+
? safeJsonParseRecord(event.data.params)
|
|
1921
|
+
: event.data.data !== undefined
|
|
1922
|
+
? safeJsonParseRecord(event.data.data)
|
|
1923
|
+
: event.data;
|
|
1924
|
+
const eventCallId = Number(event.data.callId ??
|
|
1925
|
+
event.data.call_id ??
|
|
1926
|
+
event.data.id ??
|
|
1927
|
+
rawParams.callId ??
|
|
1928
|
+
rawParams.call_id ??
|
|
1929
|
+
rawParams.id ??
|
|
1930
|
+
0);
|
|
1931
|
+
if (this.callId > 0 && eventCallId > 0 && eventCallId !== this.callId) {
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
this.emit("call_event", event);
|
|
1935
|
+
if (event.type === CallEventType.REQUEST ||
|
|
1936
|
+
event.type === CallEventType.GROUP_REQUEST ||
|
|
1937
|
+
event.act === "request" ||
|
|
1938
|
+
event.act === "group_request") {
|
|
1939
|
+
const incomingInfo = parseIncomingCallEvent(event, this.api.getContext().uid);
|
|
1940
|
+
this.emit("incoming_call", incomingInfo);
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
const extendRaw = rawParams.extendData !== undefined
|
|
1944
|
+
? safeJsonParseRecord(rawParams.extendData)
|
|
1945
|
+
: event.data.extendData !== undefined
|
|
1946
|
+
? safeJsonParseRecord(event.data.extendData)
|
|
1947
|
+
: {};
|
|
1948
|
+
if (typeof extendRaw.srtpKey === "string" && extendRaw.srtpKey.length >= 40) {
|
|
1949
|
+
this.srtpSession = new ZaloSrtpSession(extendRaw.srtpKey, this.options.srtpSuite);
|
|
1950
|
+
}
|
|
1951
|
+
else if (extendRaw.srtpMode === 0 && !this.options.enableSrtp) {
|
|
1952
|
+
this.srtpSession = null;
|
|
1953
|
+
}
|
|
1954
|
+
if (event.type === CallEventType.RINGRING) {
|
|
1955
|
+
if (this.state === "handshaking_udp" || this.state === "allocating") {
|
|
1956
|
+
this.setState("ringing");
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
else if (event.type === CallEventType.ANSWER) {
|
|
1960
|
+
this.hasReceivedPartnerMedia = true;
|
|
1961
|
+
if (this.isCaller && this.partnerId && this.callId > 0 && !this.groupId && !this.hasSentAnswerAck) {
|
|
1962
|
+
this.hasSentAnswerAck = true;
|
|
1963
|
+
try {
|
|
1964
|
+
await this.api.call.answerAckVoiceCall(this.partnerId, this.callId);
|
|
1965
|
+
}
|
|
1966
|
+
catch {
|
|
1967
|
+
// Ignore ack failure
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
this.setState("active");
|
|
1971
|
+
if (this.callType === CallType.Video ||
|
|
1972
|
+
this.callType === CallType.GroupVideo ||
|
|
1973
|
+
Number(this.extendData?.callType) === 1) {
|
|
1974
|
+
void this.sendCameraState(this.isCameraOn).catch(() => { });
|
|
1975
|
+
if (!this.isCameraOn && this.partnerId && this.callId > 0 && !this.groupId) {
|
|
1976
|
+
void this.api.call
|
|
1977
|
+
.updateCallMediaState(this.partnerId, this.callId, {
|
|
1978
|
+
isOnCam: 0,
|
|
1979
|
+
isOnMic: -1,
|
|
1980
|
+
data: "",
|
|
1981
|
+
})
|
|
1982
|
+
.catch(() => { });
|
|
1983
|
+
}
|
|
1984
|
+
void this.sendRtcpPli().catch(() => { });
|
|
1985
|
+
}
|
|
1986
|
+
const params = this.getConnectionParams();
|
|
1987
|
+
this.emit("connection_params", params);
|
|
1988
|
+
this.emit("active", params);
|
|
1989
|
+
}
|
|
1990
|
+
else if (event.type === CallEventType.CANCEL || event.type === CallEventType.END) {
|
|
1991
|
+
this.setState("ended");
|
|
1992
|
+
this.cleanupResources();
|
|
1993
|
+
this.emit("ended", event.act);
|
|
1994
|
+
}
|
|
1995
|
+
else if (event.act === "onoff_camera") {
|
|
1996
|
+
const camType = Number(rawParams.type ?? 0);
|
|
1997
|
+
const reason = Number(rawParams.reason ?? 0);
|
|
1998
|
+
if (camType === 1 || camType === 2) {
|
|
1999
|
+
this.clearIncomingVideoAssemblyBuffers();
|
|
2000
|
+
}
|
|
2001
|
+
// Khi đối phương gửi onoff_camera (đặc biệt reason=2/3 báo video loss hoặc bật lại camera), phản hồi ngay trạng thái camera và PLI
|
|
2002
|
+
void this.sendCameraState(this.isCameraOn).catch(() => { });
|
|
2003
|
+
void this.sendRtcpPli().catch(() => { });
|
|
2004
|
+
if (reason === 2 || reason === 3) {
|
|
2005
|
+
this.emit("media_state_change", {
|
|
2006
|
+
isLocal: false,
|
|
2007
|
+
rawAct: event.act,
|
|
2008
|
+
reason,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
else {
|
|
2012
|
+
this.emit("media_state_change", {
|
|
2013
|
+
isLocal: false,
|
|
2014
|
+
isCameraOn: camType === 1 || camType === 2,
|
|
2015
|
+
rawAct: event.act,
|
|
2016
|
+
reason,
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
else if (event.act === "audio_to_video" ||
|
|
2021
|
+
event.act === "requestaudio2video" ||
|
|
2022
|
+
Number(event.data.subCmd) === 12056) {
|
|
2023
|
+
const reqStatus = Number(rawParams.status ?? event.data.status ?? 1);
|
|
2024
|
+
this.callType = this.groupId ? CallType.GroupVideo : CallType.Video;
|
|
2025
|
+
this.isCameraOn = true;
|
|
2026
|
+
this.clearIncomingVideoAssemblyBuffers();
|
|
2027
|
+
this.startMediaKeepaliveLoop();
|
|
2028
|
+
if (reqStatus === 1 && this.partnerId && this.callId > 0) {
|
|
2029
|
+
void this.api.call
|
|
2030
|
+
.requestAudioToVideoCall(this.partnerId, this.callId, { status: 2 })
|
|
2031
|
+
.catch(() => { });
|
|
2032
|
+
}
|
|
2033
|
+
void this.sendCameraState(true).catch(() => { });
|
|
2034
|
+
void this.sendRtcpPli().catch(() => { });
|
|
2035
|
+
this.emit("media_state_change", {
|
|
2036
|
+
isLocal: false,
|
|
2037
|
+
callType: this.callType,
|
|
2038
|
+
isCameraOn: true,
|
|
2039
|
+
rawAct: event.act,
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
else if (event.act === "holdreq") {
|
|
2043
|
+
if (this.partnerId) {
|
|
2044
|
+
void this.api.call.holdVoiceCall(this.partnerId, { isResponse: true }).catch(() => { });
|
|
2045
|
+
}
|
|
2046
|
+
this.emit("media_state_change", {
|
|
2047
|
+
isLocal: false,
|
|
2048
|
+
isHold: true,
|
|
2049
|
+
rawAct: event.act,
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
else if (event.act === "unholdreq") {
|
|
2053
|
+
if (this.partnerId) {
|
|
2054
|
+
void this.api.call
|
|
2055
|
+
.unholdVoiceCall(this.partnerId, { isResponse: true })
|
|
2056
|
+
.catch(() => { });
|
|
2057
|
+
}
|
|
2058
|
+
this.clearIncomingVideoAssemblyBuffers();
|
|
2059
|
+
void this.sendMicState(!this.isMicMuted).catch(() => { });
|
|
2060
|
+
void this.sendCameraState(this.isCameraOn).catch(() => { });
|
|
2061
|
+
void this.sendRtcpPli().catch(() => { });
|
|
2062
|
+
this.emit("media_state_change", {
|
|
2063
|
+
isLocal: false,
|
|
2064
|
+
isHold: false,
|
|
2065
|
+
rawAct: event.act,
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
else if (event.act === "requestsharescreen" ||
|
|
2069
|
+
event.act === "sharescreen" ||
|
|
2070
|
+
event.act === "group_sharescreen") {
|
|
2071
|
+
const shareStatus = Number(rawParams.status ?? event.data.status ?? 1);
|
|
2072
|
+
this.emit("media_state_change", {
|
|
2073
|
+
isLocal: false,
|
|
2074
|
+
isScreenSharing: shareStatus === 1,
|
|
2075
|
+
rawAct: event.act,
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
else if (event.act === "conf" || event.act === "mute_audio") {
|
|
2079
|
+
this.emit("media_state_change", {
|
|
2080
|
+
isLocal: false,
|
|
2081
|
+
isCameraOn: rawParams.isOnCam !== undefined && Number(rawParams.isOnCam) !== -1
|
|
2082
|
+
? Boolean(Number(rawParams.isOnCam))
|
|
2083
|
+
: undefined,
|
|
2084
|
+
isMicMuted: rawParams.isOnMic !== undefined && Number(rawParams.isOnMic) !== -1
|
|
2085
|
+
? !Boolean(Number(rawParams.isOnMic))
|
|
2086
|
+
: undefined,
|
|
2087
|
+
rawAct: event.act,
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
setState(nextState) {
|
|
2092
|
+
if (this.state !== nextState) {
|
|
2093
|
+
if (nextState === "active" && !this.activeStartedAtMs) {
|
|
2094
|
+
this.activeStartedAtMs = Date.now();
|
|
2095
|
+
}
|
|
2096
|
+
if (nextState === "ringing" && this.options.ringingTimeoutMs > 0) {
|
|
2097
|
+
if (this.ringingTimeoutTimer)
|
|
2098
|
+
clearTimeout(this.ringingTimeoutTimer);
|
|
2099
|
+
this.ringingTimeoutTimer = setTimeout(() => {
|
|
2100
|
+
if (this.state === "ringing") {
|
|
2101
|
+
void this.endCall("ringing_timeout");
|
|
2102
|
+
}
|
|
2103
|
+
}, this.options.ringingTimeoutMs);
|
|
2104
|
+
if (typeof this.ringingTimeoutTimer.unref === "function") {
|
|
2105
|
+
this.ringingTimeoutTimer.unref();
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
else if (nextState !== "ringing" && this.ringingTimeoutTimer) {
|
|
2109
|
+
clearTimeout(this.ringingTimeoutTimer);
|
|
2110
|
+
this.ringingTimeoutTimer = null;
|
|
2111
|
+
}
|
|
2112
|
+
this.state = nextState;
|
|
2113
|
+
this.emit("state", nextState);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
applyServerInfo(serverInfo) {
|
|
2117
|
+
this.serverInfo = serverInfo;
|
|
2118
|
+
const rtpServer = serverInfo?.rtpServer ?? serverInfo?.rtpIP ?? serverInfo?.servers?.[0]?.rtpaddr ?? "";
|
|
2119
|
+
const rtcpServer = serverInfo?.rtcpServer ??
|
|
2120
|
+
serverInfo?.rtcpIP ??
|
|
2121
|
+
serverInfo?.servers?.[0]?.rtcpaddr ??
|
|
2122
|
+
rtpServer;
|
|
2123
|
+
this.rtpEndpoint = parseEndpoint(rtpServer);
|
|
2124
|
+
this.rtcpEndpoint = parseEndpoint(rtcpServer || rtpServer);
|
|
2125
|
+
this.sessId = serverInfo?.sessId ?? `${this.partnerId}_${this.callId}_401`;
|
|
2126
|
+
this.zrtpKey = serverInfo?.zrtpKey;
|
|
2127
|
+
this.fromUid32 = Number(serverInfo?.fromId) || uidToUint32(this.api.getContext().uid);
|
|
2128
|
+
this.toUid32 = Number(serverInfo?.toId) || uidToUint32(this.partnerId);
|
|
2129
|
+
if (serverInfo?.codec && serverInfo.codec !== "[]") {
|
|
2130
|
+
this.codec = serverInfo.codec;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
async ensureUdpSocket() {
|
|
2134
|
+
if (this.udpSocket)
|
|
2135
|
+
return this.udpSocket;
|
|
2136
|
+
const sock = dgram.createSocket("udp4");
|
|
2137
|
+
this.udpSocket = sock;
|
|
2138
|
+
sock.on("message", (msg) => {
|
|
2139
|
+
this.handleIncomingUdpPacket(msg);
|
|
2140
|
+
});
|
|
2141
|
+
sock.on("error", (err) => {
|
|
2142
|
+
this.emit("error", err);
|
|
2143
|
+
});
|
|
2144
|
+
await new Promise((resolve, reject) => {
|
|
2145
|
+
const onError = (err) => {
|
|
2146
|
+
sock.removeListener("listening", onListening);
|
|
2147
|
+
reject(err);
|
|
2148
|
+
};
|
|
2149
|
+
const onListening = () => {
|
|
2150
|
+
sock.removeListener("error", onError);
|
|
2151
|
+
this.localUdpPort = sock.address().port;
|
|
2152
|
+
resolve();
|
|
2153
|
+
};
|
|
2154
|
+
sock.once("error", onError);
|
|
2155
|
+
sock.once("listening", onListening);
|
|
2156
|
+
sock.bind(this.options.localUdpPort);
|
|
2157
|
+
});
|
|
2158
|
+
return sock;
|
|
2159
|
+
}
|
|
2160
|
+
async performUdpInitHandshake(isCaller) {
|
|
2161
|
+
const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
|
|
2162
|
+
const toUid = this.toUid32 || uidToUint32(this.partnerId);
|
|
2163
|
+
const initPacket = buildInitCallRequest({
|
|
2164
|
+
isCaller,
|
|
2165
|
+
seqNum: 0,
|
|
2166
|
+
headerCallId: this.callId,
|
|
2167
|
+
callId: this.callId,
|
|
2168
|
+
fromUid,
|
|
2169
|
+
toUid,
|
|
2170
|
+
flags: 1,
|
|
2171
|
+
subFlags: toUid,
|
|
2172
|
+
sessId: this.sessId,
|
|
2173
|
+
});
|
|
2174
|
+
const timeoutMs = this.options.udpHandshakeTimeoutMs;
|
|
2175
|
+
await new Promise((resolve) => {
|
|
2176
|
+
let settled = false;
|
|
2177
|
+
const retryInterval = setInterval(() => {
|
|
2178
|
+
if (!settled && this.udpSocket) {
|
|
2179
|
+
void this.sendRawUdp(initPacket, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2180
|
+
}
|
|
2181
|
+
}, 350);
|
|
2182
|
+
const timer = setTimeout(() => {
|
|
2183
|
+
if (settled)
|
|
2184
|
+
return;
|
|
2185
|
+
settled = true;
|
|
2186
|
+
clearInterval(retryInterval);
|
|
2187
|
+
this.removeListener("zrtp_control", onControl);
|
|
2188
|
+
if (!this.routingToken) {
|
|
2189
|
+
this.routingToken = this.callId >>> 0;
|
|
2190
|
+
}
|
|
2191
|
+
resolve();
|
|
2192
|
+
}, timeoutMs);
|
|
2193
|
+
const onControl = (pkt) => {
|
|
2194
|
+
if (pkt.isResponse &&
|
|
2195
|
+
(pkt.cmd === ZRTPControlCommand.InitCallCaller ||
|
|
2196
|
+
pkt.cmd === ZRTPControlCommand.InitCallCallee) &&
|
|
2197
|
+
pkt.errorCode === 0 &&
|
|
2198
|
+
pkt.routingToken) {
|
|
2199
|
+
if (settled)
|
|
2200
|
+
return;
|
|
2201
|
+
settled = true;
|
|
2202
|
+
clearTimeout(timer);
|
|
2203
|
+
clearInterval(retryInterval);
|
|
2204
|
+
this.removeListener("zrtp_control", onControl);
|
|
2205
|
+
this.routingToken = pkt.routingToken >>> 0;
|
|
2206
|
+
this.udpHandshakeVerified = true;
|
|
2207
|
+
resolve();
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
this.on("zrtp_control", onControl);
|
|
2211
|
+
void this.sendRawUdp(initPacket, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
startUdpPingLoop() {
|
|
2215
|
+
if (this.pingTimer)
|
|
2216
|
+
clearInterval(this.pingTimer);
|
|
2217
|
+
this.startedAtMs = Date.now();
|
|
2218
|
+
const sendPing = () => {
|
|
2219
|
+
if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
|
|
2220
|
+
return;
|
|
2221
|
+
const elapsed = Math.max(1, Date.now() - this.startedAtMs);
|
|
2222
|
+
const fromUid = this.fromUid32 || uidToUint32(this.api.getContext().uid);
|
|
2223
|
+
const toUid = this.toUid32 || uidToUint32(this.partnerId);
|
|
2224
|
+
const token = (this.routingToken || this.callId) >>> 0;
|
|
2225
|
+
// 1. Gói RelayPing (cmd=2) tới máy chủ Relay kèm sessId
|
|
2226
|
+
if (this.sessId) {
|
|
2227
|
+
const relayPing = buildRelayPingRequest({
|
|
2228
|
+
seqNum: 0,
|
|
2229
|
+
callId: 0,
|
|
2230
|
+
fromUid,
|
|
2231
|
+
routingToken: token,
|
|
2232
|
+
sessId: this.sessId,
|
|
2233
|
+
});
|
|
2234
|
+
void this.sendRawUdp(relayPing, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2235
|
+
}
|
|
2236
|
+
// 2. Gói Ping (cmd=5) giữ NAT
|
|
2237
|
+
const pkt = buildPingRequest({
|
|
2238
|
+
seqNum: this.controlSeq++,
|
|
2239
|
+
callId: this.callId,
|
|
2240
|
+
fromUid,
|
|
2241
|
+
toUid,
|
|
2242
|
+
elapsedMs: elapsed,
|
|
2243
|
+
});
|
|
2244
|
+
void this.sendRawUdp(pkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2245
|
+
// 3. Gói RequestForward Ping (cmd=32, subCmd=11) qua Relay tới máy đối phương
|
|
2246
|
+
const fwdPing = buildRequestForwardPacket({
|
|
2247
|
+
seqNum: 0,
|
|
2248
|
+
seqId: this.forwardPingSeq,
|
|
2249
|
+
fromUid,
|
|
2250
|
+
routingToken: token,
|
|
2251
|
+
subCmd: ZRTPForwardSubCommand.Ping,
|
|
2252
|
+
payload: String(elapsed),
|
|
2253
|
+
});
|
|
2254
|
+
void this.sendRawUdp(fwdPing, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2255
|
+
// 4. Gói RequestForward ManualControl / VideoControl (cmd=32, subCmd=10, 20-byte payload)
|
|
2256
|
+
// Cập nhật partnerRenderFps (15fps), partnerAudioRecvPktPerSec (50), partnerVideoRecvPktPerSec (30)
|
|
2257
|
+
// khớp với CallController::_sendPacketManualControl (0x100184664) & isPartnerRenderFpsLow (0x1001895dc)
|
|
2258
|
+
const isVideo = this.callType === CallType.Video ||
|
|
2259
|
+
this.callType === CallType.GroupVideo ||
|
|
2260
|
+
Number(this.extendData?.callType) === 1;
|
|
2261
|
+
const manualCtrlPayload = Buffer.alloc(20);
|
|
2262
|
+
manualCtrlPayload.writeInt32LE(isVideo ? 15 : 0, 0); // renderFps >= 3
|
|
2263
|
+
manualCtrlPayload.writeInt32LE(0, 4); // isReadyForHevcDec
|
|
2264
|
+
manualCtrlPayload.writeInt32LE(0, 8); // deviceOrientation
|
|
2265
|
+
manualCtrlPayload.writeInt32LE(50, 12); // audioRecvPktCountPerSec > 6
|
|
2266
|
+
manualCtrlPayload.writeInt32LE(isVideo ? 30 : 0, 16); // videoRecvPktCountPerSec
|
|
2267
|
+
const manualCtrlPkt = buildRequestForwardPacket({
|
|
2268
|
+
seqNum: 0,
|
|
2269
|
+
seqId: this.manualControlSeq++,
|
|
2270
|
+
fromUid,
|
|
2271
|
+
routingToken: token,
|
|
2272
|
+
subCmd: ZRTPForwardSubCommand.VideoControl,
|
|
2273
|
+
payload: manualCtrlPayload,
|
|
2274
|
+
});
|
|
2275
|
+
void this.sendRawUdp(manualCtrlPkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2276
|
+
};
|
|
2277
|
+
sendPing();
|
|
2278
|
+
this.pingTimer = setInterval(sendPing, this.options.pingIntervalMs);
|
|
2279
|
+
if (typeof this.pingTimer.unref === "function") {
|
|
2280
|
+
this.pingTimer.unref();
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
startGroupPingLoop() {
|
|
2284
|
+
if (this.groupPingTimer) {
|
|
2285
|
+
clearInterval(this.groupPingTimer);
|
|
2286
|
+
this.groupPingTimer = null;
|
|
2287
|
+
}
|
|
2288
|
+
if (!this.groupId || !this.callId)
|
|
2289
|
+
return;
|
|
2290
|
+
this.groupPingTimer = setInterval(() => {
|
|
2291
|
+
if (this.state === "ended" || !this.groupId || !this.callId)
|
|
2292
|
+
return;
|
|
2293
|
+
void this.api.call
|
|
2294
|
+
.pingGroupCall(this.callId, {
|
|
2295
|
+
hostCall: this.groupId,
|
|
2296
|
+
callState: 1,
|
|
2297
|
+
audioState: this.isMicMuted ? 0 : 1,
|
|
2298
|
+
videoState: this.isCameraOn ? 1 : 0,
|
|
2299
|
+
})
|
|
2300
|
+
.catch(() => { });
|
|
2301
|
+
}, 10000);
|
|
2302
|
+
if (typeof this.groupPingTimer.unref === "function") {
|
|
2303
|
+
this.groupPingTimer.unref();
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
startMediaKeepaliveLoop() {
|
|
2307
|
+
this.stopMediaKeepaliveLoop();
|
|
2308
|
+
if (!this.options.autoKeepaliveMedia)
|
|
2309
|
+
return;
|
|
2310
|
+
// 1. Luồng Audio RTP 20ms (50 gói/giây) thoả mãn audio_recheck_sock (checkPkgRec >= 14 / 2s)
|
|
2311
|
+
this.audioKeepaliveTimer = setInterval(() => {
|
|
2312
|
+
if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
|
|
2313
|
+
return;
|
|
2314
|
+
if (Date.now() - this.lastExternalAudioSentAtMs >= 18) {
|
|
2315
|
+
void this.sendAudioFrame(OPUS_SILENCE_20MS_FRAME, { isKeepalive: true }).catch(() => { });
|
|
2316
|
+
}
|
|
2317
|
+
}, 20);
|
|
2318
|
+
if (typeof this.audioKeepaliveTimer.unref === "function") {
|
|
2319
|
+
this.audioKeepaliveTimer.unref();
|
|
2320
|
+
}
|
|
2321
|
+
// 2. Nếu là cuộc gọi Video: chỉ gửi H264_KEEPALIVE_ANNEXB_FRAME khi KHÔNG có camera ngoài gửi frame trong >= 1000ms!
|
|
2322
|
+
// Tuyệt đối không chèn khung 16x16 vào giữa luồng camera 480x640 đang chạy (tránh làm hỏng SPS/PPS trên VideoToolbox iOS).
|
|
2323
|
+
const isVideo = this.callType === CallType.Video ||
|
|
2324
|
+
this.callType === CallType.GroupVideo ||
|
|
2325
|
+
Number(this.extendData?.callType) === 1;
|
|
2326
|
+
if (isVideo) {
|
|
2327
|
+
this.videoKeepaliveTimer = setInterval(() => {
|
|
2328
|
+
if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
|
|
2329
|
+
return;
|
|
2330
|
+
if (!this.isCameraOn)
|
|
2331
|
+
return;
|
|
2332
|
+
if (Date.now() - this.lastExternalVideoSentAtMs >= 1000) {
|
|
2333
|
+
void this.sendH264AnnexBFrame(H264_KEEPALIVE_ANNEXB_FRAME, {
|
|
2334
|
+
timestampStep: 6000,
|
|
2335
|
+
isKeepalive: true,
|
|
2336
|
+
}).catch(() => { });
|
|
2337
|
+
}
|
|
2338
|
+
}, 66);
|
|
2339
|
+
if (typeof this.videoKeepaliveTimer.unref === "function") {
|
|
2340
|
+
this.videoKeepaliveTimer.unref();
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
// 3. Báo cáo RTCP Sender Report + Receiver Report + SDES mỗi 500ms (thoả mãn durationCheckNoReportSec: 4)
|
|
2344
|
+
const sendPeriodicRtcp = () => {
|
|
2345
|
+
if (!this.udpSocket || this.rtpEndpoint.port <= 0 || this.state === "ended")
|
|
2346
|
+
return;
|
|
2347
|
+
const audioRtcp = this.buildCompoundRtcpReport(false);
|
|
2348
|
+
void this.sendRtcp(audioRtcp, false).catch(() => { });
|
|
2349
|
+
const activeVideo = this.callType === CallType.Video ||
|
|
2350
|
+
this.callType === CallType.GroupVideo ||
|
|
2351
|
+
Number(this.extendData?.callType) === 1;
|
|
2352
|
+
if (activeVideo) {
|
|
2353
|
+
const videoRtcp = this.buildCompoundRtcpReport(true);
|
|
2354
|
+
void this.sendRtcp(videoRtcp, true).catch(() => { });
|
|
2355
|
+
if (this.remoteVideoSsrc !== 0 && !this.remoteSpsNal) {
|
|
2356
|
+
void this.sendRtcpPli().catch(() => { });
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
sendPeriodicRtcp();
|
|
2361
|
+
this.rtcpTimer = setInterval(sendPeriodicRtcp, 500);
|
|
2362
|
+
if (typeof this.rtcpTimer.unref === "function") {
|
|
2363
|
+
this.rtcpTimer.unref();
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
stopMediaKeepaliveLoop() {
|
|
2367
|
+
if (this.audioKeepaliveTimer) {
|
|
2368
|
+
clearInterval(this.audioKeepaliveTimer);
|
|
2369
|
+
this.audioKeepaliveTimer = null;
|
|
2370
|
+
}
|
|
2371
|
+
if (this.videoKeepaliveTimer) {
|
|
2372
|
+
clearInterval(this.videoKeepaliveTimer);
|
|
2373
|
+
this.videoKeepaliveTimer = null;
|
|
2374
|
+
}
|
|
2375
|
+
if (this.rtcpTimer) {
|
|
2376
|
+
clearInterval(this.rtcpTimer);
|
|
2377
|
+
this.rtcpTimer = null;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
buildCompoundRtcpReport(isVideo) {
|
|
2381
|
+
const nowMs = Date.now();
|
|
2382
|
+
const ntpSec = (Math.floor(nowMs / 1000) + 2208988800) >>> 0;
|
|
2383
|
+
const ntpFrac = Math.floor(((nowMs % 1000) / 1000) * 0x100000000) >>> 0;
|
|
2384
|
+
const localSsrc = isVideo ? this.getLocalVideoSsrc() : this.getLocalAudioSsrc();
|
|
2385
|
+
const rtpTs = isVideo ? this.videoTimestamp : this.audioTimestamp;
|
|
2386
|
+
const pktCount = isVideo ? this.videoPacketCount : this.audioPacketCount;
|
|
2387
|
+
const octCount = isVideo ? this.videoOctetCount : this.audioOctetCount;
|
|
2388
|
+
const remoteSsrc = isVideo ? this.remoteVideoSsrc : this.remoteAudioSsrc;
|
|
2389
|
+
const remoteMaxSeq = isVideo ? this.remoteVideoMaxSeq : this.remoteAudioMaxSeq;
|
|
2390
|
+
const remoteCycles = isVideo ? this.remoteVideoSeqCycles : this.remoteAudioSeqCycles;
|
|
2391
|
+
const lastSrMiddleNtp = isVideo
|
|
2392
|
+
? this.lastRemoteVideoSrMiddleNtp
|
|
2393
|
+
: this.lastRemoteAudioSrMiddleNtp;
|
|
2394
|
+
const lastSrAtMs = isVideo
|
|
2395
|
+
? this.lastRemoteVideoSrReceivedAtMs
|
|
2396
|
+
: this.lastRemoteAudioSrReceivedAtMs;
|
|
2397
|
+
const hasReportBlock = remoteSsrc !== 0;
|
|
2398
|
+
const srLenBytes = hasReportBlock ? 52 : 28;
|
|
2399
|
+
const sdesLenBytes = 16;
|
|
2400
|
+
const rembLenBytes = isVideo && hasReportBlock ? 24 : 0;
|
|
2401
|
+
const buf = Buffer.alloc(srLenBytes + sdesLenBytes + rembLenBytes);
|
|
2402
|
+
// RTCP Sender Report (PT = 200)
|
|
2403
|
+
buf.writeUInt8(hasReportBlock ? 0x81 : 0x80, 0);
|
|
2404
|
+
buf.writeUInt8(200, 1);
|
|
2405
|
+
buf.writeUInt16BE(srLenBytes / 4 - 1, 2);
|
|
2406
|
+
buf.writeUInt32BE(localSsrc >>> 0, 4);
|
|
2407
|
+
buf.writeUInt32BE(ntpSec, 8);
|
|
2408
|
+
buf.writeUInt32BE(ntpFrac, 12);
|
|
2409
|
+
buf.writeUInt32BE(rtpTs >>> 0, 16);
|
|
2410
|
+
buf.writeUInt32BE(pktCount >>> 0, 20);
|
|
2411
|
+
buf.writeUInt32BE(octCount >>> 0, 24);
|
|
2412
|
+
if (hasReportBlock) {
|
|
2413
|
+
const extMaxSeq = (((remoteCycles & 0xffff) << 16) | (remoteMaxSeq & 0xffff)) >>> 0;
|
|
2414
|
+
const nowMiddleNtp = (((ntpSec & 0xffff) << 16) | ((ntpFrac >>> 16) & 0xffff)) >>> 0;
|
|
2415
|
+
const lsr = lastSrMiddleNtp !== 0
|
|
2416
|
+
? lastSrMiddleNtp >>> 0
|
|
2417
|
+
: ((nowMiddleNtp - 1638) >>> 0); // Fallback 25ms ago
|
|
2418
|
+
const dlsr = lastSrAtMs > 0 && lastSrMiddleNtp !== 0
|
|
2419
|
+
? (Math.max(65, Math.floor(((nowMs - lastSrAtMs) / 1000) * 65536)) >>> 0)
|
|
2420
|
+
: 1638; // 25ms in 1/65536s units so RTT is ~25ms (1 <= rtt < 9999)
|
|
2421
|
+
buf.writeUInt32BE(remoteSsrc >>> 0, 28);
|
|
2422
|
+
buf.writeUInt32BE(0, 32); // fractionLost = 0, cumulativeLost = 0
|
|
2423
|
+
buf.writeUInt32BE(extMaxSeq, 36);
|
|
2424
|
+
buf.writeUInt32BE(10, 40); // interarrival jitter
|
|
2425
|
+
buf.writeUInt32BE(lsr, 44);
|
|
2426
|
+
buf.writeUInt32BE(dlsr, 48);
|
|
2427
|
+
}
|
|
2428
|
+
// RTCP SDES (PT = 202)
|
|
2429
|
+
const sdesOffset = srLenBytes;
|
|
2430
|
+
buf.writeUInt8(0x81, sdesOffset);
|
|
2431
|
+
buf.writeUInt8(202, sdesOffset + 1);
|
|
2432
|
+
buf.writeUInt16BE(3, sdesOffset + 2);
|
|
2433
|
+
buf.writeUInt32BE(localSsrc >>> 0, sdesOffset + 4);
|
|
2434
|
+
buf.writeUInt8(1, sdesOffset + 8); // CNAME
|
|
2435
|
+
buf.writeUInt8(5, sdesOffset + 9);
|
|
2436
|
+
buf.write("zcajs", sdesOffset + 10, "ascii");
|
|
2437
|
+
buf.writeUInt8(0, sdesOffset + 15);
|
|
2438
|
+
// RTCP REMB (PT = 206, FMT = 15, 'R' 'E' 'M' 'B', ~1.46 Mbps) cho Video BWE
|
|
2439
|
+
if (rembLenBytes > 0) {
|
|
2440
|
+
const rembOffset = srLenBytes + sdesLenBytes;
|
|
2441
|
+
buf.writeUInt8(0x8f, rembOffset); // V=2, P=0, FMT=15
|
|
2442
|
+
buf.writeUInt8(206, rembOffset + 1); // PT=206 (PSFB)
|
|
2443
|
+
buf.writeUInt16BE(5, rembOffset + 2); // 5 words (24 bytes)
|
|
2444
|
+
buf.writeUInt32BE(localSsrc >>> 0, rembOffset + 4);
|
|
2445
|
+
buf.writeUInt32BE(0, rembOffset + 8);
|
|
2446
|
+
buf.write("REMB", rembOffset + 12, "ascii");
|
|
2447
|
+
buf.writeUInt8(1, rembOffset + 16); // numSsrcs = 1
|
|
2448
|
+
// brExp = 3, brMantissa = 183105 (0x2cb41) => 183105 << 3 = 1,464,840 bps
|
|
2449
|
+
buf.writeUInt8(0x0e, rembOffset + 17);
|
|
2450
|
+
buf.writeUInt8(0xcb, rembOffset + 18);
|
|
2451
|
+
buf.writeUInt8(0x41, rembOffset + 19);
|
|
2452
|
+
buf.writeUInt32BE(remoteSsrc >>> 0, rembOffset + 20);
|
|
2453
|
+
}
|
|
2454
|
+
return buf;
|
|
2455
|
+
}
|
|
2456
|
+
markCallActiveFromPartnerTraffic() {
|
|
2457
|
+
this.hasReceivedPartnerMedia = true;
|
|
2458
|
+
if (this.state === "ringing" || this.state === "handshaking_udp") {
|
|
2459
|
+
this.setState("active");
|
|
2460
|
+
if (this.isCaller &&
|
|
2461
|
+
this.partnerId &&
|
|
2462
|
+
this.callId > 0 &&
|
|
2463
|
+
!this.groupId &&
|
|
2464
|
+
!this.hasSentAnswerAck) {
|
|
2465
|
+
this.hasSentAnswerAck = true;
|
|
2466
|
+
void this.api.call.answerAckVoiceCall(this.partnerId, this.callId).catch(() => { });
|
|
2467
|
+
}
|
|
2468
|
+
if (this.callType === CallType.Video ||
|
|
2469
|
+
this.callType === CallType.GroupVideo ||
|
|
2470
|
+
Number(this.extendData?.callType) === 1) {
|
|
2471
|
+
void this.sendCameraState(this.isCameraOn).catch(() => { });
|
|
2472
|
+
if (!this.isCameraOn && this.partnerId && this.callId > 0 && !this.groupId) {
|
|
2473
|
+
void this.api.call
|
|
2474
|
+
.updateCallMediaState(this.partnerId, this.callId, {
|
|
2475
|
+
isOnCam: 0,
|
|
2476
|
+
isOnMic: -1,
|
|
2477
|
+
data: "",
|
|
2478
|
+
})
|
|
2479
|
+
.catch(() => { });
|
|
2480
|
+
}
|
|
2481
|
+
void this.sendRtcpPli().catch(() => { });
|
|
2482
|
+
}
|
|
2483
|
+
const params = this.getConnectionParams();
|
|
2484
|
+
this.emit("connection_params", params);
|
|
2485
|
+
this.emit("active", params);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
handleIncomingUdpPacket(msg) {
|
|
2489
|
+
const parsed = parseZRTPPacket(msg);
|
|
2490
|
+
this.emit("zrtp_packet", parsed);
|
|
2491
|
+
if (parsed.family === "control") {
|
|
2492
|
+
if (parsed.isResponse &&
|
|
2493
|
+
(parsed.cmd === ZRTPControlCommand.InitCallCaller ||
|
|
2494
|
+
parsed.cmd === ZRTPControlCommand.InitCallCallee) &&
|
|
2495
|
+
parsed.routingToken) {
|
|
2496
|
+
this.routingToken = parsed.routingToken >>> 0;
|
|
2497
|
+
}
|
|
2498
|
+
else if (parsed.cmd === ZRTPControlCommand.EndCall &&
|
|
2499
|
+
(this.state === "ringing" || this.state === "active")) {
|
|
2500
|
+
this.emit("zrtp_control", parsed);
|
|
2501
|
+
void this.endCall("remote_end");
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
else if (!parsed.isResponse &&
|
|
2505
|
+
parsed.cmd === ZRTPControlCommand.Ping &&
|
|
2506
|
+
this.udpSocket &&
|
|
2507
|
+
this.rtpEndpoint.port > 0) {
|
|
2508
|
+
const resp = buildPingResponse({
|
|
2509
|
+
seqNum: parsed.seqNum,
|
|
2510
|
+
callId: parsed.headerCallId || this.callId,
|
|
2511
|
+
fromUid: parsed.toUid,
|
|
2512
|
+
toUid: parsed.fromUid,
|
|
2513
|
+
elapsedMs: parsed.pingString ?? "0",
|
|
2514
|
+
});
|
|
2515
|
+
void this.sendRawUdp(resp, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2516
|
+
}
|
|
2517
|
+
else if (!parsed.isResponse &&
|
|
2518
|
+
parsed.cmd === ZRTPControlCommand.RequestForward &&
|
|
2519
|
+
this.udpSocket &&
|
|
2520
|
+
this.rtpEndpoint.port > 0) {
|
|
2521
|
+
this.markCallActiveFromPartnerTraffic();
|
|
2522
|
+
if (parsed.flags === ZRTPForwardSubCommand.Ping) {
|
|
2523
|
+
const pongPkt = buildRequestForwardPacket({
|
|
2524
|
+
seqNum: 0,
|
|
2525
|
+
seqId: parsed.headerCallId,
|
|
2526
|
+
fromUid: this.fromUid32 || uidToUint32(this.api.getContext().uid),
|
|
2527
|
+
routingToken: (this.routingToken || this.callId) >>> 0,
|
|
2528
|
+
subCmd: ZRTPForwardSubCommand.Pong,
|
|
2529
|
+
payload: parsed.forwardPayload ?? Buffer.alloc(0),
|
|
2530
|
+
});
|
|
2531
|
+
void this.sendRawUdp(pongPkt, this.rtpEndpoint.host, this.rtpEndpoint.port).catch(() => { });
|
|
2532
|
+
}
|
|
2533
|
+
else if (parsed.flags === ZRTPForwardSubCommand.Pong) {
|
|
2534
|
+
if (parsed.headerCallId === this.forwardPingSeq) {
|
|
2535
|
+
this.forwardPingSeq = (this.forwardPingSeq + 1) >>> 0;
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
else if (parsed.flags === ZRTPForwardSubCommand.OnCamera ||
|
|
2539
|
+
parsed.flags === ZRTPForwardSubCommand.OffCamera ||
|
|
2540
|
+
parsed.flags === ZRTPForwardSubCommand.OffCameraCallKit ||
|
|
2541
|
+
parsed.flags === ZRTPForwardSubCommand.PartnerSuspendVideo) {
|
|
2542
|
+
const isCamOn = parsed.flags === ZRTPForwardSubCommand.OnCamera;
|
|
2543
|
+
this.clearIncomingVideoAssemblyBuffers();
|
|
2544
|
+
if (isCamOn) {
|
|
2545
|
+
if (this.callType === CallType.Audio) {
|
|
2546
|
+
this.callType = CallType.Video;
|
|
2547
|
+
this.startMediaKeepaliveLoop();
|
|
2548
|
+
}
|
|
2549
|
+
else if (this.callType === CallType.GroupAudio) {
|
|
2550
|
+
this.callType = CallType.GroupVideo;
|
|
2551
|
+
this.startMediaKeepaliveLoop();
|
|
2552
|
+
}
|
|
2553
|
+
void this.sendRtcpPli().catch(() => { });
|
|
2554
|
+
const t = setTimeout(() => {
|
|
2555
|
+
if (this.state === "active") {
|
|
2556
|
+
void this.sendRtcpPli().catch(() => { });
|
|
2557
|
+
}
|
|
2558
|
+
}, 250);
|
|
2559
|
+
if (typeof t.unref === "function")
|
|
2560
|
+
t.unref();
|
|
2561
|
+
}
|
|
2562
|
+
this.emit("media_state_change", {
|
|
2563
|
+
isLocal: false,
|
|
2564
|
+
isCameraOn: isCamOn,
|
|
2565
|
+
callType: this.callType,
|
|
2566
|
+
rawAct: isCamOn
|
|
2567
|
+
? "udp_on_camera"
|
|
2568
|
+
: parsed.flags === ZRTPForwardSubCommand.OffCameraCallKit
|
|
2569
|
+
? "udp_off_camera_callkit"
|
|
2570
|
+
: parsed.flags === ZRTPForwardSubCommand.PartnerSuspendVideo
|
|
2571
|
+
? "udp_partner_suspend_video"
|
|
2572
|
+
: "udp_off_camera",
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
else if (parsed.flags === ZRTPForwardSubCommand.OnMic ||
|
|
2576
|
+
parsed.flags === ZRTPForwardSubCommand.OffMic) {
|
|
2577
|
+
this.emit("media_state_change", {
|
|
2578
|
+
isLocal: false,
|
|
2579
|
+
isMicMuted: parsed.flags === ZRTPForwardSubCommand.OffMic,
|
|
2580
|
+
rawAct: parsed.flags === ZRTPForwardSubCommand.OnMic
|
|
2581
|
+
? "udp_on_mic"
|
|
2582
|
+
: "udp_off_mic",
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
this.emit("zrtp_control", parsed);
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
if (parsed.family === "legacy_media" || parsed.family === "zrtc_media") {
|
|
2590
|
+
this.markCallActiveFromPartnerTraffic();
|
|
2591
|
+
try {
|
|
2592
|
+
if (parsed.isRtcp) {
|
|
2593
|
+
let rawRtcp = parsed.rtpPayload;
|
|
2594
|
+
if (this.srtpSession && Number(this.extendData?.srtcp) === 1) {
|
|
2595
|
+
try {
|
|
2596
|
+
rawRtcp = this.srtpSession.unprotectRtcp(parsed.rtpPayload);
|
|
2597
|
+
}
|
|
2598
|
+
catch {
|
|
2599
|
+
rawRtcp = parsed.rtpPayload;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
// Quét toàn bộ các sub-packet trong gói Compound RTCP để tìm Sender Report (PT=200)
|
|
2603
|
+
let rtcpOffset = 0;
|
|
2604
|
+
while (rtcpOffset + 8 <= rawRtcp.length) {
|
|
2605
|
+
const pt = rawRtcp.readUInt8(rtcpOffset + 1);
|
|
2606
|
+
const lenWords = rawRtcp.readUInt16BE(rtcpOffset + 2);
|
|
2607
|
+
const pktBytes = (lenWords + 1) * 4;
|
|
2608
|
+
if (pt === 200 && rtcpOffset + 28 <= rawRtcp.length) {
|
|
2609
|
+
const srSsrc = rawRtcp.readUInt32BE(rtcpOffset + 4);
|
|
2610
|
+
const middleNtp = rawRtcp.readUInt32BE(rtcpOffset + 10);
|
|
2611
|
+
if (parsed.isVideo) {
|
|
2612
|
+
if (!this.remoteVideoSsrc)
|
|
2613
|
+
this.remoteVideoSsrc = srSsrc;
|
|
2614
|
+
this.lastRemoteVideoSrMiddleNtp = middleNtp;
|
|
2615
|
+
this.lastRemoteVideoSrReceivedAtMs = Date.now();
|
|
2616
|
+
}
|
|
2617
|
+
else {
|
|
2618
|
+
if (!this.remoteAudioSsrc)
|
|
2619
|
+
this.remoteAudioSsrc = srSsrc;
|
|
2620
|
+
this.lastRemoteAudioSrMiddleNtp = middleNtp;
|
|
2621
|
+
this.lastRemoteAudioSrReceivedAtMs = Date.now();
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
if (pktBytes <= 0)
|
|
2625
|
+
break;
|
|
2626
|
+
rtcpOffset += pktBytes;
|
|
2627
|
+
}
|
|
2628
|
+
this.emit("rtcp_packet", {
|
|
2629
|
+
isVideo: parsed.isVideo,
|
|
2630
|
+
rtcpPacket: rawRtcp,
|
|
2631
|
+
zrtpPacket: parsed,
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
else {
|
|
2635
|
+
let rawRtp = parsed.rtpPayload;
|
|
2636
|
+
if (this.srtpSession) {
|
|
2637
|
+
try {
|
|
2638
|
+
rawRtp = this.srtpSession.unprotectRtp(parsed.rtpPayload);
|
|
2639
|
+
}
|
|
2640
|
+
catch {
|
|
2641
|
+
rawRtp = parsed.rtpPayload;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
const parsedRtp = parseRtpPacket(rawRtp);
|
|
2645
|
+
if (parsed.isVideo) {
|
|
2646
|
+
if (isZaloVideoPayloadType(parsedRtp.payloadType)) {
|
|
2647
|
+
this.remoteVideoSsrc = parsedRtp.ssrc;
|
|
2648
|
+
if (parsedRtp.sequenceNumber < this.remoteVideoMaxSeq && this.remoteVideoMaxSeq - parsedRtp.sequenceNumber > 30000) {
|
|
2649
|
+
this.remoteVideoSeqCycles++;
|
|
2650
|
+
}
|
|
2651
|
+
this.remoteVideoMaxSeq = parsedRtp.sequenceNumber;
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
else {
|
|
2655
|
+
this.remoteAudioSsrc = parsedRtp.ssrc;
|
|
2656
|
+
if (parsedRtp.sequenceNumber < this.remoteAudioMaxSeq && this.remoteAudioMaxSeq - parsedRtp.sequenceNumber > 30000) {
|
|
2657
|
+
this.remoteAudioSeqCycles++;
|
|
2658
|
+
}
|
|
2659
|
+
this.remoteAudioMaxSeq = parsedRtp.sequenceNumber;
|
|
2660
|
+
}
|
|
2661
|
+
this.emit("rtp_packet", {
|
|
2662
|
+
isVideo: parsed.isVideo,
|
|
2663
|
+
isFec: parsed.isFec,
|
|
2664
|
+
rtpPacket: rawRtp,
|
|
2665
|
+
parsedRtp,
|
|
2666
|
+
zrtpPacket: parsed,
|
|
2667
|
+
});
|
|
2668
|
+
if (!parsed.isVideo && !parsed.isFec) {
|
|
2669
|
+
this.emit("audio_frame", {
|
|
2670
|
+
payload: parsedRtp.payload,
|
|
2671
|
+
sequenceNumber: parsedRtp.sequenceNumber,
|
|
2672
|
+
timestamp: parsedRtp.timestamp,
|
|
2673
|
+
ssrc: parsedRtp.ssrc,
|
|
2674
|
+
payloadType: parsedRtp.payloadType,
|
|
2675
|
+
marker: parsedRtp.marker,
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
else if (parsed.isVideo &&
|
|
2679
|
+
!parsed.isFec &&
|
|
2680
|
+
isZaloVideoPayloadType(parsedRtp.payloadType)) {
|
|
2681
|
+
this.emit("video_frame", {
|
|
2682
|
+
payload: parsedRtp.payload,
|
|
2683
|
+
sequenceNumber: parsedRtp.sequenceNumber,
|
|
2684
|
+
timestamp: parsedRtp.timestamp,
|
|
2685
|
+
ssrc: parsedRtp.ssrc,
|
|
2686
|
+
payloadType: parsedRtp.payloadType,
|
|
2687
|
+
marker: parsedRtp.marker,
|
|
2688
|
+
});
|
|
2689
|
+
this.handleIncomingVideoRtpPayload(parsedRtp);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
catch (err) {
|
|
2694
|
+
if (this.listenerCount("error") > 0) {
|
|
2695
|
+
this.emit("error", err);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
handleIncomingVideoRtpPayload(parsedRtp) {
|
|
2701
|
+
const payload = parsedRtp.payload;
|
|
2702
|
+
// Bỏ qua ngay các gói RTP padding rỗng (P=1) để không làm flush nhầm frame đang ghép
|
|
2703
|
+
if (!payload || payload.length < 1)
|
|
2704
|
+
return;
|
|
2705
|
+
if (this.currentAccessUnitTs !== -1 && parsedRtp.timestamp !== this.currentAccessUnitTs) {
|
|
2706
|
+
this.flushIncomingAccessUnit(this.currentAccessUnitTs, parsedRtp.ssrc);
|
|
2707
|
+
}
|
|
2708
|
+
this.currentAccessUnitTs = parsedRtp.timestamp;
|
|
2709
|
+
if (isZaloKeyframePayloadType(parsedRtp.payloadType)) {
|
|
2710
|
+
this.currentAccessUnitHasIdr = true;
|
|
2711
|
+
}
|
|
2712
|
+
// Trường hợp 1: Payload đã bắt đầu trực tiếp bằng Annex-B start code (00 00 00 01 hoặc 00 00 01)
|
|
2713
|
+
if (startsWithAnnexBStartCode(payload)) {
|
|
2714
|
+
for (const nal of splitAnnexBNals(payload)) {
|
|
2715
|
+
this.recordIncomingNal(nal);
|
|
2716
|
+
}
|
|
2717
|
+
if (parsedRtp.marker) {
|
|
2718
|
+
this.flushIncomingAccessUnit(parsedRtp.timestamp, parsedRtp.ssrc);
|
|
2719
|
+
}
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
const nalHeader = payload[0];
|
|
2723
|
+
const nalType = nalHeader & 0x1f;
|
|
2724
|
+
if (nalType === 0) {
|
|
2725
|
+
// Khi g_nalHeaderSize == 0 trên iOS, NextAggregatePacket (0x1003db3a4) ghi trực tiếp
|
|
2726
|
+
// [uint16BE len1][00 00 00 01 SPS][uint16BE len2][00 00 00 01 PPS] tại offset 0
|
|
2727
|
+
const agg = tryParseAggregatedNals(payload, 0);
|
|
2728
|
+
if (agg) {
|
|
2729
|
+
for (const chunk of agg) {
|
|
2730
|
+
for (const nal of splitAnnexBNals(chunk)) {
|
|
2731
|
+
this.recordIncomingNal(nal);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
else {
|
|
2736
|
+
for (const nal of splitAnnexBNals(payload)) {
|
|
2737
|
+
this.recordIncomingNal(nal);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
else if (nalType >= 1 && nalType <= 23) {
|
|
2742
|
+
for (const nal of splitAnnexBNals(payload)) {
|
|
2743
|
+
this.recordIncomingNal(nal);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
else if (nalType === 24) {
|
|
2747
|
+
// RFC 6184 STAP-A (Single-Time Aggregation Packet)
|
|
2748
|
+
const agg = tryParseAggregatedNals(payload, 1);
|
|
2749
|
+
if (agg) {
|
|
2750
|
+
for (const chunk of agg) {
|
|
2751
|
+
for (const nal of splitAnnexBNals(chunk)) {
|
|
2752
|
+
this.recordIncomingNal(nal);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
else {
|
|
2757
|
+
let offset = 1;
|
|
2758
|
+
while (offset + 2 <= payload.length) {
|
|
2759
|
+
const nalSize = payload.readUInt16BE(offset);
|
|
2760
|
+
offset += 2;
|
|
2761
|
+
if (nalSize > 0 && offset + nalSize <= payload.length) {
|
|
2762
|
+
for (const nal of splitAnnexBNals(payload.subarray(offset, offset + nalSize))) {
|
|
2763
|
+
this.recordIncomingNal(nal);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
offset += nalSize;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
else if ((nalType === 28 || nalType === 29) && payload.length >= 2) {
|
|
2771
|
+
// RFC 6184 FU-A (28) / FU-B (29) + Zalo ZRTP FU-A (origNalType = 0 với Annex-B bên trong)
|
|
2772
|
+
const fuIndicator = payload[0];
|
|
2773
|
+
const fuHeader = payload[1];
|
|
2774
|
+
const isStart = (fuHeader & 0x80) !== 0;
|
|
2775
|
+
const isEnd = (fuHeader & 0x40) !== 0;
|
|
2776
|
+
const origNalType = fuHeader & 0x1f;
|
|
2777
|
+
const reconstructedNalHeader = (fuIndicator & 0xe0) | origNalType;
|
|
2778
|
+
const headerSize = nalType === 29 ? 4 : 2;
|
|
2779
|
+
if (payload.length > headerSize) {
|
|
2780
|
+
const body = payload.subarray(headerSize);
|
|
2781
|
+
if (isStart) {
|
|
2782
|
+
// Nếu origNalType === 0 hoặc body đã bắt đầu bằng Annex-B start code (00 00 00 01 / 00 00 01),
|
|
2783
|
+
// TUYỆT ĐỐI KHÔNG chèn thêm byte 0x00 vào đầu để không biến 00 00 00 01 thành 5 byte!
|
|
2784
|
+
if (origNalType === 0 || startsWithAnnexBStartCode(body)) {
|
|
2785
|
+
this.fuaFragments = [body];
|
|
2786
|
+
}
|
|
2787
|
+
else {
|
|
2788
|
+
this.fuaFragments = [Buffer.from([reconstructedNalHeader]), body];
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
else if (this.fuaFragments.length > 0) {
|
|
2792
|
+
this.fuaFragments.push(body);
|
|
2793
|
+
}
|
|
2794
|
+
if (isEnd && this.fuaFragments.length > 0) {
|
|
2795
|
+
const fullNal = Buffer.concat(this.fuaFragments);
|
|
2796
|
+
this.fuaFragments = [];
|
|
2797
|
+
for (const nal of splitAnnexBNals(fullNal)) {
|
|
2798
|
+
this.recordIncomingNal(nal);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
if (parsedRtp.marker) {
|
|
2804
|
+
this.flushIncomingAccessUnit(parsedRtp.timestamp, parsedRtp.ssrc);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
recordIncomingNal(rawNal) {
|
|
2808
|
+
if (!rawNal || rawNal.length === 0)
|
|
2809
|
+
return;
|
|
2810
|
+
const nal = stripLeadingAnnexBStartCode(rawNal);
|
|
2811
|
+
if (!nal || nal.length === 0)
|
|
2812
|
+
return;
|
|
2813
|
+
const nalType = nal[0] & 0x1f;
|
|
2814
|
+
// Bỏ qua NAL rỗng (type=0) hoặc Access Unit Delimiter (AUD, type=9)
|
|
2815
|
+
if (nalType === 0 || nalType === 9)
|
|
2816
|
+
return;
|
|
2817
|
+
if (nalType === 7) {
|
|
2818
|
+
this.remoteSpsNal = Buffer.from(nal);
|
|
2819
|
+
}
|
|
2820
|
+
else if (nalType === 8) {
|
|
2821
|
+
this.remotePpsNal = Buffer.from(nal);
|
|
2822
|
+
}
|
|
2823
|
+
else if (nalType === 5) {
|
|
2824
|
+
this.currentAccessUnitHasIdr = true;
|
|
2825
|
+
}
|
|
2826
|
+
this.currentAccessUnitNals.push(Buffer.from(nal));
|
|
2827
|
+
}
|
|
2828
|
+
flushIncomingAccessUnit(timestamp, ssrc) {
|
|
2829
|
+
if (this.currentAccessUnitNals.length === 0)
|
|
2830
|
+
return;
|
|
2831
|
+
const startCode = Buffer.from([0x00, 0x00, 0x00, 0x01]);
|
|
2832
|
+
const chunks = [];
|
|
2833
|
+
const nalTypes = this.currentAccessUnitNals.map((n) => n[0] & 0x1f);
|
|
2834
|
+
const hasInlineSps = nalTypes.includes(7);
|
|
2835
|
+
const hasInlinePps = nalTypes.includes(8);
|
|
2836
|
+
const hasVclSlice = nalTypes.some((t) => t === 1 || t === 5);
|
|
2837
|
+
// Nếu chỉ mới nhận SPS/PPS mà chưa có slice hình ảnh (IDR/P-slice), giữ lại cho gói tiếp theo
|
|
2838
|
+
if (!hasVclSlice) {
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
if (this.currentAccessUnitHasIdr) {
|
|
2842
|
+
if (!hasInlineSps && this.remoteSpsNal) {
|
|
2843
|
+
chunks.push(startCode, this.remoteSpsNal);
|
|
2844
|
+
}
|
|
2845
|
+
if (!hasInlinePps && this.remotePpsNal) {
|
|
2846
|
+
chunks.push(startCode, this.remotePpsNal);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
for (const nal of this.currentAccessUnitNals) {
|
|
2850
|
+
chunks.push(startCode, nal);
|
|
2851
|
+
}
|
|
2852
|
+
const annexB = Buffer.concat(chunks);
|
|
2853
|
+
const isKeyframe = this.currentAccessUnitHasIdr;
|
|
2854
|
+
this.currentAccessUnitNals = [];
|
|
2855
|
+
this.currentAccessUnitHasIdr = false;
|
|
2856
|
+
this.currentAccessUnitTs = -1;
|
|
2857
|
+
this.emit("video_annexb_frame", {
|
|
2858
|
+
annexB,
|
|
2859
|
+
isKeyframe,
|
|
2860
|
+
timestamp,
|
|
2861
|
+
ssrc,
|
|
2862
|
+
sps: this.remoteSpsNal ?? undefined,
|
|
2863
|
+
pps: this.remotePpsNal ?? undefined,
|
|
2864
|
+
nalTypes,
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
attachCallEventListener() {
|
|
2868
|
+
if (this.boundCallEventListener)
|
|
2869
|
+
return;
|
|
2870
|
+
this.boundCallEventListener = (event) => {
|
|
2871
|
+
void this.handleCallEvent(event);
|
|
2872
|
+
};
|
|
2873
|
+
try {
|
|
2874
|
+
this.api.listener.on("call_event", this.boundCallEventListener);
|
|
2875
|
+
}
|
|
2876
|
+
catch {
|
|
2877
|
+
// Listener might not be initialized if cookie/userAgent is missing in unit test mocks
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
detachCallEventListener() {
|
|
2881
|
+
if (!this.boundCallEventListener)
|
|
2882
|
+
return;
|
|
2883
|
+
try {
|
|
2884
|
+
this.api.listener.removeListener("call_event", this.boundCallEventListener);
|
|
2885
|
+
}
|
|
2886
|
+
catch {
|
|
2887
|
+
// Ignore
|
|
2888
|
+
}
|
|
2889
|
+
this.boundCallEventListener = undefined;
|
|
2890
|
+
}
|
|
2891
|
+
cleanupResources() {
|
|
2892
|
+
if (this.ringingTimeoutTimer) {
|
|
2893
|
+
clearTimeout(this.ringingTimeoutTimer);
|
|
2894
|
+
this.ringingTimeoutTimer = null;
|
|
2895
|
+
}
|
|
2896
|
+
if (this.pingTimer) {
|
|
2897
|
+
clearInterval(this.pingTimer);
|
|
2898
|
+
this.pingTimer = null;
|
|
2899
|
+
}
|
|
2900
|
+
if (this.groupPingTimer) {
|
|
2901
|
+
clearInterval(this.groupPingTimer);
|
|
2902
|
+
this.groupPingTimer = null;
|
|
2903
|
+
}
|
|
2904
|
+
this.stopMediaKeepaliveLoop();
|
|
2905
|
+
this.detachCallEventListener();
|
|
2906
|
+
if (this.udpSocket) {
|
|
2907
|
+
try {
|
|
2908
|
+
this.udpSocket.removeAllListeners();
|
|
2909
|
+
this.udpSocket.close();
|
|
2910
|
+
}
|
|
2911
|
+
catch {
|
|
2912
|
+
// Ignore
|
|
2913
|
+
}
|
|
2914
|
+
this.udpSocket = null;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
sendRawUdp(buf, host, port) {
|
|
2918
|
+
return new Promise((resolve, reject) => {
|
|
2919
|
+
if (!this.udpSocket) {
|
|
2920
|
+
reject(new ZaloApiError("UDP socket is not open"));
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
this.udpSocket.send(buf, port, host, (err) => {
|
|
2924
|
+
if (err)
|
|
2925
|
+
reject(err);
|
|
2926
|
+
else
|
|
2927
|
+
resolve();
|
|
2928
|
+
});
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
}
|