Calls: Upgrade library (#6916)
This commit is contained in:
parent
f2b3eed845
commit
4586064410
@ -32,7 +32,6 @@ export default defineConfig(
|
|||||||
'src/lib/gramjs/tl/',
|
'src/lib/gramjs/tl/',
|
||||||
'src/lib/lovely-chart/**',
|
'src/lib/lovely-chart/**',
|
||||||
'src/lib/music-metadata-browser',
|
'src/lib/music-metadata-browser',
|
||||||
'src/lib/secret-sauce/',
|
|
||||||
'src/lib/fastBlur.js',
|
'src/lib/fastBlur.js',
|
||||||
'src/types/language.d.ts',
|
'src/types/language.d.ts',
|
||||||
'dist/',
|
'dist/',
|
||||||
|
|||||||
@ -6,11 +6,30 @@ import type {
|
|||||||
GroupCallParticipant,
|
GroupCallParticipant,
|
||||||
GroupCallParticipantVideo,
|
GroupCallParticipantVideo,
|
||||||
SsrcGroup,
|
SsrcGroup,
|
||||||
} from '../../../lib/secret-sauce';
|
} from '../../../lib/vibecalls';
|
||||||
import type { ApiGroupCall, ApiPhoneCall } from '../../types';
|
import type { ApiGroupCall, ApiPhoneCall } from '../../types';
|
||||||
|
|
||||||
|
import { CALL_PROTOCOL_LIBRARY_VERSIONS } from '../../../config';
|
||||||
|
import { sanitizePrimitiveRecord } from '../../../util/primitives/primitiveRecord';
|
||||||
import { getApiChatIdFromMtpPeer, isMtpPeerUser } from './peers';
|
import { getApiChatIdFromMtpPeer, isMtpPeerUser } from './peers';
|
||||||
|
|
||||||
|
function parseCallParameters(data?: GramJs.TypeDataJSON) {
|
||||||
|
if (!data?.data) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.data);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizePrimitiveRecord(parsed as Record<string, unknown>);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant {
|
export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant {
|
||||||
const {
|
const {
|
||||||
self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume,
|
self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume,
|
||||||
@ -134,7 +153,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall {
|
|||||||
|
|
||||||
if (call instanceof GramJs.PhoneCall) {
|
if (call instanceof GramJs.PhoneCall) {
|
||||||
const {
|
const {
|
||||||
p2pAllowed, gAOrB, keyFingerprint, connections, startDate,
|
p2pAllowed, gAOrB, keyFingerprint, connections, startDate, customParameters,
|
||||||
} = call;
|
} = call;
|
||||||
|
|
||||||
phoneCall = {
|
phoneCall = {
|
||||||
@ -145,6 +164,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall {
|
|||||||
startDate,
|
startDate,
|
||||||
isP2pAllowed: Boolean(p2pAllowed),
|
isP2pAllowed: Boolean(p2pAllowed),
|
||||||
connections: connections.map(buildApiCallConnection).filter(Boolean),
|
connections: connections.map(buildApiCallConnection).filter(Boolean),
|
||||||
|
customParameters: parseCallParameters(customParameters),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,8 +254,9 @@ export function buildApiCallProtocol(protocol: GramJs.PhoneCallProtocol): ApiCal
|
|||||||
|
|
||||||
export function buildCallProtocol() {
|
export function buildCallProtocol() {
|
||||||
return new GramJs.PhoneCallProtocol({
|
return new GramJs.PhoneCallProtocol({
|
||||||
libraryVersions: ['4.0.0'],
|
libraryVersions: CALL_PROTOCOL_LIBRARY_VERSIONS,
|
||||||
minLayer: 92,
|
// Hardcoded values according to the docs
|
||||||
|
minLayer: 65,
|
||||||
maxLayer: 92,
|
maxLayer: 92,
|
||||||
udpReflector: true,
|
udpReflector: true,
|
||||||
udpP2p: true,
|
udpP2p: true,
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Api as GramJs } from '../../../lib/gramjs';
|
import { Api as GramJs } from '../../../lib/gramjs';
|
||||||
import { generateRandomInt32 } from '../../../lib/gramjs/Helpers';
|
import { generateRandomInt32 } from '../../../lib/gramjs/Helpers';
|
||||||
|
|
||||||
import type { JoinGroupCallPayload } from '../../../lib/secret-sauce';
|
import type { JoinGroupCallPayload } from '../../../lib/vibecalls';
|
||||||
import type {
|
import type {
|
||||||
ApiChat, ApiGroupCall, ApiPhoneCall, ApiUser,
|
ApiChat, ApiGroupCall, ApiPeer, ApiPhoneCall, ApiUser,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
|
||||||
import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../limits';
|
import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../limits';
|
||||||
@ -18,6 +18,14 @@ import {
|
|||||||
import { sendApiUpdate } from '../updates/apiUpdateEmitter';
|
import { sendApiUpdate } from '../updates/apiUpdateEmitter';
|
||||||
import { invokeRequest, invokeRequestBeacon } from './client';
|
import { invokeRequest, invokeRequestBeacon } from './client';
|
||||||
|
|
||||||
|
const MAX_SIGNED_INT64 = (1n << 63n) - 1n;
|
||||||
|
const UINT64_MOD = 1n << 64n;
|
||||||
|
|
||||||
|
function buildSignedLong(value: string) {
|
||||||
|
const parsed = BigInt(value);
|
||||||
|
return parsed > MAX_SIGNED_INT64 ? parsed - UINT64_MOD : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGroupCall({
|
export async function getGroupCall({
|
||||||
call,
|
call,
|
||||||
}: {
|
}: {
|
||||||
@ -127,13 +135,13 @@ export async function fetchGroupCallParticipants({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function leaveGroupCall({
|
export function leaveGroupCall({
|
||||||
call, isPageUnload,
|
call, isPageUnload, source,
|
||||||
}: {
|
}: {
|
||||||
call: ApiGroupCall; isPageUnload?: boolean;
|
call: ApiGroupCall; isPageUnload?: boolean; source?: number;
|
||||||
}) {
|
}) {
|
||||||
const request = new GramJs.phone.LeaveGroupCall({
|
const request = new GramJs.phone.LeaveGroupCall({
|
||||||
call: buildInputGroupCall(call),
|
call: buildInputGroupCall(call),
|
||||||
source: DEFAULT_PRIMITIVES.INT,
|
source: source ?? DEFAULT_PRIMITIVES.INT,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isPageUnload) {
|
if (isPageUnload) {
|
||||||
@ -141,19 +149,19 @@ export function leaveGroupCall({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
invokeRequest(request, {
|
return invokeRequest(request, {
|
||||||
shouldReturnTrue: true,
|
shouldReturnTrue: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinGroupCall({
|
export async function joinGroupCall({
|
||||||
call, inviteHash, params,
|
call, inviteHash, params, joinAs,
|
||||||
}: {
|
}: {
|
||||||
call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload;
|
call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload; joinAs?: ApiPeer;
|
||||||
}) {
|
}) {
|
||||||
const result = await invokeRequest(new GramJs.phone.JoinGroupCall({
|
const result = await invokeRequest(new GramJs.phone.JoinGroupCall({
|
||||||
call: buildInputGroupCall(call),
|
call: buildInputGroupCall(call),
|
||||||
joinAs: new GramJs.InputPeerSelf(),
|
joinAs: joinAs ? buildInputPeer(joinAs.id, joinAs.accessHash) : new GramJs.InputPeerSelf(),
|
||||||
muted: true,
|
muted: true,
|
||||||
videoStopped: true,
|
videoStopped: true,
|
||||||
params: new GramJs.DataJSON({
|
params: new GramJs.DataJSON({
|
||||||
@ -287,7 +295,7 @@ export async function requestCall({
|
|||||||
randomId: generateRandomInt32(),
|
randomId: generateRandomInt32(),
|
||||||
userId: buildInputUser(user.id, user.accessHash),
|
userId: buildInputUser(user.id, user.accessHash),
|
||||||
gAHash: Buffer.from(gAHash),
|
gAHash: Buffer.from(gAHash),
|
||||||
...(isVideo && { video: true }),
|
video: isVideo ? true : undefined,
|
||||||
protocol: buildCallProtocol(),
|
protocol: buildCallProtocol(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -362,7 +370,7 @@ export async function confirmCall({
|
|||||||
const result = await invokeRequest(new GramJs.phone.ConfirmCall({
|
const result = await invokeRequest(new GramJs.phone.ConfirmCall({
|
||||||
peer: buildInputPhoneCall(call),
|
peer: buildInputPhoneCall(call),
|
||||||
gA: Buffer.from(gA),
|
gA: Buffer.from(gA),
|
||||||
keyFingerprint: BigInt(keyFingerprint),
|
keyFingerprint: buildSignedLong(keyFingerprint),
|
||||||
protocol: buildCallProtocol(),
|
protocol: buildCallProtocol(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -390,3 +398,18 @@ export function sendSignalingData({
|
|||||||
peer: buildInputPhoneCall(call),
|
peer: buildInputPhoneCall(call),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchCallConfig() {
|
||||||
|
const result = await invokeRequest(new GramJs.phone.GetCallConfig());
|
||||||
|
if (!result) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(result.data);
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown> : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { AuthKey } from '../../../lib/gramjs/crypto/AuthKey';
|
import { gunzipSync, gzipSync } from 'fflate';
|
||||||
import { Logger } from '../../../lib/gramjs/extensions';
|
import { CTR } from '../../../lib/gramjs/crypto/CTR';
|
||||||
import {
|
import {
|
||||||
convertToLittle, getByteArray, modExp, readBigIntFromBuffer, sha1, sha256,
|
getByteArray, modExp, readBigIntFromBuffer, readBufferFromBigInt, sha1, sha256,
|
||||||
} from '../../../lib/gramjs/Helpers';
|
} from '../../../lib/gramjs/Helpers';
|
||||||
import MTProtoState from '../../../lib/gramjs/network/MTProtoState';
|
|
||||||
|
import { isSctpPacket, SctpSignaling } from './sctpSignaling';
|
||||||
|
|
||||||
type DhConfig = {
|
type DhConfig = {
|
||||||
p: number[];
|
p: number[];
|
||||||
@ -14,10 +15,16 @@ type DhConfig = {
|
|||||||
let currentPhoneCallState: PhoneCallState | undefined;
|
let currentPhoneCallState: PhoneCallState | undefined;
|
||||||
|
|
||||||
class PhoneCallState {
|
class PhoneCallState {
|
||||||
private state?: MTProtoState;
|
private authKey?: Buffer;
|
||||||
|
|
||||||
|
private sctp = new SctpSignaling();
|
||||||
|
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
|
|
||||||
|
private maxInboundSeq = 0;
|
||||||
|
|
||||||
|
private inboundSeqs = new Set<number>();
|
||||||
|
|
||||||
private gA?: bigint;
|
private gA?: bigint;
|
||||||
|
|
||||||
private gB?: bigint;
|
private gB?: bigint;
|
||||||
@ -30,14 +37,27 @@ class PhoneCallState {
|
|||||||
|
|
||||||
private resolveState?: VoidFunction;
|
private resolveState?: VoidFunction;
|
||||||
|
|
||||||
|
private isDestroyed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private isOutgoing: boolean,
|
private isOutgoing: boolean,
|
||||||
|
private shouldUseSctp = true,
|
||||||
) {
|
) {
|
||||||
this.waitForState = new Promise<void>((resolve) => {
|
this.waitForState = new Promise<void>((resolve) => {
|
||||||
this.resolveState = resolve;
|
this.resolveState = resolve;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.isDestroyed = true;
|
||||||
|
this.resolveState?.();
|
||||||
|
this.resolveState = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShouldUseSctp(shouldUseSctp: boolean) {
|
||||||
|
this.shouldUseSctp = shouldUseSctp;
|
||||||
|
}
|
||||||
|
|
||||||
async requestCall({ p, g, random }: DhConfig) {
|
async requestCall({ p, g, random }: DhConfig) {
|
||||||
const pBN = readBigIntFromBuffer(Buffer.from(p), false);
|
const pBN = readBigIntFromBuffer(Buffer.from(p), false);
|
||||||
const randomBN = readBigIntFromBuffer(Buffer.from(random), false);
|
const randomBN = readBigIntFromBuffer(Buffer.from(random), false);
|
||||||
@ -80,7 +100,7 @@ class PhoneCallState {
|
|||||||
this.p,
|
this.p,
|
||||||
);
|
);
|
||||||
const fingerprint: Buffer = await sha1(getByteArray(authKey));
|
const fingerprint: Buffer = await sha1(getByteArray(authKey));
|
||||||
const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8).reverse(), false);
|
const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8), true, true);
|
||||||
|
|
||||||
const emojis = await generateEmojiFingerprint(
|
const emojis = await generateEmojiFingerprint(
|
||||||
getByteArray(authKey),
|
getByteArray(authKey),
|
||||||
@ -89,35 +109,142 @@ class PhoneCallState {
|
|||||||
emojiOffsets,
|
emojiOffsets,
|
||||||
);
|
);
|
||||||
|
|
||||||
const key = new AuthKey();
|
this.authKey = readBufferFromBigInt(authKey, 256, false);
|
||||||
await key.setKey(getByteArray(authKey));
|
this.resolveState?.();
|
||||||
this.state = new MTProtoState(key, new Logger(), true, this.isOutgoing);
|
this.resolveState = undefined;
|
||||||
this.resolveState!();
|
|
||||||
|
|
||||||
return { gA: Array.from(getByteArray(this.gA!)), keyFingerprint: keyFingerprint.toString(), emojis };
|
return { gA: Array.from(getByteArray(this.gA!)), keyFingerprint: keyFingerprint.toString(), emojis };
|
||||||
}
|
}
|
||||||
|
|
||||||
async encode(data: string) {
|
private async calcKey(msgKey: Buffer, isClient: boolean) {
|
||||||
if (!this.state) return undefined;
|
if (!this.authKey) {
|
||||||
|
throw new Error('Auth key unset');
|
||||||
|
}
|
||||||
|
|
||||||
const seqArray = new Uint32Array(1);
|
const x = 128 + (this.isOutgoing !== isClient ? 8 : 0);
|
||||||
seqArray[0] = this.seq++;
|
const [sha256a, sha256b] = await Promise.all([
|
||||||
const encodedData = await this.state.encryptMessageData(
|
sha256(Buffer.concat([msgKey, this.authKey.slice(x, x + 36)])),
|
||||||
Buffer.concat([convertToLittle(seqArray), Buffer.from(data)]),
|
sha256(Buffer.concat([this.authKey.slice(x + 40, x + 76), msgKey])),
|
||||||
);
|
]);
|
||||||
return Array.from(encodedData);
|
|
||||||
|
return {
|
||||||
|
key: Buffer.concat([sha256a.slice(0, 8), sha256b.slice(8, 24), sha256a.slice(24, 32)]),
|
||||||
|
iv: Buffer.concat([sha256b.slice(0, 4), sha256a.slice(8, 16), sha256b.slice(24, 28)]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(data: unknown) {
|
||||||
|
if (!this.authKey) return undefined;
|
||||||
|
|
||||||
|
const message = Buffer.from(gzipSync(Buffer.from(JSON.stringify(data))));
|
||||||
|
const packet = Buffer.alloc(4 + message.length);
|
||||||
|
packet.writeUInt32BE(++this.seq, 0);
|
||||||
|
message.copy(packet, 4);
|
||||||
|
|
||||||
|
const x = 128 + (this.isOutgoing ? 0 : 8);
|
||||||
|
const msgKeyLarge = await sha256(Buffer.concat([this.authKey.slice(88 + x, 88 + x + 32), packet]));
|
||||||
|
const msgKey = msgKeyLarge.slice(8, 24);
|
||||||
|
const { key, iv } = await this.calcKey(msgKey, true);
|
||||||
|
const encrypted = new CTR(key, iv).encrypt(packet);
|
||||||
|
const body = Buffer.concat([msgKey, encrypted]);
|
||||||
|
|
||||||
|
return this.shouldUseSctp ? this.sctp.wrapPayload(body) : Array.from(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
async decode(data: number[]): Promise<any> {
|
async decode(data: number[]): Promise<any> {
|
||||||
if (!this.state) {
|
if (this.isDestroyed) {
|
||||||
return this.waitForState.then(() => {
|
return undefined;
|
||||||
return this.decode(data);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await this.state.decryptMessageData(Buffer.from(data)) as Buffer;
|
if (!this.authKey) {
|
||||||
|
await this.waitForState;
|
||||||
|
if (this.isDestroyed || !this.authKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.decode(data);
|
||||||
|
}
|
||||||
|
|
||||||
return JSON.parse(message.toString());
|
const incoming = Buffer.from(data);
|
||||||
|
const payloads = isSctpPacket(incoming) ? this.sctp.receive(incoming) : [];
|
||||||
|
const bodies = payloads.length ? payloads : [incoming];
|
||||||
|
const messages = [];
|
||||||
|
for (const body of bodies) {
|
||||||
|
const message = await this.decodeBody(body);
|
||||||
|
if (message) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length > 1) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async decodeBody(body: Buffer): Promise<any> {
|
||||||
|
if (body.length < 21) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const authKey = this.authKey;
|
||||||
|
if (!authKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgKey = body.slice(0, 16);
|
||||||
|
const encryptedData = body.slice(16);
|
||||||
|
const { key, iv } = await this.calcKey(msgKey, false);
|
||||||
|
const decrypted = new CTR(key, iv).decrypt(encryptedData);
|
||||||
|
|
||||||
|
const x = 128 + (this.isOutgoing ? 8 : 0);
|
||||||
|
const msgKeyLarge = await sha256(Buffer.concat([authKey.slice(88 + x, 88 + x + 32), decrypted]));
|
||||||
|
if (!msgKey.equals(msgKeyLarge.slice(8, 24))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decrypted.length < 4) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboundSeq = decrypted.readUInt32BE(0);
|
||||||
|
if (!this.shouldAcceptInboundSeq(inboundSeq)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = decrypted.slice(4);
|
||||||
|
try {
|
||||||
|
const payload = message[0] === 0x1F && message[1] === 0x8B ? Buffer.from(gunzipSync(message)) : message;
|
||||||
|
this.markInboundSeq(inboundSeq);
|
||||||
|
return JSON.parse(payload.toString());
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldAcceptInboundSeq(seq: number) {
|
||||||
|
return Boolean(seq && seq > this.maxInboundSeq - 64 && !this.inboundSeqs.has(seq));
|
||||||
|
}
|
||||||
|
|
||||||
|
private markInboundSeq(seq: number) {
|
||||||
|
this.inboundSeqs.add(seq);
|
||||||
|
if (seq > this.maxInboundSeq) {
|
||||||
|
this.maxInboundSeq = seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minSeq = this.maxInboundSeq - 64;
|
||||||
|
this.inboundSeqs.forEach((item) => {
|
||||||
|
if (item <= minSeq) {
|
||||||
|
this.inboundSeqs.delete(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drainSignalingData() {
|
||||||
|
if (!this.shouldUseSctp) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sctp.drainPackets();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,11 +277,22 @@ async function generateEmojiFingerprint(
|
|||||||
return result.join('');
|
return result.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPhoneCallState(params: ConstructorParameters<typeof PhoneCallState>) {
|
export function createPhoneCallState({
|
||||||
currentPhoneCallState = new PhoneCallState(...params);
|
isOutgoing,
|
||||||
|
shouldUseSctp = true,
|
||||||
|
}: {
|
||||||
|
isOutgoing: boolean;
|
||||||
|
shouldUseSctp?: boolean;
|
||||||
|
}) {
|
||||||
|
currentPhoneCallState = new PhoneCallState(isOutgoing, shouldUseSctp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPhoneCallSctpEnabled(shouldUseSctp: boolean) {
|
||||||
|
currentPhoneCallState?.setShouldUseSctp(shouldUseSctp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function destroyPhoneCallState() {
|
export function destroyPhoneCallState() {
|
||||||
|
currentPhoneCallState?.destroy();
|
||||||
currentPhoneCallState = undefined;
|
currentPhoneCallState = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,6 +317,10 @@ export async function decodePhoneCallData(params: ParamsOf<'decode'>) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function drainPhoneCallSignalingData() {
|
||||||
|
return currentPhoneCallState?.drainSignalingData() || [];
|
||||||
|
}
|
||||||
|
|
||||||
export function confirmPhoneCall(params: ParamsOf<'confirmCall'>): ReturnTypeOf<'confirmCall'> {
|
export function confirmPhoneCall(params: ParamsOf<'confirmCall'>): ReturnTypeOf<'confirmCall'> {
|
||||||
return currentPhoneCallState!.confirmCall(...params);
|
return currentPhoneCallState!.confirmCall(...params);
|
||||||
}
|
}
|
||||||
|
|||||||
640
src/api/gramjs/methods/sctpSignaling.ts
Normal file
640
src/api/gramjs/methods/sctpSignaling.ts
Normal file
@ -0,0 +1,640 @@
|
|||||||
|
import { DEBUG_CALLS } from '../../../config';
|
||||||
|
|
||||||
|
type SctpChunk = {
|
||||||
|
type: number;
|
||||||
|
flags: number;
|
||||||
|
body: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SctpDataChunk = {
|
||||||
|
flags: number;
|
||||||
|
body: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCTP_PORT = 5000;
|
||||||
|
const SCTP_RECEIVE_WINDOW = 0x500000;
|
||||||
|
const SCTP_MAX_PENDING_PEER_DATA_CHUNKS = 256;
|
||||||
|
const SCTP_MAX_PENDING_PEER_DATA_BYTES = 0x100000;
|
||||||
|
const SCTP_MAX_PENDING_PEER_TSN_GAP = 1024;
|
||||||
|
const SCTP_INIT_RETRY_DELAY = 1000;
|
||||||
|
const SCTP_MAX_INIT_RETRY_DELAY = 8000;
|
||||||
|
const SCTP_STREAM_ID = 0;
|
||||||
|
const SCTP_BINARY_PPID = 53;
|
||||||
|
const SCTP_INIT = 1;
|
||||||
|
const SCTP_INIT_ACK = 2;
|
||||||
|
const SCTP_SACK = 3;
|
||||||
|
const SCTP_HEARTBEAT = 4;
|
||||||
|
const SCTP_HEARTBEAT_ACK = 5;
|
||||||
|
const SCTP_ABORT = 6;
|
||||||
|
const SCTP_STATE_COOKIE = 7;
|
||||||
|
const SCTP_COOKIE_ECHO = 10;
|
||||||
|
const SCTP_COOKIE_ACK = 11;
|
||||||
|
const SCTP_DATA = 0;
|
||||||
|
|
||||||
|
const CRC32C_TABLE = createCrc32cTable();
|
||||||
|
|
||||||
|
export class SctpSignaling {
|
||||||
|
private localTag = generateUint32();
|
||||||
|
|
||||||
|
private localTsn = generateUint32();
|
||||||
|
|
||||||
|
private localSsn = 0;
|
||||||
|
|
||||||
|
private peerTag?: number;
|
||||||
|
|
||||||
|
private peerInitialTsn?: number;
|
||||||
|
|
||||||
|
private peerCumulativeTsn?: number;
|
||||||
|
|
||||||
|
private initSent = false;
|
||||||
|
|
||||||
|
private initSentAt = 0;
|
||||||
|
|
||||||
|
private initRetryCount = 0;
|
||||||
|
|
||||||
|
private isEstablished = false;
|
||||||
|
|
||||||
|
private cookie = Buffer.alloc(0);
|
||||||
|
|
||||||
|
private pendingPayloads: Buffer[] = [];
|
||||||
|
|
||||||
|
private pendingPackets: number[][] = [];
|
||||||
|
|
||||||
|
private pendingPeerData = new Map<number, SctpDataChunk>();
|
||||||
|
|
||||||
|
private pendingPeerDataSize = 0;
|
||||||
|
|
||||||
|
private reassembly?: Buffer[];
|
||||||
|
|
||||||
|
wrapPayload(payload: Buffer) {
|
||||||
|
if (this.isEstablished && this.peerTag !== undefined) {
|
||||||
|
return Array.from(this.createDataPacket(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPayloads.push(payload);
|
||||||
|
if (!this.initSent) {
|
||||||
|
return this.createInitRetryPacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldRetryInit()) {
|
||||||
|
logSctp('INIT retrying', {
|
||||||
|
retryCount: this.initRetryCount,
|
||||||
|
pendingPayloadCount: this.pendingPayloads.length,
|
||||||
|
});
|
||||||
|
return this.createInitRetryPacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRetryInit() {
|
||||||
|
if (!this.initSentAt || this.peerTag !== undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date.now() - this.initSentAt >= this.getInitRetryDelay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitRetryDelay() {
|
||||||
|
if (!this.initRetryCount) {
|
||||||
|
return SCTP_INIT_RETRY_DELAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(SCTP_INIT_RETRY_DELAY * 2 ** (this.initRetryCount - 1), SCTP_MAX_INIT_RETRY_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createInitRetryPacket() {
|
||||||
|
this.initSent = true;
|
||||||
|
this.initSentAt = Date.now();
|
||||||
|
this.initRetryCount++;
|
||||||
|
return Array.from(this.createInitPacket());
|
||||||
|
}
|
||||||
|
|
||||||
|
drainPackets() {
|
||||||
|
const result = this.pendingPackets;
|
||||||
|
this.pendingPackets = [];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
receive(packet: Buffer) {
|
||||||
|
const payloads: Buffer[] = [];
|
||||||
|
if (packet.length < 12) {
|
||||||
|
logSctp('packet dropped: too short', {
|
||||||
|
length: packet.length,
|
||||||
|
});
|
||||||
|
return payloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidSctpChecksum(packet)) {
|
||||||
|
logSctp('packet dropped: invalid CRC32C', {
|
||||||
|
length: packet.length,
|
||||||
|
sourcePort: packet.readUInt16BE(0),
|
||||||
|
destinationPort: packet.readUInt16BE(2),
|
||||||
|
verificationTag: packet.readUInt32BE(4),
|
||||||
|
});
|
||||||
|
return payloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = parseSctpChunks(packet);
|
||||||
|
chunks.forEach((chunk) => {
|
||||||
|
if (!this.validateVerificationTag(packet, chunk.type)) {
|
||||||
|
logSctp('chunk dropped: invalid verification tag', {
|
||||||
|
chunkType: chunk.type,
|
||||||
|
verificationTag: packet.readUInt32BE(4),
|
||||||
|
expectedVerificationTag: chunk.type === SCTP_INIT ? 0 : this.localTag,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.type === SCTP_INIT) {
|
||||||
|
this.handleInit(chunk.body);
|
||||||
|
} else if (chunk.type === SCTP_INIT_ACK) {
|
||||||
|
this.handleInitAck(chunk.body);
|
||||||
|
} else if (chunk.type === SCTP_COOKIE_ECHO) {
|
||||||
|
if (!this.validateCookieEcho(chunk.body)) {
|
||||||
|
logSctp('COOKIE_ECHO ignored: invalid cookie', {
|
||||||
|
cookieLength: chunk.body.length,
|
||||||
|
expectedCookieLength: this.cookie.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ACK, 0, Buffer.alloc(0))));
|
||||||
|
this.markEstablished();
|
||||||
|
} else if (chunk.type === SCTP_COOKIE_ACK) {
|
||||||
|
this.markEstablished();
|
||||||
|
} else if (chunk.type === SCTP_DATA) {
|
||||||
|
payloads.push(...this.handleData(chunk.flags, chunk.body));
|
||||||
|
} else if (chunk.type === SCTP_SACK) {
|
||||||
|
this.handleSack(chunk.body);
|
||||||
|
} else if (chunk.type === SCTP_HEARTBEAT) {
|
||||||
|
this.pendingPackets.push(Array.from(this.createPacket(SCTP_HEARTBEAT_ACK, 0, chunk.body)));
|
||||||
|
} else if (chunk.type === SCTP_HEARTBEAT_ACK) {
|
||||||
|
// Nothing to do; accepting the chunk prevents Firefox/native peers from being logged as unsupported.
|
||||||
|
} else if (chunk.type === SCTP_ABORT) {
|
||||||
|
logSctp('ABORT received; resetting association', {
|
||||||
|
bodyLength: chunk.body.length,
|
||||||
|
});
|
||||||
|
this.resetAssociation();
|
||||||
|
} else {
|
||||||
|
logSctp('chunk ignored: unsupported type', {
|
||||||
|
chunkType: chunk.type,
|
||||||
|
flags: chunk.flags,
|
||||||
|
bodyLength: chunk.body.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return payloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateCookieEcho(cookie: Buffer) {
|
||||||
|
return Boolean(this.cookie.length && cookie.length === this.cookie.length && cookie.equals(this.cookie));
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateVerificationTag(packet: Buffer, chunkType: number) {
|
||||||
|
const verificationTag = packet.readUInt32BE(4);
|
||||||
|
if (chunkType === SCTP_INIT) {
|
||||||
|
return verificationTag === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return verificationTag === this.localTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInit(body: Buffer) {
|
||||||
|
if (body.length < 16) {
|
||||||
|
logSctp('INIT ignored: body too short', {
|
||||||
|
bodyLength: body.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peerTag = body.readUInt32BE(0);
|
||||||
|
this.peerInitialTsn = body.readUInt32BE(12);
|
||||||
|
this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0;
|
||||||
|
this.initSent = true;
|
||||||
|
this.cookie = this.createCookie();
|
||||||
|
this.pendingPackets.push(Array.from(this.createInitAckPacket()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInitAck(body: Buffer) {
|
||||||
|
if (body.length < 16) {
|
||||||
|
logSctp('INIT_ACK ignored: body too short', {
|
||||||
|
bodyLength: body.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peerTag = body.readUInt32BE(0);
|
||||||
|
this.peerInitialTsn = body.readUInt32BE(12);
|
||||||
|
this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0;
|
||||||
|
|
||||||
|
const cookie = findSctpParameter(body.slice(16), SCTP_STATE_COOKIE);
|
||||||
|
if (cookie) {
|
||||||
|
this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ECHO, 0, cookie)));
|
||||||
|
} else {
|
||||||
|
logSctp('INIT_ACK ignored: missing state cookie', {
|
||||||
|
bodyLength: body.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleData(flags: number, body: Buffer) {
|
||||||
|
if (body.length < 12) {
|
||||||
|
logSctp('DATA ignored: body too short', {
|
||||||
|
bodyLength: body.length,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsn = body.readUInt32BE(0);
|
||||||
|
const streamId = body.readUInt16BE(4);
|
||||||
|
const ppid = body.readUInt32BE(8);
|
||||||
|
if (streamId !== SCTP_STREAM_ID || ppid !== SCTP_BINARY_PPID) {
|
||||||
|
logSctp('DATA ignored: unsupported stream or PPID', {
|
||||||
|
streamId,
|
||||||
|
ppid,
|
||||||
|
expectedStreamId: SCTP_STREAM_ID,
|
||||||
|
expectedPpid: SCTP_BINARY_PPID,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedTsn = this.getNextPeerTsn();
|
||||||
|
if (expectedTsn !== undefined && tsn !== expectedTsn) {
|
||||||
|
if (isTsnAfter(tsn, expectedTsn)) {
|
||||||
|
this.bufferPendingPeerData(tsn, flags, body, expectedTsn);
|
||||||
|
} else {
|
||||||
|
logSctp('DATA ignored: duplicate TSN', {
|
||||||
|
tsn,
|
||||||
|
peerCumulativeTsn: this.peerCumulativeTsn,
|
||||||
|
expectedTsn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.pendingPackets.push(Array.from(this.createSackPacket()));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.acceptData(tsn, flags, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private acceptData(tsn: number, flags: number, body: Buffer) {
|
||||||
|
const payloads: Buffer[] = [];
|
||||||
|
let currentTsn = tsn;
|
||||||
|
let currentFlags = flags;
|
||||||
|
let currentBody = body;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const payload = this.readDataPayload(currentTsn, currentFlags, currentBody);
|
||||||
|
if (payload) {
|
||||||
|
payloads.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTsn = this.getNextPeerTsn();
|
||||||
|
const nextChunk = nextTsn === undefined ? undefined : this.pendingPeerData.get(nextTsn);
|
||||||
|
if (nextTsn === undefined || !nextChunk) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deletePendingPeerData(nextTsn);
|
||||||
|
currentTsn = nextTsn;
|
||||||
|
currentFlags = nextChunk.flags;
|
||||||
|
currentBody = nextChunk.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPackets.push(Array.from(this.createSackPacket()));
|
||||||
|
return payloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDataPayload(tsn: number, flags: number, body: Buffer) {
|
||||||
|
this.markEstablished();
|
||||||
|
this.peerCumulativeTsn = tsn;
|
||||||
|
|
||||||
|
const userData = body.slice(12);
|
||||||
|
const isBegin = Boolean(flags & 0x02);
|
||||||
|
const isEnd = Boolean(flags & 0x01);
|
||||||
|
if (isBegin && isEnd) {
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBegin) {
|
||||||
|
this.reassembly = [userData];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.reassembly) {
|
||||||
|
logSctp('DATA ignored: missing reassembly start', {
|
||||||
|
flags,
|
||||||
|
tsn,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reassembly.push(userData);
|
||||||
|
if (!isEnd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Buffer.concat(this.reassembly);
|
||||||
|
this.reassembly = undefined;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSack(body: Buffer) {
|
||||||
|
if (body.length < 12) {
|
||||||
|
logSctp('SACK ignored: body too short', {
|
||||||
|
bodyLength: body.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.markEstablished();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNextPeerTsn() {
|
||||||
|
if (this.peerCumulativeTsn === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (this.peerCumulativeTsn + 1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bufferPendingPeerData(tsn: number, flags: number, body: Buffer, expectedTsn: number) {
|
||||||
|
if (this.pendingPeerData.has(tsn)) {
|
||||||
|
logSctp('DATA ignored: already buffered TSN', {
|
||||||
|
tsn,
|
||||||
|
expectedTsn,
|
||||||
|
pendingCount: this.pendingPeerData.size,
|
||||||
|
pendingBytes: this.pendingPeerDataSize,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsnGap = getForwardTsnGap(tsn, expectedTsn);
|
||||||
|
if (
|
||||||
|
tsnGap > SCTP_MAX_PENDING_PEER_TSN_GAP
|
||||||
|
|| this.pendingPeerData.size >= SCTP_MAX_PENDING_PEER_DATA_CHUNKS
|
||||||
|
|| this.pendingPeerDataSize + body.length > SCTP_MAX_PENDING_PEER_DATA_BYTES
|
||||||
|
) {
|
||||||
|
logSctp('DATA ignored: TSN gap buffer full', {
|
||||||
|
tsn,
|
||||||
|
peerCumulativeTsn: this.peerCumulativeTsn,
|
||||||
|
expectedTsn,
|
||||||
|
tsnGap,
|
||||||
|
pendingCount: this.pendingPeerData.size,
|
||||||
|
pendingBytes: this.pendingPeerDataSize,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPeerData.set(tsn, { flags, body });
|
||||||
|
this.pendingPeerDataSize += body.length;
|
||||||
|
logSctp('DATA buffered: TSN gap', {
|
||||||
|
tsn,
|
||||||
|
peerCumulativeTsn: this.peerCumulativeTsn,
|
||||||
|
expectedTsn,
|
||||||
|
tsnGap,
|
||||||
|
pendingCount: this.pendingPeerData.size,
|
||||||
|
pendingBytes: this.pendingPeerDataSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private deletePendingPeerData(tsn: number) {
|
||||||
|
const chunk = this.pendingPeerData.get(tsn);
|
||||||
|
if (!chunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingPeerDataSize -= chunk.body.length;
|
||||||
|
this.pendingPeerData.delete(tsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPendingPeerData() {
|
||||||
|
this.pendingPeerData.clear();
|
||||||
|
this.pendingPeerDataSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushPendingPayloads() {
|
||||||
|
if (this.peerTag === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloads = this.pendingPayloads;
|
||||||
|
this.pendingPayloads = [];
|
||||||
|
payloads.forEach((payload) => {
|
||||||
|
this.pendingPackets.push(Array.from(this.createDataPacket(payload)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private markEstablished() {
|
||||||
|
if (this.isEstablished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isEstablished = true;
|
||||||
|
this.flushPendingPayloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetAssociation() {
|
||||||
|
this.localTag = generateUint32();
|
||||||
|
this.localTsn = generateUint32();
|
||||||
|
this.localSsn = 0;
|
||||||
|
this.peerTag = undefined;
|
||||||
|
this.peerInitialTsn = undefined;
|
||||||
|
this.peerCumulativeTsn = undefined;
|
||||||
|
this.initSent = false;
|
||||||
|
this.initSentAt = 0;
|
||||||
|
this.initRetryCount = 0;
|
||||||
|
this.isEstablished = false;
|
||||||
|
this.cookie = Buffer.alloc(0);
|
||||||
|
this.pendingPayloads = [];
|
||||||
|
this.pendingPackets = [];
|
||||||
|
this.clearPendingPeerData();
|
||||||
|
this.reassembly = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createInitPacket() {
|
||||||
|
const body = Buffer.alloc(16);
|
||||||
|
body.writeUInt32BE(this.localTag, 0);
|
||||||
|
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
|
||||||
|
body.writeUInt16BE(0xFFFF, 8);
|
||||||
|
body.writeUInt16BE(0xFFFF, 10);
|
||||||
|
body.writeUInt32BE(this.localTsn, 12);
|
||||||
|
return this.createPacket(SCTP_INIT, 0, body, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createInitAckPacket() {
|
||||||
|
const body = Buffer.alloc(16);
|
||||||
|
body.writeUInt32BE(this.localTag, 0);
|
||||||
|
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
|
||||||
|
body.writeUInt16BE(0xFFFF, 8);
|
||||||
|
body.writeUInt16BE(0xFFFF, 10);
|
||||||
|
body.writeUInt32BE(this.localTsn, 12);
|
||||||
|
|
||||||
|
return this.createPacket(SCTP_INIT_ACK, 0, Buffer.concat([
|
||||||
|
body,
|
||||||
|
createSctpParameter(SCTP_STATE_COOKIE, this.cookie),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createDataPacket(payload: Buffer) {
|
||||||
|
const body = Buffer.alloc(12 + payload.length);
|
||||||
|
body.writeUInt32BE(this.localTsn, 0);
|
||||||
|
this.localTsn = (this.localTsn + 1) >>> 0;
|
||||||
|
body.writeUInt16BE(SCTP_STREAM_ID, 4);
|
||||||
|
body.writeUInt16BE(this.localSsn, 6);
|
||||||
|
this.localSsn = (this.localSsn + 1) & 0xFFFF;
|
||||||
|
body.writeUInt32BE(SCTP_BINARY_PPID, 8);
|
||||||
|
payload.copy(body, 12);
|
||||||
|
return this.createPacket(SCTP_DATA, 0x03, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSackPacket() {
|
||||||
|
const body = Buffer.alloc(12);
|
||||||
|
body.writeUInt32BE(this.peerCumulativeTsn || 0, 0);
|
||||||
|
body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4);
|
||||||
|
body.writeUInt16BE(0, 8);
|
||||||
|
body.writeUInt16BE(0, 10);
|
||||||
|
return this.createPacket(SCTP_SACK, 0, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPacket(type: number, flags: number, body: Buffer, verificationTag = this.peerTag || 0) {
|
||||||
|
const chunk = createSctpChunk(type, flags, body);
|
||||||
|
const packet = Buffer.alloc(12 + chunk.length);
|
||||||
|
packet.writeUInt16BE(SCTP_PORT, 0);
|
||||||
|
packet.writeUInt16BE(SCTP_PORT, 2);
|
||||||
|
packet.writeUInt32BE(verificationTag, 4);
|
||||||
|
chunk.copy(packet, 12);
|
||||||
|
packet.writeUInt32LE(crc32c(packet), 8);
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCookie() {
|
||||||
|
const cookie = Buffer.alloc(16);
|
||||||
|
cookie.writeUInt32BE(this.localTag, 0);
|
||||||
|
cookie.writeUInt32BE(this.peerTag || 0, 4);
|
||||||
|
cookie.writeUInt32BE(this.localTsn, 8);
|
||||||
|
cookie.writeUInt32BE(this.peerInitialTsn || 0, 12);
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSctpChunk(type: number, flags: number, body: Buffer) {
|
||||||
|
const length = 4 + body.length;
|
||||||
|
const paddedLength = align4(length);
|
||||||
|
const chunk = Buffer.alloc(paddedLength);
|
||||||
|
chunk[0] = type;
|
||||||
|
chunk[1] = flags;
|
||||||
|
chunk.writeUInt16BE(length, 2);
|
||||||
|
body.copy(chunk, 4);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSctpParameter(type: number, value: Buffer) {
|
||||||
|
const length = 4 + value.length;
|
||||||
|
const parameter = Buffer.alloc(align4(length));
|
||||||
|
parameter.writeUInt16BE(type, 0);
|
||||||
|
parameter.writeUInt16BE(length, 2);
|
||||||
|
value.copy(parameter, 4);
|
||||||
|
return parameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSctpParameter(parameters: Buffer, type: number) {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset + 4 <= parameters.length) {
|
||||||
|
const parameterType = parameters.readUInt16BE(offset);
|
||||||
|
const length = parameters.readUInt16BE(offset + 2);
|
||||||
|
if (length < 4 || offset + length > parameters.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parameterType === type) {
|
||||||
|
return parameters.slice(offset + 4, offset + length);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += align4(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSctpChunks(packet: Buffer) {
|
||||||
|
const chunks: SctpChunk[] = [];
|
||||||
|
let offset = 12;
|
||||||
|
while (offset + 4 <= packet.length) {
|
||||||
|
const type = packet[offset];
|
||||||
|
const flags = packet[offset + 1];
|
||||||
|
const length = packet.readUInt16BE(offset + 2);
|
||||||
|
if (length < 4 || offset + length > packet.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push({
|
||||||
|
type,
|
||||||
|
flags,
|
||||||
|
body: packet.slice(offset + 4, offset + length),
|
||||||
|
});
|
||||||
|
offset += align4(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSctpPacket(packet: Buffer) {
|
||||||
|
return packet.length >= 12
|
||||||
|
&& packet.readUInt16BE(0) === SCTP_PORT
|
||||||
|
&& packet.readUInt16BE(2) === SCTP_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidSctpChecksum(packet: Buffer) {
|
||||||
|
return isSctpPacket(packet) && packet.readUInt32LE(8) === crc32c(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function align4(value: number) {
|
||||||
|
return (value + 3) & ~3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUint32() {
|
||||||
|
const values = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(values);
|
||||||
|
|
||||||
|
return values[0] >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTsnAfter(tsn: number, expectedTsn: number) {
|
||||||
|
return ((tsn - expectedTsn) >>> 0) < 0x80000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForwardTsnGap(tsn: number, expectedTsn: number) {
|
||||||
|
return (tsn - expectedTsn) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCrc32cTable() {
|
||||||
|
const table: number[] = [];
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let value = i;
|
||||||
|
for (let bit = 0; bit < 8; bit++) {
|
||||||
|
value = value & 1 ? (0x82F63B78 ^ (value >>> 1)) : value >>> 1;
|
||||||
|
}
|
||||||
|
table[i] = value >>> 0;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32c(data: Buffer) {
|
||||||
|
const buffer = Buffer.from(data);
|
||||||
|
buffer.writeUInt32LE(0, 8);
|
||||||
|
let crc = 0xFFFFFFFF;
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
crc = CRC32C_TABLE[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (~crc) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSctp(message: string, data: Record<string, unknown> = {}) {
|
||||||
|
if (!DEBUG_CALLS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug(`[PhoneCall][SCTP] ${message}`, data);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { Api as GramJs, type Update } from '../../../lib/gramjs';
|
import { Api as GramJs, type Update } from '../../../lib/gramjs';
|
||||||
import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network';
|
import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network';
|
||||||
|
|
||||||
import type { GroupCallConnectionData } from '../../../lib/secret-sauce';
|
import type { GroupCallConnectionData } from '../../../lib/vibecalls';
|
||||||
import {
|
import {
|
||||||
type ApiMessage,
|
type ApiMessage,
|
||||||
type ApiMessagePoll,
|
type ApiMessagePoll,
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import type {
|
|||||||
GroupCallParticipant,
|
GroupCallParticipant,
|
||||||
VideoRotation,
|
VideoRotation,
|
||||||
VideoState,
|
VideoState,
|
||||||
} from '../../lib/secret-sauce';
|
} from '../../lib/vibecalls';
|
||||||
|
import type { PrimitiveRecord } from '../../util/primitives/primitiveRecord';
|
||||||
|
|
||||||
|
export type ApiPhoneCallCustomParameters = PrimitiveRecord;
|
||||||
|
|
||||||
export interface ApiGroupCall {
|
export interface ApiGroupCall {
|
||||||
chatId?: string;
|
chatId?: string;
|
||||||
@ -26,6 +29,8 @@ export interface ApiGroupCall {
|
|||||||
inviteHash?: string;
|
inviteHash?: string;
|
||||||
|
|
||||||
nextOffset?: string;
|
nextOffset?: string;
|
||||||
|
localSource?: number;
|
||||||
|
localJoinAsId?: string;
|
||||||
participants: Record<string, GroupCallParticipant>;
|
participants: Record<string, GroupCallParticipant>;
|
||||||
connectionState: GroupCallConnectionState;
|
connectionState: GroupCallConnectionState;
|
||||||
isSpeakerDisabled?: boolean;
|
isSpeakerDisabled?: boolean;
|
||||||
@ -50,6 +55,7 @@ export interface ApiPhoneCall {
|
|||||||
needDebug?: boolean;
|
needDebug?: boolean;
|
||||||
reason?: 'missed' | 'disconnect' | 'hangup' | 'busy';
|
reason?: 'missed' | 'disconnect' | 'hangup' | 'busy';
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
customParameters?: ApiPhoneCallCustomParameters;
|
||||||
|
|
||||||
emojis?: string;
|
emojis?: string;
|
||||||
gA?: number[];
|
gA?: number[];
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type {
|
|||||||
GroupCallParticipant,
|
GroupCallParticipant,
|
||||||
VideoRotation,
|
VideoRotation,
|
||||||
VideoState,
|
VideoState,
|
||||||
} from '../../lib/secret-sauce';
|
} from '../../lib/vibecalls';
|
||||||
import type { ThreadId, ThreadReadState, TranslationTone } from '../../types';
|
import type { ThreadId, ThreadReadState, TranslationTone } from '../../types';
|
||||||
import type { RegularLangFnParameters } from '../../util/localization';
|
import type { RegularLangFnParameters } from '../../util/localization';
|
||||||
import type { ApiBotCommand, ApiBotMenuButton } from './bots';
|
import type { ApiBotCommand, ApiBotMenuButton } from './bots';
|
||||||
|
|||||||
@ -8,10 +8,10 @@ import { getActions, withGlobal } from '../../../global';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
|
GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant,
|
||||||
} from '../../../lib/secret-sauce';
|
} from '../../../lib/vibecalls';
|
||||||
import type { VideoParticipant } from './hooks/useGroupCallVideoLayout';
|
import type { VideoParticipant } from './hooks/useGroupCallVideoLayout';
|
||||||
|
|
||||||
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce';
|
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/vibecalls';
|
||||||
import { selectChat, selectTabState } from '../../../global/selectors';
|
import { selectChat, selectTabState } from '../../../global/selectors';
|
||||||
import {
|
import {
|
||||||
selectCanInviteToActiveGroupCall,
|
selectCanInviteToActiveGroupCall,
|
||||||
|
|||||||
@ -5,10 +5,10 @@ import {
|
|||||||
import { withGlobal } from '../../../global';
|
import { withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { ApiPeer } from '../../../api/types';
|
import type { ApiPeer } from '../../../api/types';
|
||||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
|
||||||
|
|
||||||
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
|
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
|
||||||
import { THRESHOLD } from '../../../lib/secret-sauce';
|
import { THRESHOLD } from '../../../lib/vibecalls';
|
||||||
import { selectChat, selectUser } from '../../../global/selectors';
|
import { selectChat, selectUser } from '../../../global/selectors';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
import renderText from '../../common/helpers/renderText';
|
import renderText from '../../common/helpers/renderText';
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
|
|||||||
import { memo, useMemo } from '../../../lib/teact/teact';
|
import { memo, useMemo } from '../../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
|
||||||
|
|
||||||
import { selectActiveGroupCall } from '../../../global/selectors/calls';
|
import { selectActiveGroupCall } from '../../../global/selectors/calls';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type React from '../../../lib/teact/teact';
|
|||||||
import { memo, useEffect, useState } from '../../../lib/teact/teact';
|
import { memo, useEffect, useState } from '../../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
|
import type { GroupCallParticipant } from '../../../lib/vibecalls';
|
||||||
import type { MenuPositionOptions } from '../../ui/Menu';
|
import type { MenuPositionOptions } from '../../ui/Menu';
|
||||||
|
|
||||||
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||||
|
|||||||
@ -5,13 +5,13 @@ import {
|
|||||||
import { withGlobal } from '../../../global';
|
import { withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { ApiChat, ApiUser } from '../../../api/types';
|
import type { ApiChat, ApiUser } from '../../../api/types';
|
||||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls';
|
||||||
import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout';
|
import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout';
|
||||||
|
|
||||||
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
|
import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config';
|
||||||
import fastBlur from '../../../lib/fastBlur';
|
import fastBlur from '../../../lib/fastBlur';
|
||||||
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
|
import { requestMutation } from '../../../lib/fasterdom/fasterdom';
|
||||||
import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce';
|
import { getUserStreams, THRESHOLD } from '../../../lib/vibecalls';
|
||||||
import { selectChat, selectUser } from '../../../global/selectors';
|
import { selectChat, selectUser } from '../../../global/selectors';
|
||||||
import { animate } from '../../../util/animation';
|
import { animate } from '../../../util/animation';
|
||||||
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/browser/windowEnvironment';
|
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/browser/windowEnvironment';
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
} from '../../../lib/teact/teact';
|
} from '../../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { GroupCallConnectionState } from '../../../lib/secret-sauce';
|
import type { GroupCallConnectionState } from '../../../lib/vibecalls';
|
||||||
|
|
||||||
import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../global/selectors/calls';
|
import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../global/selectors/calls';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import type { FC } from '../../../lib/teact/teact';
|
import type { FC } from '../../../lib/teact/teact';
|
||||||
import { memo, useMemo } from '../../../lib/teact/teact';
|
import { memo, useMemo } from '../../../lib/teact/teact';
|
||||||
|
|
||||||
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
|
import type { GroupCallParticipant } from '../../../lib/vibecalls';
|
||||||
|
|
||||||
import { THRESHOLD } from '../../../lib/secret-sauce';
|
import { THRESHOLD } from '../../../lib/vibecalls';
|
||||||
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
|
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
|
||||||
|
|
||||||
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
|
import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { GroupCallParticipant } from '../../../../lib/secret-sauce';
|
import type { GroupCallParticipant } from '../../../../lib/vibecalls';
|
||||||
|
|
||||||
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../../config';
|
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../../config';
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fullscreenDialog {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type { ApiPhoneCall, ApiUser } from '../../../api/types';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getStreams, IS_SCREENSHARE_SUPPORTED, switchCameraInputP2p, toggleStreamP2p,
|
getStreams, IS_SCREENSHARE_SUPPORTED, switchCameraInputP2p, toggleStreamP2p,
|
||||||
} from '../../../lib/secret-sauce';
|
} from '../../../lib/vibecalls';
|
||||||
import { selectTabState } from '../../../global/selectors';
|
import { selectTabState } from '../../../global/selectors';
|
||||||
import { selectPhoneCallUser } from '../../../global/selectors/calls';
|
import { selectPhoneCallUser } from '../../../global/selectors/calls';
|
||||||
import {
|
import {
|
||||||
@ -59,42 +59,56 @@ const PhoneCall = ({
|
|||||||
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
|
const [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
|
||||||
const { isMobile } = useAppLayout();
|
const { isMobile } = useAppLayout();
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const isOpen = Boolean(phoneCall && phoneCall.state !== 'discarded' && !isCallPanelVisible);
|
||||||
if (isFullscreen) {
|
|
||||||
closeFullscreen();
|
const exitFullscreenIfNeeded = useCallback(() => {
|
||||||
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
|
void document.exitFullscreen().catch(() => undefined).then(closeFullscreen);
|
||||||
} else {
|
} else {
|
||||||
openFullscreen();
|
closeFullscreen();
|
||||||
}
|
}
|
||||||
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
}, [closeFullscreen]);
|
||||||
|
|
||||||
const handleToggleFullscreen = useCallback(() => {
|
const handleToggleFullscreen = useCallback(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
document.exitFullscreen().then(closeFullscreen);
|
exitFullscreenIfNeeded();
|
||||||
} else {
|
} else {
|
||||||
containerRef.current.requestFullscreen().then(openFullscreen);
|
void containerRef.current.requestFullscreen().then(openFullscreen).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
}, [exitFullscreenIfNeeded, isFullscreen, openFullscreen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined;
|
if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined;
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return undefined;
|
|
||||||
|
|
||||||
container.addEventListener('fullscreenchange', toggleFullscreen);
|
const handleFullscreenChange = () => {
|
||||||
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
|
openFullscreen();
|
||||||
|
} else {
|
||||||
|
closeFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('fullscreenchange', toggleFullscreen);
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
};
|
};
|
||||||
}, [toggleFullscreen]);
|
}, [closeFullscreen, openFullscreen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen || !isFullscreen) return;
|
||||||
|
|
||||||
|
exitFullscreenIfNeeded();
|
||||||
|
}, [exitFullscreenIfNeeded, isFullscreen, isOpen]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
toggleGroupCallPanel();
|
toggleGroupCallPanel();
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
closeFullscreen();
|
exitFullscreenIfNeeded();
|
||||||
}
|
}
|
||||||
}, [closeFullscreen, isFullscreen, toggleGroupCallPanel]);
|
}, [exitFullscreenIfNeeded, isFullscreen, toggleGroupCallPanel]);
|
||||||
|
|
||||||
const isDiscarded = phoneCall?.state === 'discarded';
|
const isDiscarded = phoneCall?.state === 'discarded';
|
||||||
const isBusy = phoneCall?.reason === 'busy';
|
const isBusy = phoneCall?.reason === 'busy';
|
||||||
@ -161,9 +175,9 @@ const PhoneCall = ({
|
|||||||
const hasPresentation = phoneCall?.screencastState === 'active';
|
const hasPresentation = phoneCall?.screencastState === 'active';
|
||||||
|
|
||||||
const streams = getStreams();
|
const streams = getStreams();
|
||||||
const hasOwnAudio = streams?.ownAudio?.getTracks()[0].enabled;
|
const hasOwnAudio = streams?.ownAudio?.getTracks()?.[0]?.enabled ?? false;
|
||||||
const hasOwnPresentation = streams?.ownPresentation?.getTracks()[0].enabled;
|
const hasOwnPresentation = streams?.ownPresentation?.getTracks()?.[0]?.enabled ?? false;
|
||||||
const hasOwnVideo = streams?.ownVideo?.getTracks()[0].enabled;
|
const hasOwnVideo = streams?.ownVideo?.getTracks()?.[0]?.enabled ?? false;
|
||||||
|
|
||||||
const [isHidingPresentation, startHidingPresentation, stopHidingPresentation] = useFlag();
|
const [isHidingPresentation, startHidingPresentation, stopHidingPresentation] = useFlag();
|
||||||
const [isHidingVideo, startHidingVideo, stopHidingVideo] = useFlag();
|
const [isHidingVideo, startHidingVideo, stopHidingVideo] = useFlag();
|
||||||
@ -228,12 +242,13 @@ const PhoneCall = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={phoneCall && phoneCall?.state !== 'discarded' && !isCallPanelVisible}
|
isOpen={isOpen}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
className={buildClassName(
|
className={buildClassName(
|
||||||
styles.root,
|
styles.root,
|
||||||
isMobile && styles.singleColumn,
|
isMobile && styles.singleColumn,
|
||||||
)}
|
)}
|
||||||
|
dialogClassName={buildClassName(isFullscreen && styles.fullscreenDialog)}
|
||||||
dialogRef={containerRef}
|
dialogRef={containerRef}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@ -249,23 +264,23 @@ const PhoneCall = ({
|
|||||||
className={buildClassName(
|
className={buildClassName(
|
||||||
styles.secondVideo,
|
styles.secondVideo,
|
||||||
!isHidingPresentation && hasOwnPresentation && styles.visible,
|
!isHidingPresentation && hasOwnPresentation && styles.visible,
|
||||||
isFullscreen && styles.fullscreen,
|
hasOwnPresentation && isFullscreen && styles.fullscreen,
|
||||||
)}
|
)}
|
||||||
muted
|
muted
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
srcObject={streams?.ownPresentation}
|
srcObject={hasOwnPresentation ? streams?.ownPresentation : undefined}
|
||||||
/>
|
/>
|
||||||
<video
|
<video
|
||||||
className={buildClassName(
|
className={buildClassName(
|
||||||
styles.secondVideo,
|
styles.secondVideo,
|
||||||
!isHidingVideo && hasOwnVideo && styles.visible,
|
!isHidingVideo && hasOwnVideo && styles.visible,
|
||||||
isFullscreen && styles.fullscreen,
|
hasOwnVideo && isFullscreen && styles.fullscreen,
|
||||||
)}
|
)}
|
||||||
muted
|
muted
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
srcObject={streams?.ownVideo}
|
srcObject={hasOwnVideo ? streams?.ownVideo : undefined}
|
||||||
/>
|
/>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{IS_REQUEST_FULLSCREEN_SUPPORTED && (
|
{IS_REQUEST_FULLSCREEN_SUPPORTED && (
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export const PAID_MESSAGES_PURPOSE = 'paid_messages';
|
|||||||
|
|
||||||
export const DEBUG = process.env.APP_ENV !== 'production';
|
export const DEBUG = process.env.APP_ENV !== 'production';
|
||||||
export const DEBUG_MORE = false;
|
export const DEBUG_MORE = false;
|
||||||
|
export const DEBUG_CALLS = false;
|
||||||
export const DEBUG_LOG_FILENAME = 'tt-log.json';
|
export const DEBUG_LOG_FILENAME = 'tt-log.json';
|
||||||
export const STRICTERDOM_ENABLED = DEBUG;
|
export const STRICTERDOM_ENABLED = DEBUG;
|
||||||
export const FORCE_FALLBACK_LANG = DEBUG;
|
export const FORCE_FALLBACK_LANG = DEBUG;
|
||||||
@ -378,6 +379,8 @@ export const FRAGMENT_PHONE_CODE = '888';
|
|||||||
export const FRAGMENT_PHONE_LENGTH = 11;
|
export const FRAGMENT_PHONE_LENGTH = 11;
|
||||||
export const BOT_VERIFICATION_PEERS_LIMIT = 20;
|
export const BOT_VERIFICATION_PEERS_LIMIT = 20;
|
||||||
|
|
||||||
|
export const CALL_PROTOCOL_LIBRARY_VERSIONS = ['13.0.0'];
|
||||||
|
|
||||||
export const LIGHT_THEME_BG_COLOR = '#99BA92';
|
export const LIGHT_THEME_BG_COLOR = '#99BA92';
|
||||||
export const DARK_THEME_BG_COLOR = '#000000';
|
export const DARK_THEME_BG_COLOR = '#000000';
|
||||||
export const DEFAULT_PATTERN_COLOR = '#4A8E3A8C';
|
export const DEFAULT_PATTERN_COLOR = '#4A8E3A8C';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { ActionReturnType } from '../../types';
|
import type { ActionReturnType } from '../../types';
|
||||||
|
|
||||||
import { GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
import { DEBUG_CALLS, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||||
import {
|
import {
|
||||||
isStreamEnabled,
|
isStreamEnabled,
|
||||||
joinGroupCall,
|
joinGroupCall,
|
||||||
@ -8,7 +8,8 @@ import {
|
|||||||
setVolume, startSharingScreen,
|
setVolume, startSharingScreen,
|
||||||
stopPhoneCall,
|
stopPhoneCall,
|
||||||
toggleStream,
|
toggleStream,
|
||||||
} from '../../../lib/secret-sauce';
|
} from '../../../lib/vibecalls';
|
||||||
|
import { logDebugMessage } from '../../../util/debugConsole';
|
||||||
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
||||||
import { callApi } from '../../../api/gramjs';
|
import { callApi } from '../../../api/gramjs';
|
||||||
import { addActionHandler, getGlobal, setGlobal } from '../../index';
|
import { addActionHandler, getGlobal, setGlobal } from '../../index';
|
||||||
@ -17,7 +18,9 @@ import {
|
|||||||
updateActiveGroupCall,
|
updateActiveGroupCall,
|
||||||
} from '../../reducers/calls';
|
} from '../../reducers/calls';
|
||||||
import { updateTabState } from '../../reducers/tabs';
|
import { updateTabState } from '../../reducers/tabs';
|
||||||
import { selectChat, selectTabState, selectUser } from '../../selectors';
|
import {
|
||||||
|
selectChat, selectPeer, selectTabState, selectUser,
|
||||||
|
} from '../../selectors';
|
||||||
import {
|
import {
|
||||||
selectActiveGroupCall, selectPhoneCallUser,
|
selectActiveGroupCall, selectPhoneCallUser,
|
||||||
} from '../../selectors/calls';
|
} from '../../selectors/calls';
|
||||||
@ -47,8 +50,11 @@ addActionHandler('leaveGroupCall', async (global, actions, payload): Promise<voi
|
|||||||
};
|
};
|
||||||
setGlobal(global);
|
setGlobal(global);
|
||||||
|
|
||||||
|
const localParticipantId = groupCall.localJoinAsId ?? global.currentUserId;
|
||||||
|
const source = groupCall.localSource
|
||||||
|
?? (localParticipantId ? groupCall.participants[localParticipantId]?.source : undefined);
|
||||||
await callApi('leaveGroupCall', {
|
await callApi('leaveGroupCall', {
|
||||||
call: groupCall, isPageUnload,
|
call: groupCall, isPageUnload, source,
|
||||||
});
|
});
|
||||||
await callApi('abortRequestGroup', 'call');
|
await callApi('abortRequestGroup', 'call');
|
||||||
|
|
||||||
@ -204,18 +210,30 @@ addActionHandler('connectToActiveGroupCall', async (global, actions, payload): P
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { currentUserId } = global;
|
||||||
currentUserId,
|
|
||||||
} = global;
|
|
||||||
|
|
||||||
if (!currentUserId) return;
|
if (!currentUserId) return;
|
||||||
|
|
||||||
const params = await joinGroupCall(currentUserId, audioContext, audioElement, actions.apiUpdate);
|
const localParticipantId = groupCall.localJoinAsId ?? currentUserId;
|
||||||
|
const joinAs = groupCall.localJoinAsId ? selectPeer(global, groupCall.localJoinAsId) : undefined;
|
||||||
|
if (groupCall.localJoinAsId && !joinAs) return;
|
||||||
|
|
||||||
|
const params = await joinGroupCall(localParticipantId, audioContext, audioElement, actions.apiUpdate);
|
||||||
|
if (!params) {
|
||||||
|
actions.showNotification({
|
||||||
|
// TODO[lang] Localize error message
|
||||||
|
message: 'Failed to join voice chat',
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
actions.leaveGroupCall({ tabId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await callApi('joinGroupCall', {
|
const result = await callApi('joinGroupCall', {
|
||||||
call: groupCall,
|
call: groupCall,
|
||||||
params,
|
params,
|
||||||
inviteHash: groupCall.inviteHash,
|
inviteHash: groupCall.inviteHash,
|
||||||
|
joinAs,
|
||||||
});
|
});
|
||||||
|
|
||||||
global = getGlobal();
|
global = getGlobal();
|
||||||
@ -230,6 +248,11 @@ addActionHandler('connectToActiveGroupCall', async (global, actions, payload): P
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.ssrc !== undefined) {
|
||||||
|
global = updateActiveGroupCall(global, { localSource: params.ssrc });
|
||||||
|
setGlobal(global);
|
||||||
|
}
|
||||||
|
|
||||||
actions.loadMoreGroupCallParticipants();
|
actions.loadMoreGroupCallParticipants();
|
||||||
|
|
||||||
if (groupCall.chatId) {
|
if (groupCall.chatId) {
|
||||||
@ -252,7 +275,10 @@ addActionHandler('connectToActivePhoneCall', async (global, actions): Promise<vo
|
|||||||
|
|
||||||
if (!dhConfig) return;
|
if (!dhConfig) return;
|
||||||
|
|
||||||
await callApi('createPhoneCallState', [true]);
|
await callApi('createPhoneCallState', {
|
||||||
|
isOutgoing: true,
|
||||||
|
shouldUseSctp: !phoneCall.customParameters?.network_signaling_nosctp,
|
||||||
|
});
|
||||||
|
|
||||||
const gAHash = await callApi('requestPhoneCall', [dhConfig]);
|
const gAHash = await callApi('requestPhoneCall', [dhConfig]);
|
||||||
|
|
||||||
@ -271,7 +297,10 @@ addActionHandler('acceptCall', async (global): Promise<void> => {
|
|||||||
const dhConfig = await callApi('getDhConfig');
|
const dhConfig = await callApi('getDhConfig');
|
||||||
if (!dhConfig) return;
|
if (!dhConfig) return;
|
||||||
|
|
||||||
await callApi('createPhoneCallState', [false]);
|
await callApi('createPhoneCallState', {
|
||||||
|
isOutgoing: false,
|
||||||
|
shouldUseSctp: !phoneCall.customParameters?.network_signaling_nosctp,
|
||||||
|
});
|
||||||
|
|
||||||
const gB = await callApi('acceptPhoneCall', [dhConfig]);
|
const gB = await callApi('acceptPhoneCall', [dhConfig]);
|
||||||
await callApi('acceptCall', { call: phoneCall, gB });
|
await callApi('acceptCall', { call: phoneCall, gB });
|
||||||
@ -283,17 +312,42 @@ addActionHandler('sendSignalingData', (global, actions, payload): ActionReturnTy
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.stringify(payload);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const encodedData = await callApi('encodePhoneCallData', [data]);
|
try {
|
||||||
|
const encodedData = await callApi('encodePhoneCallData', [payload]);
|
||||||
|
|
||||||
if (!encodedData) return;
|
if (!encodedData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
callApi('sendSignalingData', { data: encodedData, call: phoneCall });
|
await callApi('sendSignalingData', { data: encodedData, call: phoneCall });
|
||||||
|
const pendingPackets = await callApi('drainPhoneCallSignalingData');
|
||||||
|
if (!pendingPackets) return;
|
||||||
|
|
||||||
|
for (const data of pendingPackets) {
|
||||||
|
await callApi('sendSignalingData', { data, call: phoneCall });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logPhoneCallDebug('Failed to send phone call signaling data', {
|
||||||
|
error: summarizeError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function logPhoneCallDebug(message: string, data: Record<string, unknown>) {
|
||||||
|
if (!DEBUG_CALLS) return;
|
||||||
|
|
||||||
|
logDebugMessage('warn', `[PhoneCall] ${message}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeError(error: unknown) {
|
||||||
|
return error instanceof Error ? {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
} : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
addActionHandler('closeCallRatingModal', (global, actions, payload): ActionReturnType => {
|
addActionHandler('closeCallRatingModal', (global, actions, payload): ActionReturnType => {
|
||||||
const { tabId = getCurrentTabId() } = payload || {};
|
const { tabId = getCurrentTabId() } = payload || {};
|
||||||
return updateTabState(global, {
|
return updateTabState(global, {
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import type { ApiPhoneCall } from '../../../api/types';
|
import type { ApiPhoneCall, ApiPhoneCallCustomParameters } from '../../../api/types';
|
||||||
import type { ApiCallProtocol } from '../../../lib/secret-sauce';
|
import type { ApiCallProtocol } from '../../../lib/vibecalls';
|
||||||
import type { ActionReturnType } from '../../types';
|
import type { ActionReturnType } from '../../types';
|
||||||
|
|
||||||
|
import { CALL_PROTOCOL_LIBRARY_VERSIONS, DEBUG_CALLS } from '../../../config';
|
||||||
import {
|
import {
|
||||||
handleUpdateGroupCallConnection,
|
handleUpdateGroupCallConnection,
|
||||||
handleUpdateGroupCallParticipants,
|
handleUpdateGroupCallParticipants,
|
||||||
joinPhoneCall, processSignalingMessage,
|
joinPhoneCall, processSignalingMessage, sanitizePrimitiveRecord,
|
||||||
} from '../../../lib/secret-sauce';
|
} from '../../../lib/vibecalls';
|
||||||
import { ARE_CALLS_SUPPORTED } from '../../../util/browser/windowEnvironment';
|
import { ARE_CALLS_SUPPORTED } from '../../../util/browser/windowEnvironment';
|
||||||
|
import { logDebugMessage } from '../../../util/debugConsole';
|
||||||
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
import { getCurrentTabId } from '../../../util/establishMultitabRole';
|
||||||
import { omit } from '../../../util/iteratees';
|
import { omit } from '../../../util/iteratees';
|
||||||
import * as langProvider from '../../../util/oldLangProvider';
|
import * as langProvider from '../../../util/oldLangProvider';
|
||||||
@ -18,6 +20,25 @@ import { updateGroupCall, updateGroupCallParticipant } from '../../reducers/call
|
|||||||
import { updateTabState } from '../../reducers/tabs';
|
import { updateTabState } from '../../reducers/tabs';
|
||||||
import { selectActiveGroupCall, selectGroupCallParticipant, selectPhoneCallUser } from '../../selectors/calls';
|
import { selectActiveGroupCall, selectGroupCallParticipant, selectPhoneCallUser } from '../../selectors/calls';
|
||||||
|
|
||||||
|
let phoneCallSignalingDataPromise = Promise.resolve();
|
||||||
|
let groupCallNegotiationPromise = Promise.resolve();
|
||||||
|
|
||||||
|
type QueuedPhoneCallSignalingData = {
|
||||||
|
callId?: string;
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function enqueueGroupCallNegotiation(callback: () => Promise<void>) {
|
||||||
|
groupCallNegotiationPromise = groupCallNegotiationPromise
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(callback)
|
||||||
|
.catch((err) => {
|
||||||
|
logPhoneCallDebug('Failed to process group call negotiation update', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
||||||
const { activeGroupCallId } = global.groupCalls;
|
const { activeGroupCallId } = global.groupCalls;
|
||||||
|
|
||||||
@ -48,7 +69,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
|||||||
case 'updateGroupCallParticipants': {
|
case 'updateGroupCallParticipants': {
|
||||||
const { groupCallId, participants } = update;
|
const { groupCallId, participants } = update;
|
||||||
if (activeGroupCallId === groupCallId) {
|
if (activeGroupCallId === groupCallId) {
|
||||||
void handleUpdateGroupCallParticipants(participants);
|
enqueueGroupCallNegotiation(() => handleUpdateGroupCallParticipants(participants));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -58,12 +79,15 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
|||||||
if ('leaveGroupCall' in actions) actions.leaveGroupCall({ tabId: getCurrentTabId() });
|
if ('leaveGroupCall' in actions) actions.leaveGroupCall({ tabId: getCurrentTabId() });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
void handleUpdateGroupCallConnection(update.data, update.presentation);
|
enqueueGroupCallNegotiation(async () => {
|
||||||
|
await handleUpdateGroupCallConnection(update.data, update.presentation);
|
||||||
|
|
||||||
const groupCall = selectActiveGroupCall(global);
|
global = getGlobal();
|
||||||
if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) {
|
const groupCall = selectActiveGroupCall(global);
|
||||||
void handleUpdateGroupCallParticipants(Object.values(groupCall.participants));
|
if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) {
|
||||||
}
|
await handleUpdateGroupCallParticipants(Object.values(groupCall.participants));
|
||||||
|
}
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'updatePhoneCallMediaState':
|
case 'updatePhoneCallMediaState':
|
||||||
@ -128,51 +152,138 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
|||||||
}, getCurrentTabId());
|
}, getCurrentTabId());
|
||||||
} else if (state === 'accepted' && accessHash && gB) {
|
} else if (state === 'accepted' && accessHash && gB) {
|
||||||
(async () => {
|
(async () => {
|
||||||
const { gA, keyFingerprint, emojis } = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS]);
|
try {
|
||||||
|
const activeCallId = call.id;
|
||||||
global = getGlobal();
|
const result = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS]);
|
||||||
const newCall = {
|
if (!result) {
|
||||||
...global.phoneCall,
|
logPhoneCallDebug('Failed to confirm accepted phone call', {
|
||||||
emojis,
|
callId: activeCallId,
|
||||||
} as ApiPhoneCall;
|
});
|
||||||
|
return;
|
||||||
global = {
|
}
|
||||||
...global,
|
const { gA, keyFingerprint, emojis } = result;
|
||||||
phoneCall: newCall,
|
|
||||||
};
|
|
||||||
setGlobal(global);
|
|
||||||
|
|
||||||
callApi('confirmCall', {
|
|
||||||
call, gA, keyFingerprint,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
} else if (state === 'active' && connections && phoneCall?.state !== 'active') {
|
|
||||||
if (!isOutgoing) {
|
|
||||||
callApi('receivedCall', { call });
|
|
||||||
(async () => {
|
|
||||||
const { emojis } = await callApi('confirmPhoneCall', [call.gAOrB!, EMOJI_DATA, EMOJI_OFFSETS]);
|
|
||||||
|
|
||||||
global = getGlobal();
|
global = getGlobal();
|
||||||
|
if (global.phoneCall?.id !== activeCallId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callApi('confirmCall', {
|
||||||
|
call, gA, keyFingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
global = getGlobal();
|
||||||
|
if (global.phoneCall?.id !== activeCallId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newCall = {
|
const newCall = {
|
||||||
...global.phoneCall,
|
...global.phoneCall,
|
||||||
emojis,
|
emojis,
|
||||||
} as ApiPhoneCall;
|
};
|
||||||
|
|
||||||
global = {
|
global = {
|
||||||
...global,
|
...global,
|
||||||
phoneCall: newCall,
|
phoneCall: newCall,
|
||||||
};
|
};
|
||||||
setGlobal(global);
|
setGlobal(global);
|
||||||
})();
|
} catch (err) {
|
||||||
}
|
logPhoneCallDebug('Failed to confirm accepted phone call', {
|
||||||
void joinPhoneCall(
|
callId: call.id,
|
||||||
connections,
|
error: err instanceof Error ? err.message : String(err),
|
||||||
actions.sendSignalingData,
|
});
|
||||||
isOutgoing,
|
}
|
||||||
Boolean(call?.isVideo),
|
})();
|
||||||
Boolean(call.isP2pAllowed),
|
} else if (state === 'active' && connections && phoneCall?.state !== 'active') {
|
||||||
actions.apiUpdate,
|
(async () => {
|
||||||
);
|
try {
|
||||||
|
const activeCallId = call.id;
|
||||||
|
let callConfigResult: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
callConfigResult = await callApi('fetchCallConfig');
|
||||||
|
} catch (err) {
|
||||||
|
logPhoneCallDebug('Failed to fetch phone call config', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const callConfig = sanitizePrimitiveRecord(callConfigResult) || {};
|
||||||
|
const customParameters: ApiPhoneCallCustomParameters = Object.assign(
|
||||||
|
{},
|
||||||
|
callConfig,
|
||||||
|
call.customParameters,
|
||||||
|
);
|
||||||
|
call.customParameters = customParameters;
|
||||||
|
global = getGlobal();
|
||||||
|
if (global.phoneCall?.id === call.id) {
|
||||||
|
global = {
|
||||||
|
...global,
|
||||||
|
phoneCall: {
|
||||||
|
...global.phoneCall,
|
||||||
|
customParameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setGlobal(global);
|
||||||
|
}
|
||||||
|
|
||||||
|
global = getGlobal();
|
||||||
|
if (global.phoneCall?.id === call.id) {
|
||||||
|
await callApi('setPhoneCallSctpEnabled', !customParameters.network_signaling_nosctp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOutgoing) {
|
||||||
|
await callApi('receivedCall', { call });
|
||||||
|
global = getGlobal();
|
||||||
|
if (global.phoneCall?.id !== activeCallId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callApi('confirmPhoneCall', [call.gAOrB!, EMOJI_DATA, EMOJI_OFFSETS]);
|
||||||
|
if (!result) {
|
||||||
|
logPhoneCallDebug('Failed to confirm phone call', {
|
||||||
|
callId: activeCallId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { emojis } = result;
|
||||||
|
|
||||||
|
global = getGlobal();
|
||||||
|
if (global.phoneCall?.id !== activeCallId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCall = {
|
||||||
|
...global.phoneCall,
|
||||||
|
emojis,
|
||||||
|
};
|
||||||
|
|
||||||
|
global = {
|
||||||
|
...global,
|
||||||
|
phoneCall: newCall,
|
||||||
|
};
|
||||||
|
setGlobal(global);
|
||||||
|
}
|
||||||
|
|
||||||
|
global = getGlobal();
|
||||||
|
if (global.phoneCall?.id !== activeCallId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await joinPhoneCall(
|
||||||
|
connections,
|
||||||
|
actions.sendSignalingData,
|
||||||
|
isOutgoing,
|
||||||
|
Boolean(call?.isVideo),
|
||||||
|
Boolean(call.isP2pAllowed),
|
||||||
|
actions.apiUpdate,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logPhoneCallDebug('Failed to start phone call', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
callId: call.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
return global;
|
return global;
|
||||||
@ -202,7 +313,20 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
callApi('decodePhoneCallData', [update.data])?.then(processSignalingMessage);
|
const queued: QueuedPhoneCallSignalingData = {
|
||||||
|
callId: phoneCall.id,
|
||||||
|
data: update.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
phoneCallSignalingDataPromise = phoneCallSignalingDataPromise
|
||||||
|
.then(() => processPhoneCallSignalingData(queued))
|
||||||
|
.catch((err) => {
|
||||||
|
logPhoneCallDebug('Failed to process phone call signaling data', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
isSctp: isSctpSignalingData(queued.data),
|
||||||
|
length: queued.data.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -211,7 +335,79 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function verifyPhoneCallProtocol(protocol?: ApiCallProtocol) {
|
function verifyPhoneCallProtocol(protocol?: ApiCallProtocol) {
|
||||||
return protocol?.libraryVersions.some((version) => {
|
return Boolean(
|
||||||
return version === '4.0.0' || version === '4.0.1';
|
protocol
|
||||||
});
|
&& CALL_PROTOCOL_LIBRARY_VERSIONS.some((version) => protocol.libraryVersions.includes(version)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPhoneCallSignalingData(queued: QueuedPhoneCallSignalingData) {
|
||||||
|
const { data } = queued;
|
||||||
|
let global = getGlobal();
|
||||||
|
if (global.phoneCall?.id !== queued.callId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = await callApi('decodePhoneCallData', [data]);
|
||||||
|
} catch (err) {
|
||||||
|
logPhoneCallDebug('Failed to decode phone call signaling data', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
isSctp: isSctpSignalingData(data),
|
||||||
|
length: data.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global = getGlobal();
|
||||||
|
const activeCall = global.phoneCall;
|
||||||
|
if (activeCall?.id !== queued.callId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let packetCount = 0;
|
||||||
|
if (activeCall) {
|
||||||
|
try {
|
||||||
|
const packets = await callApi('drainPhoneCallSignalingData');
|
||||||
|
packetCount = packets?.length || 0;
|
||||||
|
if (packets) {
|
||||||
|
for (const packetData of packets) {
|
||||||
|
await callApi('sendSignalingData', { data: packetData, call: activeCall });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logPhoneCallDebug('Failed to drain phone call signaling data', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
isSctp: isSctpSignalingData(data),
|
||||||
|
length: data.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(message)) {
|
||||||
|
for (const item of message) {
|
||||||
|
await processSignalingMessage(item);
|
||||||
|
}
|
||||||
|
} else if (message) {
|
||||||
|
await processSignalingMessage(message);
|
||||||
|
} else if (!packetCount && !isSctpSignalingData(data)) {
|
||||||
|
logPhoneCallDebug('Failed to decode phone call signaling data', {
|
||||||
|
length: data.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logPhoneCallDebug(message: string, data: Record<string, unknown>) {
|
||||||
|
if (!DEBUG_CALLS) return;
|
||||||
|
|
||||||
|
logDebugMessage('warn', `[PhoneCall] ${message}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSctpSignalingData(data: number[]) {
|
||||||
|
return data.length >= 12
|
||||||
|
&& data[0] === 0x13
|
||||||
|
&& data[1] === 0x88
|
||||||
|
&& data[2] === 0x13
|
||||||
|
&& data[3] === 0x88;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
|
|||||||
const { groupCallId, participants, nextOffset } = update;
|
const { groupCallId, participants, nextOffset } = update;
|
||||||
const { currentUserId } = global;
|
const { currentUserId } = global;
|
||||||
|
|
||||||
// `secret-sauce` should disconnect if the participant is us but from another device
|
// `vibecalls` should disconnect if the participant is us but from another device
|
||||||
global = getGlobal();
|
global = getGlobal();
|
||||||
participants.forEach((participant) => {
|
participants.forEach((participant) => {
|
||||||
if (participant.id) {
|
if (participant.id) {
|
||||||
|
|||||||
@ -301,6 +301,7 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
|
|||||||
{
|
{
|
||||||
...groupCall,
|
...groupCall,
|
||||||
inviteHash,
|
inviteHash,
|
||||||
|
localJoinAsId: undefined,
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
groupCall.participantsCount + 1,
|
groupCall.participantsCount + 1,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ApiGroupCall } from '../../api/types';
|
import type { ApiGroupCall } from '../../api/types';
|
||||||
import type { GroupCallParticipant } from '../../lib/secret-sauce';
|
import type { GroupCallParticipant } from '../../lib/vibecalls';
|
||||||
import type { GlobalState } from '../types';
|
import type { GlobalState } from '../types';
|
||||||
|
|
||||||
import { omit } from '../../util/iteratees';
|
import { omit } from '../../util/iteratees';
|
||||||
|
|||||||
@ -70,7 +70,7 @@ import type {
|
|||||||
import type { ApiCredentials } from '../../components/payment/PaymentModal';
|
import type { ApiCredentials } from '../../components/payment/PaymentModal';
|
||||||
import type { FoldersActions } from '../../hooks/reducers/useFoldersReducer';
|
import type { FoldersActions } from '../../hooks/reducers/useFoldersReducer';
|
||||||
import type { ReducerAction } from '../../hooks/useReducer';
|
import type { ReducerAction } from '../../hooks/useReducer';
|
||||||
import type { P2pMessage } from '../../lib/secret-sauce';
|
import type { P2pMessage } from '../../lib/vibecalls';
|
||||||
import type {
|
import type {
|
||||||
AccountSettings,
|
AccountSettings,
|
||||||
AttachmentCompression,
|
AttachmentCompression,
|
||||||
|
|||||||
@ -436,9 +436,11 @@ export default class MTProtoSender {
|
|||||||
const encryptedData = await this._state.encryptMessageData(data);
|
const encryptedData = await this._state.encryptMessageData(data);
|
||||||
|
|
||||||
postMessage({
|
postMessage({
|
||||||
type: 'sendBeacon',
|
payloads: [{
|
||||||
data: encryptedData,
|
type: 'sendBeacon',
|
||||||
url: this._fallbackConnection.href,
|
data: encryptedData,
|
||||||
|
url: this._fallbackConnection.href,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1933,6 +1933,7 @@ payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id
|
|||||||
payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes;
|
payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes;
|
||||||
payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts;
|
payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts;
|
||||||
payments.craftStarGift#b0f9684f stargift:Vector<InputSavedStarGift> = Updates;
|
payments.craftStarGift#b0f9684f stargift:Vector<InputSavedStarGift> = Updates;
|
||||||
|
phone.getCallConfig#55451fa9 = DataJSON;
|
||||||
phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||||
phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||||
phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall;
|
phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall;
|
||||||
|
|||||||
@ -387,6 +387,7 @@
|
|||||||
"phone.toggleGroupCallStartSubscription",
|
"phone.toggleGroupCallStartSubscription",
|
||||||
"phone.joinGroupCallPresentation",
|
"phone.joinGroupCallPresentation",
|
||||||
"phone.leaveGroupCallPresentation",
|
"phone.leaveGroupCallPresentation",
|
||||||
|
"phone.getCallConfig",
|
||||||
"phone.requestCall",
|
"phone.requestCall",
|
||||||
"phone.acceptCall",
|
"phone.acceptCall",
|
||||||
"phone.confirmCall",
|
"phone.confirmCall",
|
||||||
|
|||||||
@ -1,506 +0,0 @@
|
|||||||
import { black, silence } from './blacksilence';
|
|
||||||
import type { ApiPhoneCallConnection, P2pParsedSdp } from './types';
|
|
||||||
import parseSdp from './parseSdp';
|
|
||||||
import type { MediaContent, MediaStateMessage, P2pMessage } from './p2pMessage';
|
|
||||||
import {
|
|
||||||
fromTelegramSource,
|
|
||||||
IS_ECHO_CANCELLATION_SUPPORTED,
|
|
||||||
IS_NOISE_SUPPRESSION_SUPPORTED,
|
|
||||||
p2pPayloadTypeToConference,
|
|
||||||
} from './utils';
|
|
||||||
import buildSdp, { Conference } from './buildSdp';
|
|
||||||
import { StreamType } from './secretsauce';
|
|
||||||
|
|
||||||
type P2pState = {
|
|
||||||
connection: RTCPeerConnection;
|
|
||||||
dataChannel: RTCDataChannel;
|
|
||||||
emitSignalingData: (data: P2pMessage) => void;
|
|
||||||
onUpdate: (...args: any[]) => void;
|
|
||||||
conference?: Partial<Conference>;
|
|
||||||
isOutgoing: boolean;
|
|
||||||
pendingCandidates: string[];
|
|
||||||
streams: {
|
|
||||||
video?: MediaStream;
|
|
||||||
audio?: MediaStream;
|
|
||||||
presentation?: MediaStream;
|
|
||||||
ownAudio?: MediaStream;
|
|
||||||
ownVideo?: MediaStream;
|
|
||||||
ownPresentation?: MediaStream;
|
|
||||||
};
|
|
||||||
silence: MediaStream;
|
|
||||||
blackVideo: MediaStream;
|
|
||||||
blackPresentation: MediaStream;
|
|
||||||
mediaState: Omit<MediaStateMessage, '@type'>;
|
|
||||||
audio: HTMLAudioElement;
|
|
||||||
gotInitialSetup?: boolean;
|
|
||||||
facingMode?: VideoFacingModeEnum;
|
|
||||||
};
|
|
||||||
|
|
||||||
let state: P2pState | undefined;
|
|
||||||
|
|
||||||
const ICE_CANDIDATE_POOL_SIZE = 10;
|
|
||||||
|
|
||||||
export function getStreams() {
|
|
||||||
return state?.streams;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStreams() {
|
|
||||||
state?.onUpdate({
|
|
||||||
...state.mediaState,
|
|
||||||
'@type': 'updatePhoneCallMediaState',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'user') {
|
|
||||||
if (streamType === 'presentation') {
|
|
||||||
return (navigator.mediaDevices as any).getDisplayMedia({
|
|
||||||
audio: false,
|
|
||||||
video: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: streamType === 'audio' ? {
|
|
||||||
...(IS_ECHO_CANCELLATION_SUPPORTED && { echoCancellation: true }),
|
|
||||||
...(IS_NOISE_SUPPRESSION_SUPPORTED && { noiseSuppression: true }),
|
|
||||||
} : false,
|
|
||||||
video: streamType === 'video' ? {
|
|
||||||
facingMode: facing,
|
|
||||||
} : false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function switchCameraInputP2p() {
|
|
||||||
if (!state || !state.facingMode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = state.streams.ownVideo;
|
|
||||||
|
|
||||||
if (!stream) return;
|
|
||||||
|
|
||||||
const track = stream.getTracks()[0];
|
|
||||||
|
|
||||||
if (!track) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
|
|
||||||
|
|
||||||
if (!sender) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.facingMode = state.facingMode === 'environment' ? 'user' : 'environment';
|
|
||||||
try {
|
|
||||||
const newStream = await getUserStream('video', state.facingMode);
|
|
||||||
|
|
||||||
await sender.replaceTrack(newStream.getTracks()[0]);
|
|
||||||
state.streams.ownVideo = newStream;
|
|
||||||
updateStreams();
|
|
||||||
} catch (e) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleStreamP2p(streamType: StreamType, value: boolean | undefined = undefined) {
|
|
||||||
if (!state) return;
|
|
||||||
const stream = streamType === 'audio' ? state.streams.ownAudio
|
|
||||||
: (streamType === 'video' ? state.streams.ownVideo : state.streams.ownPresentation);
|
|
||||||
|
|
||||||
if (!stream) return;
|
|
||||||
const track = stream.getTracks()[0];
|
|
||||||
|
|
||||||
if (!track) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sender = state.connection.getSenders().find((l) => track.id === l.track?.id);
|
|
||||||
|
|
||||||
if (!sender) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
value = value === undefined ? !track.enabled : value;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (value && !track.enabled) {
|
|
||||||
const newStream = await getUserStream(streamType);
|
|
||||||
newStream.getTracks()[0].onended = () => {
|
|
||||||
toggleStreamP2p(streamType, false);
|
|
||||||
};
|
|
||||||
await sender.replaceTrack(newStream.getTracks()[0]);
|
|
||||||
if (streamType === 'audio') {
|
|
||||||
state.streams.ownAudio = newStream;
|
|
||||||
} else if (streamType === 'video') {
|
|
||||||
state.streams.ownVideo = newStream;
|
|
||||||
state.facingMode = 'user';
|
|
||||||
} else {
|
|
||||||
state.streams.ownPresentation = newStream;
|
|
||||||
}
|
|
||||||
if (streamType === 'video' || streamType === 'presentation') {
|
|
||||||
toggleStreamP2p(streamType === 'video' ? 'presentation' : 'video', false);
|
|
||||||
}
|
|
||||||
// if (streamType === 'video') {
|
|
||||||
// state.facingMode = 'user';
|
|
||||||
// }
|
|
||||||
} else if (!value && track.enabled) {
|
|
||||||
track.stop();
|
|
||||||
const newStream = streamType === 'audio' ? state.silence
|
|
||||||
: (streamType === 'video' ? state.blackVideo : state.blackPresentation);
|
|
||||||
if (!newStream) return;
|
|
||||||
|
|
||||||
await sender.replaceTrack(newStream.getTracks()[0]);
|
|
||||||
|
|
||||||
if (streamType === 'audio') {
|
|
||||||
state.streams.ownAudio = newStream;
|
|
||||||
} else if (streamType === 'video') {
|
|
||||||
state.streams.ownVideo = newStream;
|
|
||||||
} else {
|
|
||||||
state.streams.ownPresentation = newStream;
|
|
||||||
}
|
|
||||||
// if (streamType === 'video') {
|
|
||||||
// state.facingMode = undefined;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
updateStreams();
|
|
||||||
sendMediaState();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function joinPhoneCall(
|
|
||||||
connections: ApiPhoneCallConnection[],
|
|
||||||
emitSignalingData: (data: P2pMessage) => void,
|
|
||||||
isOutgoing: boolean,
|
|
||||||
shouldStartVideo: boolean,
|
|
||||||
isP2p: boolean,
|
|
||||||
onUpdate: (...args: any[]) => void,
|
|
||||||
) {
|
|
||||||
const conn = new RTCPeerConnection({
|
|
||||||
iceServers: connections.map((connection) => (
|
|
||||||
{
|
|
||||||
urls: [
|
|
||||||
connection.isTurn && `turn:${connection.ip}:${connection.port}`,
|
|
||||||
connection.isStun && `stun:${connection.ip}:${connection.port}`,
|
|
||||||
].filter(Boolean),
|
|
||||||
username: connection.username,
|
|
||||||
credentialType: 'password',
|
|
||||||
credential: connection.password,
|
|
||||||
}
|
|
||||||
)),
|
|
||||||
iceTransportPolicy: isP2p ? 'all' : 'relay',
|
|
||||||
bundlePolicy: 'max-bundle',
|
|
||||||
iceCandidatePoolSize: ICE_CANDIDATE_POOL_SIZE,
|
|
||||||
});
|
|
||||||
|
|
||||||
conn.onicecandidate = (e) => {
|
|
||||||
if (!e.candidate) {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
emitSignalingData({
|
|
||||||
'@type': 'Candidates',
|
|
||||||
candidates: [{
|
|
||||||
sdpString: e.candidate.candidate,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
conn.onconnectionstatechange = () => {
|
|
||||||
onUpdate({
|
|
||||||
'@type': 'updatePhoneCallConnectionState',
|
|
||||||
connectionState: conn.connectionState,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
conn.ontrack = (e) => {
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
const stream = e.streams[0];
|
|
||||||
if (e.track.kind === 'audio') {
|
|
||||||
state.audio.srcObject = stream;
|
|
||||||
state.audio.play().catch();
|
|
||||||
state.streams.audio = stream;
|
|
||||||
} else if (e.transceiver.mid === '1') {
|
|
||||||
state.streams.video = stream;
|
|
||||||
} else {
|
|
||||||
state.streams.presentation = stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStreams();
|
|
||||||
};
|
|
||||||
|
|
||||||
conn.oniceconnectionstatechange = async (e) => {
|
|
||||||
switch(conn.iceConnectionState) {
|
|
||||||
case 'disconnected':
|
|
||||||
case 'failed':
|
|
||||||
if (isOutgoing) {
|
|
||||||
await createOffer(conn, {
|
|
||||||
offerToReceiveAudio: true,
|
|
||||||
offerToReceiveVideo: true,
|
|
||||||
iceRestart: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const slnc = silence(new AudioContext());
|
|
||||||
const video = black({ width: 640, height: 480 });
|
|
||||||
const screenshare = black({ width: 640, height: 480 });
|
|
||||||
conn.addTrack(slnc.getTracks()[0], slnc);
|
|
||||||
conn.addTrack(video.getTracks()[0], video);
|
|
||||||
conn.addTrack(screenshare.getTracks()[0], screenshare);
|
|
||||||
|
|
||||||
const dc = conn.createDataChannel('data', {
|
|
||||||
id: 0,
|
|
||||||
negotiated: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
dc.onmessage = (e) => {
|
|
||||||
processSignalingMessage(JSON.parse(e.data));
|
|
||||||
};
|
|
||||||
|
|
||||||
const audio = new Audio();
|
|
||||||
|
|
||||||
state = {
|
|
||||||
audio,
|
|
||||||
connection: conn,
|
|
||||||
emitSignalingData,
|
|
||||||
isOutgoing,
|
|
||||||
pendingCandidates: [],
|
|
||||||
onUpdate,
|
|
||||||
streams: {
|
|
||||||
ownVideo: video,
|
|
||||||
ownAudio: slnc,
|
|
||||||
ownPresentation: screenshare,
|
|
||||||
},
|
|
||||||
mediaState: {
|
|
||||||
isBatteryLow: false,
|
|
||||||
screencastState: 'inactive',
|
|
||||||
videoState: 'inactive',
|
|
||||||
videoRotation: 0,
|
|
||||||
isMuted: true,
|
|
||||||
},
|
|
||||||
blackVideo: video,
|
|
||||||
blackPresentation: screenshare,
|
|
||||||
silence: slnc,
|
|
||||||
dataChannel: dc,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
toggleStreamP2p('audio', true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOutgoing) {
|
|
||||||
await createOffer(conn, {
|
|
||||||
offerToReceiveAudio: true,
|
|
||||||
offerToReceiveVideo: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopPhoneCall() {
|
|
||||||
if (!state) return;
|
|
||||||
|
|
||||||
state.streams.ownVideo?.getTracks().forEach((track) => track.stop());
|
|
||||||
state.streams.ownPresentation?.getTracks().forEach((track) => track.stop());
|
|
||||||
state.streams.ownAudio?.getTracks().forEach((track) => track.stop());
|
|
||||||
state.dataChannel.close();
|
|
||||||
state.connection.close();
|
|
||||||
state = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendMediaState() {
|
|
||||||
if (!state) return;
|
|
||||||
const { emitSignalingData, streams } = state;
|
|
||||||
|
|
||||||
emitSignalingData({
|
|
||||||
'@type': 'MediaState',
|
|
||||||
videoRotation: 0,
|
|
||||||
isMuted: !streams.ownAudio?.getTracks()[0].enabled,
|
|
||||||
isBatteryLow: true,
|
|
||||||
videoState: streams.ownVideo?.getTracks()[0].enabled ? 'active' : 'inactive',
|
|
||||||
screencastState: streams.ownPresentation?.getTracks()[0].enabled ? 'active' : 'inactive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterVP8(mediaContent: MediaContent) {
|
|
||||||
if (!state || state.isOutgoing) return mediaContent;
|
|
||||||
|
|
||||||
const { payloadTypes } = mediaContent!;
|
|
||||||
const idx = payloadTypes.findIndex((payloadType) => payloadType.name === 'VP8');
|
|
||||||
const vp8PayloadType = payloadTypes[idx];
|
|
||||||
const rtxIdx = payloadTypes.findIndex((payloadType) => Number(payloadType.parameters?.apt) === vp8PayloadType.id);
|
|
||||||
mediaContent.payloadTypes = [payloadTypes[idx], payloadTypes[rtxIdx]];
|
|
||||||
|
|
||||||
return mediaContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendInitialSetup(sdp: P2pParsedSdp) {
|
|
||||||
if (!state) return;
|
|
||||||
const { emitSignalingData } = state;
|
|
||||||
|
|
||||||
if (!sdp.ssrc || !sdp['ssrc-groups'] || !sdp['ssrc-groups'][0] || !sdp['ssrc-groups'][1]) return;
|
|
||||||
|
|
||||||
emitSignalingData({
|
|
||||||
'@type': 'InitialSetup',
|
|
||||||
fingerprints: sdp.fingerprints,
|
|
||||||
ufrag: sdp.ufrag,
|
|
||||||
pwd: sdp.pwd,
|
|
||||||
audio: {
|
|
||||||
ssrc: fromTelegramSource(sdp.ssrc).toString(),
|
|
||||||
ssrcGroups: [],
|
|
||||||
payloadTypes: sdp.audioPayloadTypes,
|
|
||||||
rtpExtensions: sdp.audioExtmap,
|
|
||||||
},
|
|
||||||
video: filterVP8({
|
|
||||||
ssrc: fromTelegramSource(sdp['ssrc-groups'][0].sources[0]).toString(),
|
|
||||||
ssrcGroups: [{
|
|
||||||
semantics: sdp['ssrc-groups'][0].semantics,
|
|
||||||
ssrcs: sdp['ssrc-groups'][0].sources.map(fromTelegramSource),
|
|
||||||
}],
|
|
||||||
payloadTypes: sdp.videoPayloadTypes,
|
|
||||||
rtpExtensions: sdp.videoExtmap,
|
|
||||||
}),
|
|
||||||
screencast: filterVP8({
|
|
||||||
ssrc: fromTelegramSource(sdp['ssrc-groups'][1].sources[0]).toString(),
|
|
||||||
ssrcGroups: [{
|
|
||||||
semantics: sdp['ssrc-groups'][1].semantics,
|
|
||||||
ssrcs: sdp['ssrc-groups'][1].sources.map(fromTelegramSource),
|
|
||||||
}],
|
|
||||||
payloadTypes: sdp.screencastPayloadTypes,
|
|
||||||
rtpExtensions: sdp.screencastExtmap,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processSignalingMessage(message: P2pMessage) {
|
|
||||||
if (!state || !state.connection) return;
|
|
||||||
|
|
||||||
switch (message['@type']) {
|
|
||||||
case 'MediaState': {
|
|
||||||
state.mediaState = message;
|
|
||||||
updateStreams();
|
|
||||||
sendMediaState();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Candidates': {
|
|
||||||
const { pendingCandidates, gotInitialSetup } = state;
|
|
||||||
message.candidates.forEach((candidate) => {
|
|
||||||
pendingCandidates.push(candidate.sdpString);
|
|
||||||
});
|
|
||||||
if (gotInitialSetup) {
|
|
||||||
await commitPendingIceCandidates();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'InitialSetup': {
|
|
||||||
const {
|
|
||||||
connection, isOutgoing,
|
|
||||||
} = state;
|
|
||||||
if (!connection) return;
|
|
||||||
|
|
||||||
const newConference = {
|
|
||||||
transport: {
|
|
||||||
candidates: [],
|
|
||||||
ufrag: message.ufrag,
|
|
||||||
pwd: message.pwd,
|
|
||||||
fingerprints: message.fingerprints,
|
|
||||||
'rtcp-mux': false,
|
|
||||||
xmlns: '',
|
|
||||||
},
|
|
||||||
sessionId: Date.now(),
|
|
||||||
ssrcs: [
|
|
||||||
message.audio && {
|
|
||||||
isVideo: false,
|
|
||||||
isMain: false,
|
|
||||||
userId: '123',
|
|
||||||
endpoint: '0',
|
|
||||||
mid: '0',
|
|
||||||
sourceGroups: [{
|
|
||||||
sources: [message.audio.ssrc],
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
message.video && {
|
|
||||||
isVideo: true,
|
|
||||||
isPresentation: false,
|
|
||||||
isMain: false,
|
|
||||||
userId: '123',
|
|
||||||
endpoint: '1',
|
|
||||||
mid: '1',
|
|
||||||
sourceGroups: message.video.ssrcGroups.map((l) => ({
|
|
||||||
semantics: l.semantics,
|
|
||||||
sources: l.ssrcs,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
message.screencast && {
|
|
||||||
isVideo: true,
|
|
||||||
isPresentation: true,
|
|
||||||
isMain: false,
|
|
||||||
userId: '123',
|
|
||||||
endpoint: '2',
|
|
||||||
mid: '2',
|
|
||||||
sourceGroups: message.screencast.ssrcGroups.map((l) => ({
|
|
||||||
semantics: l.semantics,
|
|
||||||
sources: l.ssrcs,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
audioPayloadTypes: message.audio!.payloadTypes?.map(p2pPayloadTypeToConference) || [],
|
|
||||||
audioExtensions: message.audio!.rtpExtensions,
|
|
||||||
videoPayloadTypes: filterVP8(message.video!).payloadTypes?.map(p2pPayloadTypeToConference) || [],
|
|
||||||
videoExtensions: message.video!.rtpExtensions,
|
|
||||||
} as Conference;
|
|
||||||
|
|
||||||
await connection.setRemoteDescription({
|
|
||||||
sdp: buildSdp(newConference, isOutgoing, undefined, true),
|
|
||||||
type: isOutgoing ? 'answer' : 'offer',
|
|
||||||
});
|
|
||||||
|
|
||||||
state.conference = newConference;
|
|
||||||
|
|
||||||
if (!isOutgoing) {
|
|
||||||
const answer = await connection.createAnswer();
|
|
||||||
await connection.setLocalDescription(answer);
|
|
||||||
sendInitialSetup(parseSdp(connection.localDescription!, true) as P2pParsedSdp);
|
|
||||||
}
|
|
||||||
state.gotInitialSetup = true;
|
|
||||||
await commitPendingIceCandidates();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commitPendingIceCandidates() {
|
|
||||||
if (!state) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { pendingCandidates, connection } = state;
|
|
||||||
if (!pendingCandidates.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Promise.all(pendingCandidates.map((c) => tryAddCandidate(connection, c)));
|
|
||||||
state.pendingCandidates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryAddCandidate(connection: RTCPeerConnection, candidate: string) {
|
|
||||||
try {
|
|
||||||
await connection.addIceCandidate({
|
|
||||||
candidate,
|
|
||||||
sdpMLineIndex: 0,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOffer(conn: RTCPeerConnection, params: RTCOfferOptions) {
|
|
||||||
const offer = await conn.createOffer(params);
|
|
||||||
await conn.setLocalDescription(offer);
|
|
||||||
sendInitialSetup(parseSdp(conn.localDescription!, true) as P2pParsedSdp);
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
import { toTelegramSource } from './utils';
|
|
||||||
import type { JoinGroupCallPayload, SsrcGroup } from './types';
|
|
||||||
|
|
||||||
export default (sessionDescription: RTCSessionDescriptionInit, isP2p = false): JoinGroupCallPayload => {
|
|
||||||
if (!sessionDescription || !sessionDescription.sdp) {
|
|
||||||
throw Error('Failed parsing SDP: session description is null');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = sessionDescription
|
|
||||||
.sdp
|
|
||||||
.split('\r\nm=')
|
|
||||||
.map((s, i) => (i === 0 ? s : `m=${s}`))
|
|
||||||
.reduce((acc: Record<string, string[]>, el) => {
|
|
||||||
const name = el.match(/^m=(.+?)\s/)?.[1] || 'header';
|
|
||||||
acc[acc.hasOwnProperty(name) && name === 'video' ? 'screencast' : name] = el.split('\r\n').filter(Boolean);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const lookup = (prefix: string, sectionName?: string) => {
|
|
||||||
if (!sectionName) {
|
|
||||||
return Object.values(sections).map((section) => {
|
|
||||||
return section.find((line) => line.startsWith(prefix))?.substr(prefix.length);
|
|
||||||
}).filter(Boolean)[0];
|
|
||||||
} else {
|
|
||||||
return sections[sectionName]?.find((line) => line.startsWith(prefix))?.substr(prefix.length);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseExtmaps = (sectionName: string) => {
|
|
||||||
return sections[sectionName].filter((l) => l.startsWith('a=extmap')).map((l) => {
|
|
||||||
const [, id, uri] = l.match(/extmap:(\d+)(?:\/.+)?\s(.+)/)!;
|
|
||||||
return { id: Number(id), uri };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsePayloadTypes = (sectionName: string) => {
|
|
||||||
const payloads = sections[sectionName].filter((l) => l.startsWith('a=rtpmap')).map((l) => {
|
|
||||||
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
|
|
||||||
const [name, clockrate, channels] = data.split('/');
|
|
||||||
return {
|
|
||||||
id: Number(id), name, clockrate: Number(clockrate), ...(channels && { channels: Number(channels) }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const fbParams = sections[sectionName].filter((l) => l.startsWith('a=rtcp-fb')).map((l) => {
|
|
||||||
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
|
|
||||||
const [type, subtype] = data.split(' ');
|
|
||||||
return { id: Number(id), type, subtype: subtype || '' };
|
|
||||||
});
|
|
||||||
|
|
||||||
const parameters = sections[sectionName].filter((l) => l.startsWith('a=fmtp')).map((l) => {
|
|
||||||
const [, id, data] = l.match(/:(\d+)\s(.+)/) || [];
|
|
||||||
const d = data?.split(';').reduce((acc: Record<string, string>, q) => {
|
|
||||||
const [name, value] = q.split('=');
|
|
||||||
acc[name] = value;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
if (!d || Object.values(d).some((z) => !z)) return undefined;
|
|
||||||
return { id: Number(id), data: d };
|
|
||||||
}).filter(Boolean);
|
|
||||||
|
|
||||||
return payloads.map((payload) => {
|
|
||||||
const p = parameters.filter((l) => l!.id === payload.id).map((q) => q!.data).reduce((acc, el) => {
|
|
||||||
return Object.assign(acc, el);
|
|
||||||
}, {});
|
|
||||||
const f = fbParams.filter((l) => l.id === payload.id).map((l) => {
|
|
||||||
return {
|
|
||||||
type: l.type,
|
|
||||||
subtype: l.subtype,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
...(Object.keys(p).length > 0 && { parameters: p }),
|
|
||||||
...(f.length > 0 && { feedbackTypes: f }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawSource = lookup('a=ssrc:', 'audio');
|
|
||||||
const sourceAudio = rawSource && Number(rawSource.split(' ')[0]);
|
|
||||||
|
|
||||||
// TODO multiple source groups
|
|
||||||
const rawSourceVideo = lookup('a=ssrc-group:', 'video')?.split(' ') || undefined;
|
|
||||||
const rawSourceScreencast = lookup('a=ssrc-group:', 'screencast')?.split(' ') || undefined;
|
|
||||||
|
|
||||||
if (!rawSourceVideo) {
|
|
||||||
throw Error('Failed parsing SDP: no video ssrc');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [hash, fingerprint] = lookup('a=fingerprint:')?.split(' ') || [];
|
|
||||||
|
|
||||||
const setup = lookup('a=setup:');
|
|
||||||
if (!hash || !fingerprint) {
|
|
||||||
throw Error('Failed parsing SDP: no fingerprint');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(sections);
|
|
||||||
|
|
||||||
const ufrag = lookup('a=ice-ufrag:');
|
|
||||||
const pwd = lookup('a=ice-pwd:');
|
|
||||||
|
|
||||||
if (!ufrag || !pwd) {
|
|
||||||
throw Error('Failed parsing SDP: no ICE ufrag or pwd');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fingerprints: [
|
|
||||||
{
|
|
||||||
fingerprint,
|
|
||||||
hash,
|
|
||||||
setup: isP2p ? setup! : 'active',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pwd,
|
|
||||||
ufrag,
|
|
||||||
...(sourceAudio && { ssrc: toTelegramSource(sourceAudio) }),
|
|
||||||
...(rawSourceVideo && {
|
|
||||||
'ssrc-groups': [
|
|
||||||
{
|
|
||||||
semantics: rawSourceVideo[0],
|
|
||||||
sources: rawSourceVideo.slice(1, rawSourceVideo.length).map(Number).map(toTelegramSource),
|
|
||||||
},
|
|
||||||
(isP2p && rawSourceScreencast && {
|
|
||||||
semantics: rawSourceScreencast[0],
|
|
||||||
sources: rawSourceScreencast.slice(1, rawSourceScreencast.length).map(Number).map(toTelegramSource),
|
|
||||||
}),
|
|
||||||
].filter(Boolean) as SsrcGroup[],
|
|
||||||
}),
|
|
||||||
...(isP2p && {
|
|
||||||
audioExtmap: parseExtmaps('audio'),
|
|
||||||
videoExtmap: parseExtmaps('video'),
|
|
||||||
screencastExtmap: parseExtmaps('screencast'),
|
|
||||||
audioPayloadTypes: parsePayloadTypes('audio'),
|
|
||||||
videoPayloadTypes: parsePayloadTypes('video'),
|
|
||||||
screencastPayloadTypes: parsePayloadTypes('screencast'),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -39,7 +39,7 @@ export type ReceiverVideoConstraints = {
|
|||||||
onStageEndpoints: string[];
|
onStageEndpoints: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ColibriClass = (
|
export type GroupCallDataChannelMessage = (
|
||||||
LastNEndpointsChangeEvent | DebugMessage | EndpointConnectivityStatusChangeEvent |
|
LastNEndpointsChangeEvent | DebugMessage | EndpointConnectivityStatusChangeEvent |
|
||||||
SenderVideoConstraints | DominantSpeakerEndpointChangeEvent | ReceiverVideoConstraints
|
SenderVideoConstraints | DominantSpeakerEndpointChangeEvent | ReceiverVideoConstraints
|
||||||
);
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@ -3,12 +3,13 @@ export {
|
|||||||
getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream,
|
getDevices, getUserStreams, setVolume, isStreamEnabled, toggleStream,
|
||||||
leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput,
|
leaveGroupCall, handleUpdateGroupCallParticipants, switchCameraInput,
|
||||||
toggleSpeaker, toggleNoiseSuppression,
|
toggleSpeaker, toggleNoiseSuppression,
|
||||||
} from './secretsauce';
|
} from './group/groupCall';
|
||||||
export {
|
export {
|
||||||
joinPhoneCall, processSignalingMessage, getStreams, toggleStreamP2p, stopPhoneCall, switchCameraInputP2p,
|
joinPhoneCall, processSignalingMessage, getStreams, toggleStreamP2p, stopPhoneCall, switchCameraInputP2p,
|
||||||
} from './p2p';
|
} from './phone/phoneCall';
|
||||||
export * from './p2pMessage';
|
export * from './phone/signalingMessages';
|
||||||
export {
|
export {
|
||||||
IS_SCREENSHARE_SUPPORTED, THRESHOLD,
|
IS_SCREENSHARE_SUPPORTED, sanitizePrimitiveRecord, THRESHOLD,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
export type { PrimitiveRecord, PrimitiveRecordValue } from './utils';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
2195
src/lib/vibecalls/phone/phoneCall.ts
Normal file
2195
src/lib/vibecalls/phone/phoneCall.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Fingerprint, RTCPFeedbackParam, RTPExtension,
|
Fingerprint, RTCPFeedbackParam, RTPExtension,
|
||||||
} from './types';
|
} from '../types';
|
||||||
|
|
||||||
export type VideoState = 'inactive' | 'active' | 'suspended';
|
export type VideoState = 'inactive' | 'active' | 'suspended';
|
||||||
|
|
||||||
@ -8,15 +8,17 @@ export type VideoRotation = 0 | 90 | 180 | 270;
|
|||||||
|
|
||||||
export type MediaStateMessage = {
|
export type MediaStateMessage = {
|
||||||
'@type': 'MediaState';
|
'@type': 'MediaState';
|
||||||
isMuted: boolean;
|
muted: boolean;
|
||||||
videoState: VideoState;
|
videoState: VideoState;
|
||||||
videoRotation: VideoRotation;
|
videoRotation: VideoRotation;
|
||||||
screencastState: VideoState;
|
screencastState: VideoState;
|
||||||
isBatteryLow: boolean;
|
lowBattery: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CandidatesMessage = {
|
type CandidatesMessage = {
|
||||||
'@type': 'Candidates';
|
'@type': 'Candidates';
|
||||||
|
exchangeId?: string;
|
||||||
|
ufrag?: string;
|
||||||
candidates: P2pCandidate[];
|
candidates: P2pCandidate[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,17 +26,22 @@ export type InitialSetupMessage = {
|
|||||||
'@type': 'InitialSetup';
|
'@type': 'InitialSetup';
|
||||||
ufrag: string;
|
ufrag: string;
|
||||||
pwd: string;
|
pwd: string;
|
||||||
|
renomination: boolean;
|
||||||
fingerprints: Fingerprint[];
|
fingerprints: Fingerprint[];
|
||||||
audio?: MediaContent;
|
|
||||||
video?: MediaContent;
|
|
||||||
screencast?: MediaContent;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MediaContent = {
|
export type MediaContent = {
|
||||||
|
type: 'audio' | 'video';
|
||||||
ssrc: string;
|
ssrc: string;
|
||||||
ssrcGroups: P2pSsrcGroup[];
|
ssrcGroups?: P2pSsrcGroup[];
|
||||||
payloadTypes: P2PPayloadType[];
|
payloadTypes?: P2PPayloadType[];
|
||||||
rtpExtensions: RTPExtension[];
|
rtpExtensions?: RTPExtension[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NegotiateChannelsMessage = {
|
||||||
|
'@type': 'NegotiateChannels';
|
||||||
|
exchangeId: string;
|
||||||
|
contents: MediaContent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface P2PPayloadType {
|
export interface P2PPayloadType {
|
||||||
@ -53,6 +60,9 @@ type P2pSsrcGroup = {
|
|||||||
|
|
||||||
type P2pCandidate = {
|
type P2pCandidate = {
|
||||||
sdpString: string;
|
sdpString: string;
|
||||||
|
sdpMid?: string;
|
||||||
|
sdpMLineIndex?: number;
|
||||||
|
usernameFragment?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage;
|
export type P2pMessage = CandidatesMessage | InitialSetupMessage | MediaStateMessage | NegotiateChannelsMessage;
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
Candidate, GroupCallTransport, PayloadType, RTPExtension, SsrcGroup,
|
Candidate, GroupCallTransport, PayloadType, RTPExtension, SsrcGroup,
|
||||||
} from './types';
|
} from '../types';
|
||||||
import { fromTelegramSource } from './utils';
|
|
||||||
|
import { fromTelegramSource } from '../utils';
|
||||||
|
|
||||||
export type Conference = {
|
export type Conference = {
|
||||||
sessionId: number;
|
sessionId: number;
|
||||||
@ -77,7 +78,9 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
|||||||
add(`a=ice-pwd:${pwd}`);
|
add(`a=ice-pwd:${pwd}`);
|
||||||
fingerprints.forEach((fingerprint) => {
|
fingerprints.forEach((fingerprint) => {
|
||||||
add(`a=fingerprint:${fingerprint.hash} ${fingerprint.fingerprint}`);
|
add(`a=fingerprint:${fingerprint.hash} ${fingerprint.fingerprint}`);
|
||||||
add(`a=setup:${isP2p ? (fingerprint.setup) : 'passive'}`);
|
const setup = isAnswer && fingerprint.setup !== 'active' && fingerprint.setup !== 'passive'
|
||||||
|
? 'passive' : fingerprint.setup || 'passive';
|
||||||
|
add(`a=setup:${setup}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
candidates.forEach(addCandidate);
|
candidates.forEach(addCandidate);
|
||||||
@ -93,7 +96,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
|||||||
|
|
||||||
if (parameters) {
|
if (parameters) {
|
||||||
const parametersString = Object.keys(parameters).map((key) => {
|
const parametersString = Object.keys(parameters).map((key) => {
|
||||||
return `${key}=${parameters![key]};`;
|
return `${key}=${parameters[key]};`;
|
||||||
}).join(' ');
|
}).join(' ');
|
||||||
|
|
||||||
add(`a=fmtp:${id} ${parametersString}`);
|
add(`a=fmtp:${id} ${parametersString}`);
|
||||||
@ -108,7 +111,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
|||||||
const payloadTypes = entry.isVideo ? videoPayloadTypes : audioPayloadTypes;
|
const payloadTypes = entry.isVideo ? videoPayloadTypes : audioPayloadTypes;
|
||||||
|
|
||||||
const type = entry.isVideo ? 'video' : 'audio';
|
const type = entry.isVideo ? 'video' : 'audio';
|
||||||
add(`m=${type} ${entry.isMain ? 1 : 0} RTP/SAVPF ${payloadTypes.map((l) => l.id).join(' ')}`);
|
add(`m=${type} ${entry.isRemoved ? 0 : 1} RTP/SAVPF ${payloadTypes.map((l) => l.id).join(' ')}`);
|
||||||
add('c=IN IP4 0.0.0.0');
|
add('c=IN IP4 0.0.0.0');
|
||||||
add('b=AS:1300'); // 1300000 / 1000
|
add('b=AS:1300'); // 1300000 / 1000
|
||||||
add(`a=mid:${entry.mid}`);
|
add(`a=mid:${entry.mid}`);
|
||||||
@ -162,9 +165,9 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!isP2p) {
|
if (!isP2p) {
|
||||||
ssrcs.filter((ssrc) => ssrc.mid === '0' || ssrc.mid === '1').map(addSsrcEntry);
|
ssrcs.filter((ssrc) => ssrc.mid === '0' || ssrc.mid === '1').forEach(addSsrcEntry);
|
||||||
} else {
|
} else {
|
||||||
ssrcs.filter(addSsrcEntry);
|
ssrcs.forEach(addSsrcEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPresentation) {
|
if (!isPresentation) {
|
||||||
@ -178,7 +181,7 @@ export default (conference: Conference, isAnswer = false, isPresentation = false
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isP2p) {
|
if (!isP2p) {
|
||||||
ssrcs.filter((ssrc) => ssrc.mid !== '0' && ssrc.mid !== '1').map(addSsrcEntry);
|
ssrcs.filter((ssrc) => ssrc.mid !== '0' && ssrc.mid !== '1').forEach(addSsrcEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${lines.join('\n')}\n`;
|
return `${lines.join('\n')}\n`;
|
||||||
231
src/lib/vibecalls/sdp/common.ts
Normal file
231
src/lib/vibecalls/sdp/common.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import type { P2PPayloadType } from '../phone/signalingMessages';
|
||||||
|
import type {
|
||||||
|
Fingerprint, RTPExtension,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export type SdpSection = {
|
||||||
|
kind: string;
|
||||||
|
lines: string[];
|
||||||
|
mid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SdpSummarySection = {
|
||||||
|
kind: string;
|
||||||
|
mid?: string;
|
||||||
|
port: number;
|
||||||
|
direction?: string;
|
||||||
|
payloads: string[];
|
||||||
|
ssrcs: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseSdpSections(sdp: string) {
|
||||||
|
return sdp
|
||||||
|
.split(/\r?\nm=/)
|
||||||
|
.map((section, index) => index === 0 ? section : `m=${section}`)
|
||||||
|
.map((section): SdpSection => {
|
||||||
|
const lines = section.split(/\r?\n/).filter(Boolean);
|
||||||
|
const kind = lines[0]?.match(/^m=([^\s]+)/)?.[1] || 'session';
|
||||||
|
const mid = lines.find((line) => line.startsWith('a=mid:'))?.slice('a=mid:'.length);
|
||||||
|
return { kind, lines, mid };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBundleMids(sdp: string) {
|
||||||
|
const line = sdp.split(/\r?\n/).find((item) => item.startsWith('a=group:BUNDLE '));
|
||||||
|
return line?.slice('a=group:BUNDLE '.length).split(' ').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findSdpLineValue(sections: SdpSection[], prefix: string, section?: SdpSection) {
|
||||||
|
if (section) {
|
||||||
|
const sectionLine = section.lines.find((line) => line.startsWith(prefix));
|
||||||
|
if (sectionLine) {
|
||||||
|
return sectionLine.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of sections) {
|
||||||
|
if (item.kind !== 'session') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionLine = item.lines.find((line) => line.startsWith(prefix));
|
||||||
|
if (sessionLine) {
|
||||||
|
return sessionLine.slice(prefix.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of sections) {
|
||||||
|
const value = item.lines.find((line) => line.startsWith(prefix))?.slice(prefix.length);
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSdpDirection(section: SdpSection) {
|
||||||
|
return section.lines.find((line) => {
|
||||||
|
return line === 'a=sendrecv' || line === 'a=sendonly' || line === 'a=recvonly' || line === 'a=inactive';
|
||||||
|
})?.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSdpPort(section: SdpSection) {
|
||||||
|
return Number(section.lines[0]?.split(' ')[1] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFingerprints(sections: SdpSection[]): Fingerprint[] {
|
||||||
|
const values = new Map<string, Fingerprint>();
|
||||||
|
|
||||||
|
sections.forEach((section) => {
|
||||||
|
const fingerprint = findSdpLineValue(sections, 'a=fingerprint:', section);
|
||||||
|
if (!fingerprint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hash, value] = fingerprint.split(' ');
|
||||||
|
const setup = findSdpLineValue(sections, 'a=setup:', section) || 'actpass';
|
||||||
|
if (!hash || !value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.set(`${hash}:${value}:${setup}`, {
|
||||||
|
hash,
|
||||||
|
setup,
|
||||||
|
fingerprint: value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(values.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSsrcGroups(section: SdpSection) {
|
||||||
|
return section.lines.filter((line) => line.startsWith('a=ssrc-group:')).map((line) => {
|
||||||
|
const [, value] = line.split(':');
|
||||||
|
const [semantics, ...ssrcs] = value.split(' ');
|
||||||
|
return {
|
||||||
|
semantics,
|
||||||
|
ssrcs: ssrcs.map(Number),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSsrcs(section: SdpSection, shouldIncludeGroups = false) {
|
||||||
|
const values = new Set<number>();
|
||||||
|
|
||||||
|
section.lines.forEach((line) => {
|
||||||
|
if (shouldIncludeGroups && line.startsWith('a=ssrc-group:')) {
|
||||||
|
line.match(/\d+/g)?.forEach((value) => {
|
||||||
|
values.add(Number(value));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = line.match(/^a=ssrc:(\d+)/);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.add(Number(match[1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseExtmaps(section: SdpSection): RTPExtension[] {
|
||||||
|
return section.lines.filter((line) => line.startsWith('a=extmap:')).map((line) => {
|
||||||
|
const [, rawId, uri] = line.match(/^a=extmap:(\d+)(?:\/[^\s]+)?\s(.+)$/) || [];
|
||||||
|
if (!rawId || !uri) {
|
||||||
|
throw Error('Failed parsing SDP RTP extension');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(rawId),
|
||||||
|
uri,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePayloadTypes(section: SdpSection): P2PPayloadType[] {
|
||||||
|
const payloadTypes: P2PPayloadType[] = section.lines.filter((line) => line.startsWith('a=rtpmap:')).map((line) => {
|
||||||
|
const [, rawId, name, rawClockrate, rawChannels] = line.match(/^a=rtpmap:(\d+)\s([^/]+)\/(\d+)(?:\/(\d+))?/) || [];
|
||||||
|
if (!rawId || !name || !rawClockrate) {
|
||||||
|
throw Error('Failed parsing SDP payload type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(rawId),
|
||||||
|
name,
|
||||||
|
clockrate: Number(rawClockrate),
|
||||||
|
channels: rawChannels ? Number(rawChannels) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
payloadTypes.forEach((payloadType) => {
|
||||||
|
const parameters = parsePayloadParameters(section, payloadType.id);
|
||||||
|
const feedbackTypes = parseFeedbackTypes(section, payloadType.id);
|
||||||
|
|
||||||
|
if (Object.keys(parameters).length) {
|
||||||
|
payloadType.parameters = parameters;
|
||||||
|
}
|
||||||
|
if (feedbackTypes.length) {
|
||||||
|
payloadType.feedbackTypes = feedbackTypes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return payloadTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeSdp(sdp: string, shouldIncludeSsrcGroups = false) {
|
||||||
|
return parseSdpSections(sdp).filter((section) => section.kind !== 'session').map((section): SdpSummarySection => {
|
||||||
|
const mLine = section.lines[0] || '';
|
||||||
|
const parts = mLine.split(' ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: section.kind,
|
||||||
|
mid: section.mid,
|
||||||
|
port: getSdpPort(section),
|
||||||
|
direction: getSdpDirection(section),
|
||||||
|
payloads: parts.slice(3),
|
||||||
|
ssrcs: parseSsrcs(section, shouldIncludeSsrcGroups),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayloadParameters(section: SdpSection, payloadId: number) {
|
||||||
|
const parameters: Record<string, string> = {};
|
||||||
|
const line = section.lines.find((item) => item.startsWith(`a=fmtp:${payloadId} `));
|
||||||
|
const rawParameters = line?.slice(`a=fmtp:${payloadId} `.length);
|
||||||
|
if (!rawParameters) {
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
rawParameters.split(';').forEach((item) => {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
const separatorIndex = trimmed.indexOf('=');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separatorIndex);
|
||||||
|
const value = trimmed.slice(separatorIndex + 1);
|
||||||
|
if (key && value) {
|
||||||
|
parameters[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeedbackTypes(section: SdpSection, payloadId: number): NonNullable<P2PPayloadType['feedbackTypes']> {
|
||||||
|
return section.lines.filter((line) => line.startsWith(`a=rtcp-fb:${payloadId} `)).map((line) => {
|
||||||
|
const value = line.slice(`a=rtcp-fb:${payloadId} `.length);
|
||||||
|
const [type, subtype] = value.split(' ');
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
subtype: subtype || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
127
src/lib/vibecalls/sdp/groupSdp.ts
Normal file
127
src/lib/vibecalls/sdp/groupSdp.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import type { JoinGroupCallPayload, P2pParsedSdp, SsrcGroup } from '../types';
|
||||||
|
import type { SdpSection } from './common';
|
||||||
|
|
||||||
|
import { toTelegramSource } from '../utils';
|
||||||
|
import {
|
||||||
|
findSdpLineValue,
|
||||||
|
parseExtmaps as parseSectionExtmaps,
|
||||||
|
parsePayloadTypes as parseSectionPayloadTypes,
|
||||||
|
parseSdpSections,
|
||||||
|
} from './common';
|
||||||
|
|
||||||
|
// Returns undefined when the SDP is missing or malformed.
|
||||||
|
export default (
|
||||||
|
sessionDescription: RTCSessionDescriptionInit,
|
||||||
|
isP2p = false,
|
||||||
|
): JoinGroupCallPayload | P2pParsedSdp | undefined => {
|
||||||
|
if (!sessionDescription.sdp) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdpSections = parseSdpSections(sessionDescription.sdp);
|
||||||
|
const sections = sdpSections.reduce((acc: Record<string, SdpSection>, section) => {
|
||||||
|
const name = section.kind === 'session' ? 'header' : section.kind;
|
||||||
|
acc[acc.hasOwnProperty(name) && name === 'video' ? 'screencast' : name] = section;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const lookup = (prefix: string, sectionName?: string) => {
|
||||||
|
return findSdpLineValue(Object.values(sections), prefix, sectionName ? sections[sectionName] : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookupAll = (prefix: string, sectionName: string) => {
|
||||||
|
return sections[sectionName]?.lines
|
||||||
|
.filter((line) => line.startsWith(prefix))
|
||||||
|
.map((line) => line.slice(prefix.length)) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseExtmaps = (sectionName: string) => parseSectionExtmaps(sections[sectionName]);
|
||||||
|
const parsePayloadTypes = (sectionName: string) => parseSectionPayloadTypes(sections[sectionName]);
|
||||||
|
|
||||||
|
const rawSource = lookup('a=ssrc:', 'audio');
|
||||||
|
const sourceAudio = rawSource && Number(rawSource.split(' ')[0]);
|
||||||
|
|
||||||
|
const rawSourceVideo = lookupAll('a=ssrc-group:', 'video').map((line) => line.split(' '));
|
||||||
|
const rawSourceScreencast = lookupAll('a=ssrc-group:', 'screencast').map((line) => line.split(' '));
|
||||||
|
|
||||||
|
if (!rawSourceVideo.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hash, fingerprint] = lookup('a=fingerprint:')?.split(' ') || [];
|
||||||
|
|
||||||
|
const setup = lookup('a=setup:');
|
||||||
|
if (!hash || !fingerprint) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ufrag = lookup('a=ice-ufrag:');
|
||||||
|
const pwd = lookup('a=ice-pwd:');
|
||||||
|
|
||||||
|
if (!ufrag || !pwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: JoinGroupCallPayload = {
|
||||||
|
fingerprints: [
|
||||||
|
{
|
||||||
|
fingerprint,
|
||||||
|
hash,
|
||||||
|
setup: setup === 'active' || setup === 'passive' ? setup : 'passive',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pwd,
|
||||||
|
ufrag,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sourceAudio) {
|
||||||
|
payload.ssrc = toTelegramSource(sourceAudio);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssrcGroups = parseSourceGroups(rawSourceVideo);
|
||||||
|
if (!ssrcGroups.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isP2p && rawSourceScreencast.length) {
|
||||||
|
ssrcGroups.push(...parseSourceGroups(rawSourceScreencast));
|
||||||
|
}
|
||||||
|
|
||||||
|
payload['ssrc-groups'] = ssrcGroups;
|
||||||
|
|
||||||
|
if (!isP2p) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasScreencast = Boolean(sections.screencast);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
audioExtmap: parseExtmaps('audio'),
|
||||||
|
videoExtmap: parseExtmaps('video'),
|
||||||
|
screencastExtmap: hasScreencast ? parseExtmaps('screencast') : [],
|
||||||
|
audioPayloadTypes: parsePayloadTypes('audio'),
|
||||||
|
videoPayloadTypes: parsePayloadTypes('video'),
|
||||||
|
screencastPayloadTypes: hasScreencast ? parsePayloadTypes('screencast') : [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSourceGroups(rawGroups: string[][]): SsrcGroup[] {
|
||||||
|
const result: SsrcGroup[] = [];
|
||||||
|
rawGroups.forEach(([semantics, ...sources]) => {
|
||||||
|
if (!semantics || !sources.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
semantics,
|
||||||
|
sources: sources.map(Number).map(toTelegramSource),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { P2PPayloadType } from './p2pMessage';
|
import type { P2PPayloadType } from './phone/signalingMessages';
|
||||||
|
|
||||||
|
export type StreamType = 'audio' | 'video' | 'presentation';
|
||||||
|
|
||||||
export interface GroupCallParticipant {
|
export interface GroupCallParticipant {
|
||||||
isSelf?: boolean;
|
isSelf?: boolean;
|
||||||
@ -111,7 +113,7 @@ export interface GroupCallTransport {
|
|||||||
|
|
||||||
export interface GroupCallConnectionData {
|
export interface GroupCallConnectionData {
|
||||||
transport: GroupCallTransport;
|
transport: GroupCallTransport;
|
||||||
audio: {
|
audio?: {
|
||||||
'payload-types': PayloadType[];
|
'payload-types': PayloadType[];
|
||||||
'rtp-hdrexts': RTPExtension[];
|
'rtp-hdrexts': RTPExtension[];
|
||||||
};
|
};
|
||||||
@ -1,17 +1,23 @@
|
|||||||
import type { P2PPayloadType } from './p2pMessage';
|
import type { P2PPayloadType } from './phone/signalingMessages';
|
||||||
import type { PayloadType } from './types';
|
import type { PayloadType } from './types';
|
||||||
|
|
||||||
|
export {
|
||||||
|
sanitizePrimitiveRecord,
|
||||||
|
} from '../../util/primitives/primitiveRecord';
|
||||||
|
export type {
|
||||||
|
PrimitiveRecord,
|
||||||
|
PrimitiveRecordValue,
|
||||||
|
} from '../../util/primitives/primitiveRecord';
|
||||||
|
|
||||||
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally
|
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally
|
||||||
/// unsign => sign
|
/// unsign => sign
|
||||||
export function toTelegramSource(source: number) {
|
export function toTelegramSource(source: number) {
|
||||||
// eslint-disable-next-line no-bitwise
|
|
||||||
return source << 0;
|
return source << 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally
|
/// NOTE: telegram returns sign source, while webrtc uses unsign source internally
|
||||||
/// sign => unsign
|
/// sign => unsign
|
||||||
export function fromTelegramSource(source: number) {
|
export function fromTelegramSource(source: number) {
|
||||||
// eslint-disable-next-line no-bitwise
|
|
||||||
return source >>> 0;
|
return source >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,5 +70,4 @@ export const THRESHOLD = 0.1;
|
|||||||
|
|
||||||
export const IS_SCREENSHARE_SUPPORTED = 'getDisplayMedia' in (navigator?.mediaDevices || {});
|
export const IS_SCREENSHARE_SUPPORTED = 'getDisplayMedia' in (navigator?.mediaDevices || {});
|
||||||
export const IS_ECHO_CANCELLATION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().echoCancellation;
|
export const IS_ECHO_CANCELLATION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().echoCancellation;
|
||||||
// @ts-ignore
|
|
||||||
export const IS_NOISE_SUPPRESSION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().noiseSuppression;
|
export const IS_NOISE_SUPPRESSION_SUPPORTED = navigator?.mediaDevices?.getSupportedConstraints().noiseSuppression;
|
||||||
@ -82,23 +82,11 @@
|
|||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
&::-ms-track {
|
|
||||||
cursor: var(--custom-cursor, pointer);
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
border-color: transparent;
|
|
||||||
|
|
||||||
color: transparent;
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
&::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export const IS_CANVAS_FILTER_SUPPORTED = (
|
|||||||
!IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {})
|
!IS_TEST && 'filter' in (document.createElement('canvas').getContext('2d') || {})
|
||||||
);
|
);
|
||||||
export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div');
|
export const IS_REQUEST_FULLSCREEN_SUPPORTED = 'requestFullscreen' in document.createElement('div');
|
||||||
export const ARE_CALLS_SUPPORTED = true;
|
export const ARE_CALLS_SUPPORTED = !IS_FIREFOX;
|
||||||
|
|
||||||
export const IS_WAVE_TRANSFORM_SUPPORTED = !IS_MOBILE
|
export const IS_WAVE_TRANSFORM_SUPPORTED = !IS_MOBILE
|
||||||
&& !IS_FIREFOX // https://bugzilla.mozilla.org/show_bug.cgi?id=1961378
|
&& !IS_FIREFOX // https://bugzilla.mozilla.org/show_bug.cgi?id=1961378
|
||||||
|
|||||||
13
src/util/primitives/primitiveRecord.ts
Normal file
13
src/util/primitives/primitiveRecord.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export type PrimitiveRecordValue = string | number | boolean;
|
||||||
|
export type PrimitiveRecord = Record<string, PrimitiveRecordValue>;
|
||||||
|
|
||||||
|
export function sanitizePrimitiveRecord(data?: Record<string, unknown>) {
|
||||||
|
const result: PrimitiveRecord = {};
|
||||||
|
Object.entries(data || {}).forEach(([key, value]) => {
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(result).length ? result : undefined;
|
||||||
|
}
|
||||||
@ -97,12 +97,6 @@ export default function createConfig(
|
|||||||
{
|
{
|
||||||
directory: path.resolve(__dirname, 'src/lib/rlottie'),
|
directory: path.resolve(__dirname, 'src/lib/rlottie'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
directory: path.resolve(__dirname, 'src/lib/video-preview'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
directory: path.resolve(__dirname, 'src/lib/secret-sauce'),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
devMiddleware: {
|
devMiddleware: {
|
||||||
stats: 'minimal',
|
stats: 'minimal',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user