Calls: Upgrade library (#6916)

This commit is contained in:
zubiden 2026-05-05 13:46:51 +02:00 committed by Alexander Zinchuk
parent f2b3eed845
commit 4586064410
45 changed files with 4421 additions and 938 deletions

View File

@ -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/',

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;
}
if (!this.authKey) {
await this.waitForState;
if (this.isDestroyed || !this.authKey) {
return undefined;
}
return this.decode(data); return this.decode(data);
}
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);
}
}); });
} }
const message = await this.state.decryptMessageData(Buffer.from(data)) as Buffer; drainSignalingData() {
if (!this.shouldUseSctp) {
return [];
}
return JSON.parse(message.toString()); 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);
} }

View 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);
}

View File

@ -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,

View File

@ -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[];

View File

@ -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';

View File

@ -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,

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -48,6 +48,10 @@
} }
} }
.fullscreenDialog {
border-radius: 0;
}
.header { .header {
position: absolute; position: absolute;

View File

@ -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 && (

View File

@ -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';

View File

@ -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, {

View File

@ -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);
global = getGlobal();
const groupCall = selectActiveGroupCall(global); const groupCall = selectActiveGroupCall(global);
if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) { if (groupCall?.participants && Object.keys(groupCall.participants).length > 0) {
void handleUpdateGroupCallParticipants(Object.values(groupCall.participants)); await handleUpdateGroupCallParticipants(Object.values(groupCall.participants));
} }
});
break; break;
} }
case 'updatePhoneCallMediaState': case 'updatePhoneCallMediaState':
@ -128,44 +152,124 @@ 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;
const result = await callApi('confirmPhoneCall', [gB, EMOJI_DATA, EMOJI_OFFSETS]);
if (!result) {
logPhoneCallDebug('Failed to confirm accepted phone call', {
callId: activeCallId,
});
return;
}
const { gA, keyFingerprint, emojis } = result;
global = getGlobal(); global = getGlobal();
const newCall = { if (global.phoneCall?.id !== activeCallId) {
...global.phoneCall, return;
emojis, }
} as ApiPhoneCall;
global = { await callApi('confirmCall', {
...global,
phoneCall: newCall,
};
setGlobal(global);
callApi('confirmCall', {
call, gA, keyFingerprint, 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;
}
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', {
callId: call.id,
error: err instanceof Error ? err.message : String(err),
});
} }
void joinPhoneCall( })();
} else if (state === 'active' && connections && phoneCall?.state !== 'active') {
(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, connections,
actions.sendSignalingData, actions.sendSignalingData,
isOutgoing, isOutgoing,
@ -173,6 +277,13 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
Boolean(call.isP2pAllowed), Boolean(call.isP2pAllowed),
actions.apiUpdate, 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;
}

View File

@ -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) {

View File

@ -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,

View File

@ -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';

View File

@ -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,

View File

@ -436,9 +436,11 @@ export default class MTProtoSender {
const encryptedData = await this._state.encryptMessageData(data); const encryptedData = await this._state.encryptMessageData(data);
postMessage({ postMessage({
payloads: [{
type: 'sendBeacon', type: 'sendBeacon',
data: encryptedData, data: encryptedData,
url: this._fallbackConnection.href, url: this._fallbackConnection.href,
}],
}); });
} }

View File

@ -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;

View File

@ -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",

View File

@ -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);
}

View File

@ -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'),
}),
};
};

View File

@ -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
); );

View File

@ -1,21 +1,30 @@
import parseSdp from './parseSdp'; import type { Conference, Ssrc } from '../sdp/buildSdp';
import { ColibriClass } from './colibriClass';
import type { import type {
GroupCallConnectionData, GroupCallConnectionState, GroupCallParticipant, JoinGroupCallPayload, GroupCallConnectionData, GroupCallConnectionState, GroupCallParticipant, JoinGroupCallPayload, PayloadType,
} from './types'; StreamType,
import buildSdp, { Conference, Ssrc } from './buildSdp'; } from '../types';
import { black, silence } from './blacksilence'; import type { GroupCallDataChannelMessage } from './dataChannelMessages';
import { DEBUG_CALLS } from '../../../config';
import { logDebugMessage } from '../../../util/debugConsole';
import Deferred from '../../../util/Deferred';
import safePlay from '../../../util/safePlay';
import { black, silence } from '../fallbackMedia';
import buildSdp from '../sdp/buildSdp';
import { summarizeSdp } from '../sdp/common';
import parseSdp from '../sdp/groupSdp';
import { import {
fromTelegramSource,
getAmplitude, getAmplitude,
IS_ECHO_CANCELLATION_SUPPORTED, IS_ECHO_CANCELLATION_SUPPORTED,
IS_NOISE_SUPPRESSION_SUPPORTED, IS_NOISE_SUPPRESSION_SUPPORTED,
THRESHOLD, THRESHOLD,
} from './utils'; toTelegramSource,
import Deferred from "../../util/Deferred"; } from '../utils';
import safePlay from "../../util/safePlay";
export type StreamType = 'audio' | 'video' | 'presentation';
const DEFAULT_MID = 3; const DEFAULT_MID = 3;
const GROUP_CALL_VIDEO_MAX_HEIGHT = 720;
type GroupCallState = { type GroupCallState = {
connection?: RTCPeerConnection; connection?: RTCPeerConnection;
screenshareConnection?: RTCPeerConnection; screenshareConnection?: RTCPeerConnection;
@ -33,6 +42,7 @@ type GroupCallState = {
setVolume?: (volume: number) => void; setVolume?: (volume: number) => void;
toggleMute?: (muted: boolean) => void; toggleMute?: (muted: boolean) => void;
getCurrentAmplitude?: () => number; getCurrentAmplitude?: () => number;
dispose?: NoneToVoidFunction;
}>; }>;
onUpdate?: (...args: any[]) => void; onUpdate?: (...args: any[]) => void;
myId?: string; myId?: string;
@ -55,6 +65,237 @@ type GroupCallState = {
let state: GroupCallState | undefined; let state: GroupCallState | undefined;
function logGroupCall(message: string, data: Record<string, unknown> = {}) {
if (!DEBUG_CALLS) return;
logDebugMessage('debug', `[GroupCall] ${message}`, data);
}
function summarizeError(error: unknown) {
return error instanceof Error ? {
name: error.name,
message: error.message,
} : String(error);
}
function summarizeTrack(track: MediaStreamTrack | undefined) {
if (!track) {
return undefined;
}
return {
id: track.id,
kind: track.kind,
enabled: track.enabled,
muted: track.muted,
readyState: track.readyState,
label: track.label,
};
}
function summarizeStream(stream: MediaStream | undefined) {
if (!stream) {
return undefined;
}
return {
id: stream.id,
active: stream.active,
tracks: stream.getTracks().map(summarizeTrack),
};
}
function summarizeTransceiver(transceiver: RTCRtpTransceiver | undefined) {
if (!transceiver) {
return undefined;
}
return {
mid: transceiver.mid,
direction: transceiver.direction,
currentDirection: transceiver.currentDirection,
senderTrack: summarizeTrack(transceiver.sender.track || undefined),
receiverTrack: summarizeTrack(transceiver.receiver.track),
};
}
function summarizeConnection(connection: RTCPeerConnection | undefined) {
if (!connection) {
return undefined;
}
return {
connectionState: connection.connectionState,
iceConnectionState: connection.iceConnectionState,
iceGatheringState: connection.iceGatheringState,
signalingState: connection.signalingState,
transceivers: connection.getTransceivers().map(summarizeTransceiver),
};
}
function summarizeSsrc(ssrc: Ssrc) {
return {
userId: ssrc.userId,
endpoint: ssrc.endpoint,
mid: ssrc.mid,
isMain: ssrc.isMain,
isRemoved: ssrc.isRemoved,
isVideo: ssrc.isVideo,
isPresentation: ssrc.isPresentation,
sourceGroups: ssrc.sourceGroups,
};
}
function summarizeConference(conference: Partial<Conference> | undefined) {
if (!conference) {
return undefined;
}
return {
sessionId: conference.sessionId,
ssrcCount: conference.ssrcs?.length,
ssrcs: conference.ssrcs?.map(summarizeSsrc),
transport: conference.transport ? {
ufrag: conference.transport.ufrag,
candidateCount: conference.transport.candidates?.length,
fingerprintCount: conference.transport.fingerprints?.length,
isRtcpMux: conference.transport['rtcp-mux'],
} : undefined,
audioPayloadTypes: conference.audioPayloadTypes?.map((payloadType) => `${payloadType.id}:${payloadType.name}`),
videoPayloadTypes: conference.videoPayloadTypes?.map((payloadType) => `${payloadType.id}:${payloadType.name}`),
audioExtensions: conference.audioExtensions?.map((extension) => `${extension.id}:${extension.uri}`),
videoExtensions: conference.videoExtensions?.map((extension) => `${extension.id}:${extension.uri}`),
};
}
function summarizeParticipant(participant: GroupCallParticipant) {
return {
id: participant.id,
isSelf: participant.isSelf,
isMuted: participant.isMuted,
isMutedByMe: participant.isMutedByMe,
isLeft: participant.isLeft,
isVideoJoined: participant.isVideoJoined,
source: participant.source,
volume: participant.volume,
video: participant.video ? {
endpoint: participant.video.endpoint,
sourceGroups: participant.video.sourceGroups,
isPaused: participant.video.isPaused,
} : undefined,
presentation: participant.presentation ? {
endpoint: participant.presentation.endpoint,
sourceGroups: participant.presentation.sourceGroups,
isPaused: participant.presentation.isPaused,
} : undefined,
};
}
function summarizeConnectionData(data: GroupCallConnectionData) {
return {
isStream: data.stream,
transport: {
ufrag: data.transport.ufrag,
candidateCount: data.transport.candidates.length,
fingerprintCount: data.transport.fingerprints.length,
isRtcpMux: data.transport['rtcp-mux'],
},
audioPayloadTypes: data.audio?.['payload-types'].map((payloadType) => `${payloadType.id}:${payloadType.name}`),
videoPayloadTypes: data.video['payload-types'].map((payloadType) => `${payloadType.id}:${payloadType.name}`),
audioExtensions: data.audio?.['rtp-hdrexts'].map((extension) => `${extension.id}:${extension.uri}`),
videoExtensions: data.video['rtp-hdrexts'].map((extension) => `${extension.id}:${extension.uri}`),
videoEndpoint: data.video.endpoint,
serverSources: data.video.server_sources,
};
}
function getPresentationSourceFromVideoGroups(ssrcGroups: JoinGroupCallPayload['ssrc-groups']) {
const firstVideoSource = ssrcGroups?.[0]?.sources[0];
if (!firstVideoSource) {
return undefined;
}
return toTelegramSource(fromTelegramSource(firstVideoSource) - 1);
}
function buildPayloadTypeLines(payloadType: PayloadType) {
const {
channels, id, name, clockrate, parameters,
} = payloadType;
const lines = [
`a=rtpmap:${id} ${name}/${clockrate}${channels ? `/${channels}` : ''}`,
];
if (parameters) {
const parametersString = Object.keys(parameters).map((key) => `${key}=${parameters[key]};`).join(' ');
lines.push(`a=fmtp:${id} ${parametersString}`);
}
payloadType['rtcp-fbs']?.forEach((fbParam) => {
lines.push(`a=rtcp-fb:${id} ${fbParam.type}${fbParam.subtype ? ` ${fbParam.subtype}` : ''}`);
});
return lines;
}
function mungePresentationOfferSdp(sdp: string) {
const videoPayloadTypes = state?.conference?.videoPayloadTypes;
const videoExtensions = state?.conference?.videoExtensions;
if (!videoPayloadTypes?.length) {
logGroupCall('presentation offer munge skipped: missing protocol video params', {
hasVideoPayloadTypes: Boolean(videoPayloadTypes?.length),
hasVideoExtensions: Boolean(videoExtensions?.length),
});
return sdp;
}
const additions = videoPayloadTypes.flatMap(buildPayloadTypeLines);
if (videoExtensions?.length) {
additions.push(...videoExtensions.map(({ id, uri }) => `a=extmap:${id} ${uri}`));
}
const sections = sdp.split(/\r?\nm=/).map((section, index) => index === 0 ? section : `m=${section}`);
const mungedSections = sections.map((section) => {
const lines = section.split(/\r?\n/).filter(Boolean);
if (!lines[0]?.startsWith('m=video ')) {
return section;
}
const mLineParts = lines[0].split(' ');
const mungedLines: string[] = [
`${mLineParts.slice(0, 3).join(' ')} ${videoPayloadTypes.map(({ id }) => id).join(' ')}`,
];
let didAddProtocolLines = false;
lines.slice(1).forEach((line) => {
if (
line.startsWith('a=rtpmap:')
|| line.startsWith('a=fmtp:')
|| line.startsWith('a=rtcp-fb:')
|| line.startsWith('a=extmap:')
) {
return;
}
mungedLines.push(line);
if (line === 'a=rtcp-mux') {
mungedLines.push(...additions);
didAddProtocolLines = true;
}
});
if (!didAddProtocolLines) {
mungedLines.splice(1, 0, ...additions);
}
return mungedLines.join('\r\n');
});
return `${mungedSections.join('\r\n')}\r\n`;
}
export async function getDevices(streamType: StreamType, isInput = true) { export async function getDevices(streamType: StreamType, isInput = true) {
return (await navigator.mediaDevices.enumerateDevices()) return (await navigator.mediaDevices.enumerateDevices())
.filter((l) => l.kind === `${streamType}${isInput ? 'input' : 'output'}`); .filter((l) => l.kind === `${streamType}${isInput ? 'input' : 'output'}`);
@ -73,7 +314,7 @@ export function toggleSpeaker() {
}); });
if (state.participantFunctions) { if (state.participantFunctions) {
Object.values(state.participantFunctions).forEach((l) => { Object.values(state.participantFunctions).forEach((l) => {
l.toggleMute?.(!!state?.isSpeakerDisabled); l.toggleMute?.(Boolean(state?.isSpeakerDisabled));
}); });
} }
} }
@ -213,22 +454,38 @@ export async function switchCameraInput() {
await sender.replaceTrack(newStream.getTracks()[0]); await sender.replaceTrack(newStream.getTracks()[0]);
state.streams[state.myId].video = newStream; state.streams[state.myId].video = newStream;
} catch (e) { } catch (err) {
logGroupCall('switch camera failed', {
error: summarizeError(err),
});
} }
} }
export async function toggleStream(streamType: StreamType, value: boolean | undefined = undefined) { export async function toggleStream(streamType: StreamType, value: boolean | undefined = undefined) {
if (!state || !state.myId || !state.connection || !state.streams) { if (!state || !state.myId || !state.connection || !state.streams) {
logGroupCall('toggle stream skipped: missing state', {
streamType,
hasState: Boolean(state),
});
return; return;
} }
const stream = getUserStreams(state.myId)?.[streamType]; const stream = getUserStreams(state.myId)?.[streamType];
if (!stream) return; if (!stream) {
logGroupCall('toggle stream skipped: missing stream', {
streamType,
myId: state.myId,
});
return;
}
const track = stream.getTracks()[0]; const track = stream.getTracks()[0];
if (!track) { if (!track) {
logGroupCall('toggle stream skipped: missing track', {
streamType,
stream: summarizeStream(stream),
});
return; return;
} }
@ -238,6 +495,10 @@ export async function toggleStream(streamType: StreamType, value: boolean | unde
].find((l) => track.id === l.track?.id); ].find((l) => track.id === l.track?.id);
if (!sender) { if (!sender) {
logGroupCall('toggle stream skipped: missing sender', {
streamType,
track: summarizeTrack(track),
});
return; return;
} }
@ -301,11 +562,18 @@ export async function toggleStream(streamType: StreamType, value: boolean | unde
updateGroupCallStreams(state.myId!); updateGroupCallStreams(state.myId!);
if (streamType === 'presentation' && !value) leavePresentation(true); if (streamType === 'presentation' && !value) leavePresentation(true);
} catch (e) { } catch (e) {
logGroupCall('toggle stream failed', {
streamType,
shouldEnable: value,
error: summarizeError(e),
});
} }
} }
function updateConnectionState(connectionState: GroupCallConnectionState) { function updateConnectionState(connectionState: GroupCallConnectionState) {
logGroupCall('connection state update', {
connectionState,
});
state?.onUpdate?.({ state?.onUpdate?.({
'@type': 'updateGroupCallConnectionState', '@type': 'updateGroupCallConnectionState',
connectionState, connectionState,
@ -317,6 +585,13 @@ export function leaveGroupCall() {
return; return;
} }
logGroupCall('leave', {
myId: state.myId,
connection: summarizeConnection(state.connection),
screenshareConnection: summarizeConnection(state.screenshareConnection),
streamUserCount: Object.keys(state.streams || {}).length,
});
if (state.myId && state.streams?.[state.myId]) { if (state.myId && state.streams?.[state.myId]) {
Object.values(state.streams[state.myId] || {}).forEach((stream) => { Object.values(state.streams[state.myId] || {}).forEach((stream) => {
stream?.getTracks().forEach((track) => { stream?.getTracks().forEach((track) => {
@ -336,6 +611,11 @@ export function leaveGroupCall() {
if (state.analyserInterval) { if (state.analyserInterval) {
clearInterval(state.analyserInterval); clearInterval(state.analyserInterval);
} }
if (state.participantFunctions) {
Object.values(state.participantFunctions).forEach(({ dispose }) => {
dispose?.();
});
}
state = undefined; state = undefined;
} }
@ -344,7 +624,10 @@ function analyzeAmplitudes() {
if (!state || !state.participantFunctions) return; if (!state || !state.participantFunctions) return;
Object.keys(state.participantFunctions).forEach((id) => { Object.keys(state.participantFunctions).forEach((id) => {
const { getCurrentAmplitude } = state!.participantFunctions![Number(id)]; const participantFunctions = state!.participantFunctions![id];
if (!participantFunctions) return;
const { getCurrentAmplitude } = participantFunctions;
if (getCurrentAmplitude) { if (getCurrentAmplitude) {
const amplitude = getCurrentAmplitude(); const amplitude = getCurrentAmplitude();
@ -358,19 +641,87 @@ function analyzeAmplitudes() {
}); });
} }
function getRemoteVideoEndpoints() {
const endpoints: string[] = [];
state?.conference?.ssrcs?.forEach((ssrc) => {
if (!ssrc.isVideo || ssrc.isRemoved || !ssrc.userId || !ssrc.endpoint) {
return;
}
if (!endpoints.includes(ssrc.endpoint)) {
endpoints.push(ssrc.endpoint);
}
});
return endpoints;
}
function sendDataChannelMessage(message: GroupCallDataChannelMessage) {
if (!state?.dataChannel || state.dataChannel.readyState !== 'open') {
logGroupCall('data channel send skipped', {
colibriClass: message.colibriClass,
hasDataChannel: Boolean(state?.dataChannel),
readyState: state?.dataChannel?.readyState,
});
return false;
}
const data = JSON.stringify(message);
state.dataChannel.send(data);
return true;
}
function updateRemoteVideoConstraints() {
const endpoints = getRemoteVideoEndpoints();
const constraints: Record<string, { minHeight: number; maxHeight: number }> = {};
endpoints.forEach((endpoint) => {
constraints[endpoint] = {
minHeight: 0,
maxHeight: GROUP_CALL_VIDEO_MAX_HEIGHT,
};
});
sendDataChannelMessage({
colibriClass: 'ReceiverVideoConstraints',
defaultConstraints: {
maxHeight: 0,
},
constraints,
onStageEndpoints: endpoints,
});
}
function createDataChannel(connection: RTCPeerConnection) { function createDataChannel(connection: RTCPeerConnection) {
const dataChannel = connection.createDataChannel('data', { const dataChannel = connection.createDataChannel('data', {
id: 0, id: 0,
}); });
dataChannel.onopen = () => { dataChannel.onopen = () => {
// console.log('Data channel open!'); updateRemoteVideoConstraints();
}; };
dataChannel.onmessage = (e) => { dataChannel.onmessage = (e) => {
// console.log('onmessage'); if (typeof e.data !== 'string') {
const data = JSON.parse(e.data) as ColibriClass; logGroupCall('data channel non-string message', {
// console.log(data); dataType: typeof e.data,
});
return;
}
let data: GroupCallDataChannelMessage;
try {
data = JSON.parse(e.data) as GroupCallDataChannelMessage;
} catch (err) {
logGroupCall('data channel message parse failed', {
dataLength: e.data.length,
error: summarizeError(err),
});
return;
}
switch (data.colibriClass) { switch (data.colibriClass) {
case 'DominantSpeakerEndpointChangeEvent': case 'DominantSpeakerEndpointChangeEvent':
break; break;
@ -384,8 +735,12 @@ function createDataChannel(connection: RTCPeerConnection) {
}; };
dataChannel.onerror = (e) => { dataChannel.onerror = (e) => {
console.log('%conerror', 'background: green; font-size: 5em'); logGroupCall('data channel error', {
console.error(e); id: dataChannel.id,
label: dataChannel.label,
readyState: dataChannel.readyState,
error: e instanceof ErrorEvent ? e.message : summarizeError(e),
});
}; };
return dataChannel; return dataChannel;
@ -393,6 +748,9 @@ function createDataChannel(connection: RTCPeerConnection) {
export async function handleUpdateGroupCallParticipants(updatedParticipants: GroupCallParticipant[]) { export async function handleUpdateGroupCallParticipants(updatedParticipants: GroupCallParticipant[]) {
if (!state) { if (!state) {
logGroupCall('participants update skipped: missing state', {
participantCount: updatedParticipants.length,
});
return; return;
} }
@ -401,6 +759,15 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
} = state; } = state;
if (!participants || !conference || !connection || !conference.ssrcs || !conference.transport || !myId) { if (!participants || !conference || !connection || !conference.ssrcs || !conference.transport || !myId) {
logGroupCall('participants update skipped: incomplete state', {
participantCount: updatedParticipants.length,
hasParticipants: Boolean(participants),
hasConference: Boolean(conference),
hasConnection: Boolean(connection),
hasSsrcs: Boolean(conference?.ssrcs),
hasTransport: Boolean(conference?.transport),
myId,
});
return; return;
} }
@ -410,11 +777,14 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
&& participant.source && participant.source
!== state?.conference?.ssrcs?.find((l) => l.isMain && !l.isVideo)?.sourceGroups[0].sources[0]; !== state?.conference?.ssrcs?.find((l) => l.isMain && !l.isVideo)?.sourceGroups[0].sources[0];
})) { })) {
logGroupCall('participants update detected self source mismatch; leaving', {
participants: updatedParticipants.map(summarizeParticipant),
mainAudioSource: state?.conference?.ssrcs?.find((l) => l.isMain && !l.isVideo)?.sourceGroups[0].sources[0],
});
leaveGroupCall(); leaveGroupCall();
return; return;
} }
const newEndpoints: string[] = [];
updatedParticipants.forEach((participant) => { updatedParticipants.forEach((participant) => {
if (participant.isSelf) { if (participant.isSelf) {
if (participant.isMuted && !participant.canSelfUnmute) { if (participant.isMuted && !participant.canSelfUnmute) {
@ -446,7 +816,7 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
if (ssrc.isVideo) { if (ssrc.isVideo) {
if (!ssrc.isPresentation) { if (!ssrc.isPresentation) {
if (!!participant.video && ssrc.endpoint === participant.video.endpoint) { if (Boolean(participant.video) && ssrc.endpoint === participant.video.endpoint) {
hasVideo = true; hasVideo = true;
} }
// console.log('has video = ', hasVideo, ' removed=', isVideoLeft); // console.log('has video = ', hasVideo, ' removed=', isVideoLeft);
@ -454,7 +824,7 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
} }
if (ssrc.isPresentation) { if (ssrc.isPresentation) {
if (!!participant.presentation && ssrc.endpoint === participant.presentation.endpoint) { if (Boolean(participant.presentation) && ssrc.endpoint === participant.presentation.endpoint) {
hasPresentation = true; hasPresentation = true;
} }
// console.log('has presentation, removed=', isPresentationLeft); // console.log('has presentation, removed=', isPresentationLeft);
@ -464,8 +834,11 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
}); });
if (!isAudioLeft && !hasAudio) { if (!isAudioLeft && !hasAudio) {
// console.log('add audio');
state!.lastMid = state!.lastMid + 1; state!.lastMid = state!.lastMid + 1;
logGroupCall('participant audio source added', {
participant: summarizeParticipant(participant),
mid: state!.lastMid.toString(),
});
conference.ssrcs!.push({ conference.ssrcs!.push({
userId: participant.id, userId: participant.id,
isMain: false, isMain: false,
@ -474,28 +847,37 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
sourceGroups: [{ sourceGroups: [{
sources: [participant.source], sources: [participant.source],
}], }],
mid: state!.lastMid.toString() mid: state!.lastMid.toString(),
}); });
} }
if (!isVideoLeft && !hasVideo && participant.video) { if (!isVideoLeft && !hasVideo && participant.video) {
// console.log('add video', participant.video);
state!.lastMid = state!.lastMid + 1; state!.lastMid = state!.lastMid + 1;
newEndpoints.push(participant.video.endpoint); logGroupCall('participant video source added', {
participant: summarizeParticipant(participant),
endpoint: participant.video.endpoint,
mid: state!.lastMid.toString(),
sourceGroups: participant.video.sourceGroups,
});
conference.ssrcs!.push({ conference.ssrcs!.push({
userId: participant.id, userId: participant.id,
isMain: false, isMain: false,
endpoint: participant.video.endpoint, endpoint: participant.video.endpoint,
isVideo: true, isVideo: true,
sourceGroups: participant.video.sourceGroups, sourceGroups: participant.video.sourceGroups,
mid: state!.lastMid.toString() mid: state!.lastMid.toString(),
}); });
} }
if (!isPresentationLeft && !hasPresentation && participant.presentation) { if (!isPresentationLeft && !hasPresentation && participant.presentation) {
// console.log('add presentation');
state!.lastMid = state!.lastMid + 1; state!.lastMid = state!.lastMid + 1;
logGroupCall('participant presentation source added', {
participant: summarizeParticipant(participant),
endpoint: participant.presentation.endpoint,
mid: state!.lastMid.toString(),
sourceGroups: participant.presentation.sourceGroups,
});
conference.ssrcs!.push({ conference.ssrcs!.push({
isPresentation: true, isPresentation: true,
userId: participant.id, userId: participant.id,
@ -503,7 +885,7 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
endpoint: participant.presentation.endpoint, endpoint: participant.presentation.endpoint,
isVideo: true, isVideo: true,
sourceGroups: participant.presentation.sourceGroups, sourceGroups: participant.presentation.sourceGroups,
mid: state!.lastMid.toString() mid: state!.lastMid.toString(),
}); });
} }
}); });
@ -516,54 +898,43 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
} }
const sdp = buildSdp(conference as Conference); const sdp = buildSdp(conference as Conference);
try {
await connection.setRemoteDescription({ await connection.setRemoteDescription({
type: 'offer', type: 'offer',
sdp, sdp,
}); });
try {
const answer = await connection.createAnswer(); const answer = await connection.createAnswer();
await connection.setLocalDescription(answer); await connection.setLocalDescription(answer);
updateRemoteVideoConstraints();
updateGroupCallStreams(myId); updateGroupCallStreams(myId);
if (state.updatingParticipantsQueue.length > 0) { if (state.updatingParticipantsQueue.length > 0) {
// eslint-disable-next-line no-restricted-syntax
for (const newConference of state.updatingParticipantsQueue) { for (const newConference of state.updatingParticipantsQueue) {
const queuedSdp = buildSdp(newConference as Conference);
await connection.setRemoteDescription({ await connection.setRemoteDescription({
type: 'offer', type: 'offer',
sdp: buildSdp(newConference as Conference), sdp: queuedSdp,
}); });
const answerNew = await connection.createAnswer(); const answerNew = await connection.createAnswer();
await connection.setLocalDescription(answerNew); await connection.setLocalDescription(answerNew);
updateRemoteVideoConstraints();
updateGroupCallStreams(myId); updateGroupCallStreams(myId);
// if (newEndpoints.length > 0) {
// sendDataChannelMessage({
// colibriClass: 'ReceiverVideoConstraints',
// defaultConstraints: {
// maxHeight: 0,
// },
// constraints: {
// ...(newEndpoints.reduce((acc: Record<string, any>, el) => {
// acc[el] = {
// minHeight: 0,
// maxHeight: 1080,
// };
// return acc;
// }, {})),
// },
// onStageEndpoints: [],
// });
// }
} }
} }
state.updatingParticipantsQueue = undefined;
} catch (e) { } catch (e) {
console.error(e); logGroupCall('participants negotiation failed', {
error: summarizeError(e),
connection: summarizeConnection(connection),
});
} finally {
if (state) {
state.updatingParticipantsQueue = undefined;
}
} }
} }
// //
// function sendDataChannelMessage(message: ColibriClass) { // function sendDataChannelMessage(message: GroupCallDataChannelMessage) {
// if (!state || !state.dataChannel || state.dataChannel.readyState !== 'open') { // if (!state || !state.dataChannel || state.dataChannel.readyState !== 'open') {
// return; // return;
// } // }
@ -574,6 +945,10 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
export async function handleUpdateGroupCallConnection(data: GroupCallConnectionData, isPresentation: boolean) { export async function handleUpdateGroupCallConnection(data: GroupCallConnectionData, isPresentation: boolean) {
if (!state) { if (!state) {
logGroupCall('connection update skipped: missing state', {
isPresentation,
data: summarizeConnectionData(data),
});
return; return;
} }
@ -581,19 +956,26 @@ export async function handleUpdateGroupCallConnection(data: GroupCallConnectionD
const connection = isPresentation ? state.screenshareConnection : state.connection; const connection = isPresentation ? state.screenshareConnection : state.connection;
if (!conference || !connection || !conference.ssrcs) { if (!conference || !connection || !conference.ssrcs) {
logGroupCall('connection update skipped: incomplete state', {
isPresentation,
hasConference: Boolean(conference),
hasConnection: Boolean(connection),
hasSsrcs: Boolean(conference?.ssrcs),
data: summarizeConnectionData(data),
});
return; return;
} }
const sessionId = Date.now(); const newConference: Conference = {
const newConference = {
...conference, ...conference,
ssrcs: conference.ssrcs,
transport: data.transport, transport: data.transport,
sessionId, sessionId: Date.now(),
audioExtensions: data.audio?.['rtp-hdrexts'], audioExtensions: data.audio?.['rtp-hdrexts'] || conference.audioExtensions || [],
audioPayloadTypes: data.audio?.['payload-types'], audioPayloadTypes: data.audio?.['payload-types'] || conference.audioPayloadTypes || [],
videoExtensions: data.video?.['rtp-hdrexts'], videoExtensions: data.video['rtp-hdrexts'],
videoPayloadTypes: data.video?.['payload-types'], videoPayloadTypes: data.video['payload-types'],
} as Conference; };
state = { state = {
...state, ...state,
@ -601,24 +983,41 @@ export async function handleUpdateGroupCallConnection(data: GroupCallConnectionD
}; };
try { try {
const sdp = buildSdp(newConference, true, isPresentation);
await connection.setRemoteDescription({ await connection.setRemoteDescription({
type: 'answer', type: 'answer',
sdp: buildSdp(newConference, true, isPresentation), sdp,
}); });
// state.resolveTest(); // state.resolveTest();
// state.test = true; // state.test = true;
} catch (e) { } catch (e) {
console.error(e); logGroupCall('server answer apply failed', {
isPresentation,
error: summarizeError(e),
connection: summarizeConnection(connection),
});
} }
} }
function handleTrack(e: RTCTrackEvent) { function handleTrack(e: RTCTrackEvent) {
if (!state || !state.audioElement || !state.audioContext || !state.mediaStream) { if (!state || !state.audioElement || !state.audioContext || !state.mediaStream) {
logGroupCall('remote track skipped: incomplete state', {
track: summarizeTrack(e.track),
streamCount: e.streams.length,
hasState: Boolean(state),
hasAudioElement: Boolean(state?.audioElement),
hasAudioContext: Boolean(state?.audioContext),
hasMediaStream: Boolean(state?.mediaStream),
});
return; return;
} }
const ssrc = state.conference?.ssrcs?.find((l) => l.endpoint === e.track.id); const ssrc = state.conference?.ssrcs?.find((l) => l.endpoint === e.track.id);
if (!ssrc || !ssrc.userId) { if (!ssrc || !ssrc.userId) {
logGroupCall('remote track skipped: no matching ssrc', {
track: summarizeTrack(e.track),
endpoint: e.track.id,
conference: summarizeConference(state.conference),
});
return; return;
} }
@ -628,22 +1027,41 @@ function handleTrack(e: RTCTrackEvent) {
const streamType = (e.track.kind === 'video' ? (isPresentation ? 'presentation' : 'video') : 'audio') as StreamType; const streamType = (e.track.kind === 'video' ? (isPresentation ? 'presentation' : 'video') : 'audio') as StreamType;
e.track.onended = () => { e.track.onended = () => {
logGroupCall('remote track ended', {
userId,
streamType,
track: summarizeTrack(e.track),
});
if (streamType === 'audio' && state?.streams?.[userId]?.audio === stream) {
state.participantFunctions?.[userId]?.dispose?.();
delete state.participantFunctions?.[userId];
}
delete state?.streams?.[userId][streamType]; delete state?.streams?.[userId][streamType];
updateGroupCallStreams(userId); updateGroupCallStreams(userId);
}; };
e.track.onmute = () => undefined;
e.track.onunmute = () => undefined;
const stream = e.streams[0]; const stream = e.streams[0];
if (!stream) {
logGroupCall('remote track skipped: missing stream', {
userId,
streamType,
track: summarizeTrack(e.track),
});
return;
}
if (e.track.kind === 'audio') { if (e.track.kind === 'audio') {
const { mediaStream } = state; const { audioContext } = state;
const audioContext = new (window.AudioContext)(); state.participantFunctions?.[userId]?.dispose?.();
const source = audioContext.createMediaStreamSource(stream); const source = audioContext.createMediaStreamSource(stream);
const gainNode = audioContext.createGain(); const gainNode = audioContext.createGain();
gainNode.gain.value = (participant?.volume || 10000) / 10000; gainNode.gain.value = (participant?.volume || 10000) / 10000;
const muteNode = audioContext.createGain(); const muteNode = audioContext.createGain();
gainNode.gain.value = 1; muteNode.gain.value = state.isSpeakerDisabled ? 0 : 1;
const analyser = audioContext.createAnalyser(); const analyser = audioContext.createAnalyser();
analyser.minDecibels = -100; analyser.minDecibels = -100;
@ -653,8 +1071,6 @@ function handleTrack(e: RTCTrackEvent) {
source.connect(analyser).connect(muteNode).connect(gainNode).connect(audioContext.destination); source.connect(analyser).connect(muteNode).connect(gainNode).connect(audioContext.destination);
mediaStream!.addTrack(source.mediaStream.getAudioTracks()[0]);
// https://stackoverflow.com/questions/41784137/webrtc-doesnt-work-with-audiocontext#comment117600018_41784241 // https://stackoverflow.com/questions/41784137/webrtc-doesnt-work-with-audiocontext#comment117600018_41784241
const test = new Audio(); const test = new Audio();
test.srcObject = stream; test.srcObject = stream;
@ -679,6 +1095,12 @@ function handleTrack(e: RTCTrackEvent) {
analyser.getByteFrequencyData(array); analyser.getByteFrequencyData(array);
return getAmplitude(array, 1.5); return getAmplitude(array, 1.5);
}, },
dispose: () => {
source.disconnect();
analyser.disconnect();
muteNode.disconnect();
gainNode.disconnect();
},
}, },
}, },
}; };
@ -700,7 +1122,7 @@ function handleTrack(e: RTCTrackEvent) {
function initializeConnection( function initializeConnection(
streams: MediaStream[], streams: MediaStream[],
resolve: (payload: JoinGroupCallPayload) => void, resolve: (payload?: JoinGroupCallPayload) => void,
isPresentation = false, isPresentation = false,
) { ) {
const connection = new RTCPeerConnection(); const connection = new RTCPeerConnection();
@ -714,7 +1136,6 @@ function initializeConnection(
if (!isPresentation) { if (!isPresentation) {
connection.oniceconnectionstatechange = () => { connection.oniceconnectionstatechange = () => {
const connectionState = connection.iceConnectionState; const connectionState = connection.iceConnectionState;
console.log('iceconnectionstatechange', connectionState);
if (connectionState === 'connected' || connectionState === 'completed') { if (connectionState === 'connected' || connectionState === 'completed') {
updateConnectionState('connected'); updateConnectionState('connected');
} else if (connectionState === 'checking' || connectionState === 'new') { } else if (connectionState === 'checking' || connectionState === 'new') {
@ -724,34 +1145,48 @@ function initializeConnection(
} }
}; };
} }
connection.onconnectionstatechange = () => {
console.log('connectionstatechange', connection.connectionState);
}
connection.ontrack = handleTrack; connection.ontrack = handleTrack;
connection.onnegotiationneeded = async () => { connection.onnegotiationneeded = async () => {
if (!state) return; if (!state) return;
console.log('onnegotiationneeded');
const { myId } = state; const { myId } = state;
if (!myId) { if (!myId) {
return; return;
} }
const offer = await connection.createOffer({ let offer = await connection.createOffer({
offerToReceiveVideo: true, offerToReceiveVideo: true,
offerToReceiveAudio: !isPresentation, offerToReceiveAudio: !isPresentation,
}); });
console.log('offer created'); if (isPresentation && offer.sdp) {
offer = {
...offer,
sdp: mungePresentationOfferSdp(offer.sdp),
};
}
logGroupCall('local offer created', {
isPresentation,
sdp: offer.sdp ? summarizeSdp(offer.sdp, true) : undefined,
});
await connection.setLocalDescription(offer); await connection.setLocalDescription(offer);
console.log('local desc set');
if (!offer.sdp) { if (!offer.sdp) {
return; return;
} }
const sdp = parseSdp(offer); const sdp = parseSdp(offer);
if (!sdp) {
logGroupCall('local offer parse failed', {
isPresentation,
});
resolve(undefined);
return;
}
if (isPresentation && !sdp.ssrc) {
sdp.ssrc = getPresentationSourceFromVideoGroups(sdp['ssrc-groups']);
}
const audioSsrc: Ssrc | undefined = !isPresentation ? { const audioSsrc: Ssrc | undefined = !isPresentation ? {
userId: '', userId: '',
sourceGroups: [ sourceGroups: [
@ -764,7 +1199,7 @@ function initializeConnection(
isVideo: false, isVideo: false,
isPresentation, isPresentation,
endpoint: isPresentation ? '1' : '0', endpoint: isPresentation ? '1' : '0',
mid: isPresentation ? '1' : '0' mid: isPresentation ? '1' : '0',
} : undefined; } : undefined;
const videoSsrc: Ssrc | undefined = sdp['ssrc-groups'] && { const videoSsrc: Ssrc | undefined = sdp['ssrc-groups'] && {
@ -774,7 +1209,7 @@ function initializeConnection(
isMain: true, isMain: true,
isVideo: true, isVideo: true,
endpoint: isPresentation ? '0' : '1', endpoint: isPresentation ? '0' : '1',
mid: isPresentation ? '0' : '1' mid: isPresentation ? '0' : '1',
}; };
const conference = isPresentation ? state.screenshareConference : state.conference; const conference = isPresentation ? state.screenshareConference : state.conference;
@ -824,6 +1259,7 @@ function initializeConnection(
export async function startSharingScreen(): Promise<JoinGroupCallPayload | undefined> { export async function startSharingScreen(): Promise<JoinGroupCallPayload | undefined> {
if (!state) { if (!state) {
logGroupCall('start sharing screen skipped: missing state');
return undefined; return undefined;
} }
@ -831,11 +1267,16 @@ export async function startSharingScreen(): Promise<JoinGroupCallPayload | undef
const stream: MediaStream | undefined = await getUserStream('presentation'); const stream: MediaStream | undefined = await getUserStream('presentation');
if (!stream) { if (!stream) {
logGroupCall('start sharing screen failed: missing stream');
return undefined; return undefined;
} }
stream.getTracks()[0].onended = () => { stream.getTracks()[0].onended = () => {
if (state && state.myId) { if (state && state.myId) {
logGroupCall('screen sharing track ended', {
myId: state.myId,
stream: summarizeStream(stream),
});
delete state.streams?.[state.myId].presentation; delete state.streams?.[state.myId].presentation;
updateGroupCallStreams(state.myId); updateGroupCallStreams(state.myId);
leavePresentation(); leavePresentation();
@ -851,6 +1292,9 @@ export async function startSharingScreen(): Promise<JoinGroupCallPayload | undef
}; };
}); });
} catch (e) { } catch (e) {
logGroupCall('start sharing screen failed', {
error: summarizeError(e),
});
return undefined; return undefined;
} }
} }
@ -860,11 +1304,15 @@ export function joinGroupCall(
audioContext: AudioContext, audioContext: AudioContext,
audioElement: HTMLAudioElement, audioElement: HTMLAudioElement,
onUpdate: (...args: any[]) => void, onUpdate: (...args: any[]) => void,
): Promise<JoinGroupCallPayload> { ): Promise<JoinGroupCallPayload | undefined> {
if (state) { if (state) {
throw Error('Already in call'); throw Error('Already in call');
} }
logGroupCall('join', {
myId,
audioContextState: audioContext.state,
});
updateConnectionState('connecting'); updateConnectionState('connecting');
const mediaStream = new MediaStream(); const mediaStream = new MediaStream();
@ -888,7 +1336,11 @@ export function joinGroupCall(
}; };
// Prepare microphone // Prepare microphone
getUserStream('audio'); void getUserStream('audio').catch((err) => {
logGroupCall('initial microphone preparation failed', {
error: summarizeError(err),
});
});
return new Promise((resolve) => { return new Promise((resolve) => {
state = { state = {

View File

@ -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';

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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`;

View 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 || '',
};
});
}

View 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;
}

View File

@ -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[];
}; };

View File

@ -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;

View File

@ -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;
} }

View File

@ -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

View 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;
}

View File

@ -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',