tweb/src/lib/appManagers/appGroupCallsManager.ts
2022-03-23 17:34:10 +02:00

657 lines
22 KiB
TypeScript

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*
* Originally from:
* https://github.com/evgeny-nadymov/telegram-react
* Copyright (C) 2018 Evgeny Nadymov
* https://github.com/evgeny-nadymov/telegram-react/blob/master/LICENSE
*/
import { MOUNT_CLASS_TO } from "../../config/debug";
import AudioAssetPlayer from "../../helpers/audioAssetPlayer";
import safeReplaceObject from "../../helpers/object/safeReplaceObject";
import { nextRandomUint } from "../../helpers/random";
import tsNow from "../../helpers/tsNow";
import { GroupCall, GroupCallParticipant, GroupCallParticipantVideo, GroupCallParticipantVideoSourceGroup, InputGroupCall, Peer, PhoneJoinGroupCall, PhoneJoinGroupCallPresentation, Update, Updates } from "../../layer";
import GroupCallInstance from "../calls/groupCallInstance";
import GROUP_CALL_STATE from "../calls/groupCallState";
import createMainStreamManager from "../calls/helpers/createMainStreamManager";
import { generateSsrc } from "../calls/localConferenceDescription";
import { WebRTCLineType } from "../calls/sdpBuilder";
import StreamManager from "../calls/streamManager";
import { Ssrc } from "../calls/types";
import { logger } from "../logger";
import apiManager from "../mtproto/mtprotoworker";
import { NULL_PEER_ID } from "../mtproto/mtproto_config";
import rootScope from "../rootScope";
import apiUpdatesManager from "./apiUpdatesManager";
import appChatsManager from "./appChatsManager";
import appPeersManager from "./appPeersManager";
import appUsersManager from "./appUsersManager";
export type GroupCallId = GroupCall['id'];
export type MyGroupCall = GroupCall | InputGroupCall;
export type GroupCallConnectionType = 'main' | 'presentation';
export type JoinGroupCallJsonPayload = {
fingerprints: {
fingerprint: string;
setup: string;
hash: string;
}[];
pwd: string;
ssrc: number;
'ssrc-groups': GroupCallParticipantVideoSourceGroup.groupCallParticipantVideoSourceGroup[];
ufrag: string;
};
const GET_PARTICIPANTS_LIMIT = 100;
let IS_MUTED = true;
export type GroupCallOutputSource = 'main' | 'presentation' | number;
export type GroupCallAudioAssetName = "group_call_connect.mp3" | "group_call_end.mp3" | "group_call_start.mp3" | "voip_onallowtalk.mp3";
export class AppGroupCallsManager {
private log: ReturnType<typeof logger>;
private groupCalls: Map<GroupCallId, MyGroupCall>;
private participants: Map<GroupCallId, Map<PeerId, GroupCallParticipant>>;
private nextOffsets: Map<GroupCallId, string>;
// private audioAsset: AudioAsset;
private currentGroupCall: GroupCallInstance;
private connectionAudio: HTMLAudioElement;
private doNotDispatchParticipantUpdate: PeerId;
private audioAsset: AudioAssetPlayer<GroupCallAudioAssetName>;
constructor() {
this.log = logger('GROUP-CALLS');
this.groupCalls = new Map();
this.participants = new Map();
this.nextOffsets = new Map();
rootScope.addMultipleEventsListeners({
updateGroupCall: (update) => {
this.saveGroupCall(update.call, update.chat_id);
},
updateGroupCallParticipants: (update) => {
this.saveGroupCall(update.call);
// this.getGroupCallFull(update.call.id, true); // ! WARNING TEMP
const groupCallId = update.call.id;
this.saveApiParticipants(groupCallId, update.participants);
}
});
rootScope.addEventListener('group_call_update', (groupCall) => {
if(groupCall._ === 'groupCallDiscarded') {
const {currentGroupCall} = this;
if(currentGroupCall?.id === groupCall.id) {
currentGroupCall.hangUp(false, false, true);
}
this.participants.delete(groupCall.id);
}
});
this.audioAsset = new AudioAssetPlayer<GroupCallAudioAssetName>([
'group_call_connect.mp3',
'group_call_end.mp3',
'group_call_start.mp3',
'voip_onallowtalk.mp3'
]);
}
get groupCall() {
return this.currentGroupCall;
}
public getCachedParticipants(groupCallId: GroupCallId) {
let participants = this.participants.get(groupCallId);
if(!participants) {
this.participants.set(groupCallId, participants = new Map());
}
return participants;
}
private prepareToSavingNextOffset(groupCallId: GroupCallId) {
const nextOffsetsMap = this.nextOffsets;
const setNextOffset = (newNextOffset: string) => {
if(nextOffsetsMap.get(groupCallId) === nextOffset) {
nextOffsetsMap.set(groupCallId, newNextOffset);
}
};
const nextOffset = nextOffsetsMap.get(groupCallId);
return {
nextOffset,
setNextOffset
};
}
public saveApiParticipant(groupCallId: GroupCallId, participant: GroupCallParticipant, skipCounterUpdating?: boolean) {
const {currentGroupCall} = this;
const participants = this.getCachedParticipants(groupCallId);
const peerId = appPeersManager.getPeerId(participant.peer);
const oldParticipant = participants.get(peerId);
const hasLeft = participant.pFlags.left;
if(!oldParticipant && hasLeft) {
return;
}
// * fix missing flag
if(!participant.pFlags.muted && !participant.pFlags.can_self_unmute) {
participant.pFlags.can_self_unmute = true;
}
const isCurrentGroupCall = currentGroupCall?.id === groupCallId;
if(oldParticipant) {
safeReplaceObject(oldParticipant, participant);
participant = oldParticipant;
} else {
participants.set(peerId, participant);
}
if(isCurrentGroupCall) {
currentGroupCall.onParticipantUpdate(participant, this.doNotDispatchParticipantUpdate);
}
// if(!skipCounterUpdating) {
const groupCall = this.getGroupCall(groupCallId);
if(groupCall?._ === 'groupCall') {
let modified = false;
if(hasLeft) {
--groupCall.participants_count;
modified = true;
} else if(participant.pFlags.just_joined && !oldParticipant && !participant.pFlags.self) {
++groupCall.participants_count;
modified = true;
}
if(modified) {
rootScope.dispatchEvent('group_call_update', groupCall);
}
}
// }
if(hasLeft) {
participants.delete(peerId);
}
if(oldParticipant && this.doNotDispatchParticipantUpdate !== peerId) {
rootScope.dispatchEvent('group_call_participant', {
groupCallId,
participant
});
}
}
public saveApiParticipants(groupCallId: GroupCallId, apiParticipants: GroupCallParticipant[], skipCounterUpdating?: boolean) {
if((apiParticipants as any).saved) return;
(apiParticipants as any).saved = true;
apiParticipants.forEach(p => this.saveApiParticipant(groupCallId, p, skipCounterUpdating));
}
public async editParticipant(groupCallId: GroupCallId, participant: GroupCallParticipant, options: Partial<{
muted: boolean,
volume: number,
raiseHand: boolean,
videoStopped: boolean,
videoPaused: boolean,
presentationPaused: boolean
}>) {
if(!Object.keys(options).length) {
return;
}
let processUpdate = true;
if(participant) {
const {currentGroupCall} = this;
const isCurrentCall = currentGroupCall?.id === groupCallId;
const isUpdatingMeInCurrentCall = isCurrentCall && participant.pFlags.self;
if(isUpdatingMeInCurrentCall) {
if(options.muted !== undefined && !currentGroupCall.isSharingAudio) {
delete options.muted;
if(!Object.keys(options).length) {
return;
}
}
}
// if(isCurrentCall) {
const muted = options.muted;
if(muted !== undefined) {
/* const isAdmin = appChatsManager.hasRights(currentGroupCall.chatId, 'manage_call');
if(isAdmin) {
if(muted) {
participant.pFlags.muted = true;
delete participant.pFlags.can_self_unmute;
} else {
participant.pFlags.can_self_unmute = true;
}
} else */if(participant.pFlags.self) {
if(muted) {
participant.pFlags.muted = true;
} else if(participant.pFlags.can_self_unmute) {
delete participant.pFlags.muted;
}
}/* else {
if(muted) {
participant.pFlags.muted_by_you = true;
} else {
delete participant.pFlags.muted_by_you;
}
} */
}
// }
/* const a: [keyof GroupCallParticipant['pFlags'], keyof typeof options][] = [
['muted', 'muted']
];
a.forEach(([key, optionKey]) => {
const value = options[optionKey];
if(value === undefined) {
return;
}
if(value) {
participant.pFlags[key] = true;
} else {
delete participant.pFlags[key];
}
}); */
if(options.raiseHand !== undefined) {
if(options.raiseHand) participant.raise_hand_rating = '1';
else delete participant.raise_hand_rating;
}
if(isUpdatingMeInCurrentCall) {
if(options.videoStopped !== undefined) {
if(options.videoStopped) delete participant.video;
else participant.video = this.generateSelfVideo(currentGroupCall.connections.main.sources.video);
}
if(!participant.pFlags.muted && participant.pFlags.can_self_unmute) {
currentGroupCall.setMuted(false);
}
currentGroupCall.dispatchEvent('state', currentGroupCall.state);
}
rootScope.dispatchEvent('group_call_participant', {groupCallId, participant});
/* if(participant.pFlags.self) {
processUpdate = false;
} */
}
const peerId = participant.pFlags.self ? NULL_PEER_ID : appPeersManager.getPeerId(participant.peer);
const updates = await apiManager.invokeApiSingle('phone.editGroupCallParticipant', {
call: appGroupCallsManager.getGroupCallInput(groupCallId),
participant: peerId === NULL_PEER_ID ? appPeersManager.getInputPeerSelf() : appPeersManager.getInputPeerById(peerId),
muted: options.muted,
volume: options.volume,
raise_hand: options.raiseHand,
video_paused: options.videoPaused,
video_stopped: options.videoStopped,
presentation_paused: options.presentationPaused
});
// do not replace with peerId because it can be null
if(!processUpdate) this.doNotDispatchParticipantUpdate = appPeersManager.getPeerId(participant.peer);
apiUpdatesManager.processUpdateMessage(updates);
if(!processUpdate) this.doNotDispatchParticipantUpdate = undefined;
}
public getGroupCall(id: GroupCallId) {
return this.groupCalls.get(id);
}
public async getGroupCallFull(id: GroupCallId, override?: boolean): Promise<GroupCall> {
const call = this.getGroupCall(id);
if(call && call._ !== 'inputGroupCall' && !override) {
return call;
}
const limit = this.getCachedParticipants(id).size ? 0 : GET_PARTICIPANTS_LIMIT;
return apiManager.invokeApiSingleProcess({
method: 'phone.getGroupCall',
params: {
call: this.getGroupCallInput(id),
limit
},
processResult: (groupCall) => {
// ? maybe I should save group call after participants so I can avoid passing the 'skipCounterUpdating' flag ?
appUsersManager.saveApiUsers(groupCall.users);
appChatsManager.saveApiChats(groupCall.chats);
this.saveApiParticipants(id, groupCall.participants, true);
const call = this.saveGroupCall(groupCall.call) as GroupCall;
if(limit && this.nextOffsets.get(id) === undefined) {
this.nextOffsets.set(id, groupCall.participants_next_offset);
}
return call;
}
});
}
public saveGroupCall(call: MyGroupCall, chatId?: ChatId) {
const oldCall = this.groupCalls.get(call.id);
const shouldUpdate = call._ !== 'inputGroupCall' && (!oldCall || oldCall._ !== 'groupCallDiscarded');
if(oldCall) {
if(shouldUpdate) {
safeReplaceObject(oldCall, call);
}
call = oldCall;
} else {
this.groupCalls.set(call.id, call);
}
if(shouldUpdate) {
rootScope.dispatchEvent('group_call_update', call as any);
}
return call;
}
public startConnectingSound() {
this.stopConnectingSound();
this.audioAsset.playSoundWithTimeout('group_call_connect.mp3', true, 2500);
}
public stopConnectingSound() {
this.audioAsset.stopSound();
this.audioAsset.cancelDelayedPlay();
}
public setCurrentGroupCall(groupCall: GroupCallInstance) {
this.currentGroupCall = groupCall;
if(groupCall) {
rootScope.dispatchEvent('group_call_instance', groupCall);
}
/* TdLibController.clientUpdate({
'@type': 'clientUpdateGroupCall',
call
}); */
}
public async createGroupCall(chatId: ChatId, scheduleDate?: number, title?: string) {
const updates = await apiManager.invokeApi('phone.createGroupCall', {
peer: appPeersManager.getInputPeerById(chatId.toPeerId(true)),
random_id: nextRandomUint(32),
schedule_date: scheduleDate,
title
});
apiUpdatesManager.processUpdateMessage(updates);
const update = (updates as Updates.updates).updates.find(update => update._ === 'updateGroupCall') as Update.updateGroupCall;
return update.call;
}
public async joinGroupCall(chatId: ChatId, groupCallId: GroupCallId, muted = IS_MUTED, rejoin?: boolean, joinVideo?: boolean) {
this.audioAsset.createAudio();
this.log(`joinGroupCall chatId=${chatId} id=${groupCallId} muted=${muted} rejoin=${rejoin}`);
let streamManager: StreamManager;
if(rejoin) {
streamManager = this.currentGroupCall.connections.main.streamManager;
} else {
streamManager = await createMainStreamManager(muted, joinVideo);
}
return this.joinGroupCallInternal(chatId, groupCallId, streamManager, muted, rejoin, joinVideo);
}
public async joinGroupCallInternal(chatId: ChatId, groupCallId: GroupCallId, streamManager: StreamManager, muted: boolean, rejoin = false, joinVideo?: boolean) {
const log = this.log.bindPrefix('joinGroupCallInternal');
log('start', groupCallId);
const type: GroupCallConnectionType = 'main';
let {currentGroupCall} = this;
if(currentGroupCall && rejoin) {
// currentGroupCall.connections.main.connection = connection;
currentGroupCall.handleUpdateGroupCallParticipants = false;
currentGroupCall.updatingSdp = false;
log('update currentGroupCall', groupCallId, currentGroupCall);
} else {
currentGroupCall = new GroupCallInstance({
chatId,
id: groupCallId
});
currentGroupCall.fixSafariAudio();
currentGroupCall.addEventListener('state', (state) => {
if(this.currentGroupCall === currentGroupCall && state === GROUP_CALL_STATE.CLOSED) {
this.setCurrentGroupCall(null);
this.stopConnectingSound();
this.audioAsset.playSound('group_call_end.mp3');
rootScope.dispatchEvent('chat_update', currentGroupCall.chatId);
}
});
currentGroupCall.groupCall = await this.getGroupCallFull(groupCallId);
const connectionInstance = currentGroupCall.createConnectionInstance({
streamManager,
type,
options: {
type,
isMuted: muted,
joinVideo,
rejoin
}
});
const connection = connectionInstance.createPeerConnection();
connection.addEventListener('negotiationneeded', () => {
connectionInstance.negotiate();
});
connection.addEventListener('track', (event) => {
log('ontrack', event);
currentGroupCall.onTrack(event);
});
connection.addEventListener('iceconnectionstatechange', () => {
currentGroupCall.dispatchEvent('state', currentGroupCall.state);
const {iceConnectionState} = connection;
if(iceConnectionState === 'disconnected' || iceConnectionState === 'checking' || iceConnectionState === 'new') {
this.startConnectingSound();
} else {
this.stopConnectingSound();
}
switch(iceConnectionState) {
case 'checking': {
break;
}
case 'closed': {
currentGroupCall.hangUp();
break;
}
case 'completed': {
break;
}
case 'connected': {
if(!currentGroupCall.joined) {
currentGroupCall.joined = true;
this.audioAsset.playSound('group_call_start.mp3');
this.getGroupCallParticipants(groupCallId).then(({participants}) => {
this.saveApiParticipants(groupCallId, [...participants.values()]);
});
}
break;
}
case 'disconnected': {
break;
}
case 'failed': {
//TODO: replace with ICE restart
currentGroupCall.hangUp();
// connection.restartIce();
break;
}
case 'new': {
break;
}
}
});
connectionInstance.createDescription();
connectionInstance.createDataChannel();
connectionInstance.appendStreamToConference();
this.setCurrentGroupCall(currentGroupCall);
log('set currentGroupCall', groupCallId, currentGroupCall);
this.startConnectingSound();
return connectionInstance.negotiate();
}
}
public getGroupCallInput(id: GroupCallId): InputGroupCall {
const groupCall = this.getGroupCall(id);
return {
_: 'inputGroupCall',
id: groupCall.id,
access_hash: groupCall.access_hash
};
}
public generateSelfVideo(source: Ssrc, audioSource?: number): GroupCallParticipantVideo {
return source && {
_: 'groupCallParticipantVideo',
pFlags: {},
endpoint: '',
source_groups: source.sourceGroups,
audio_source: audioSource
};
}
public generateSelfParticipant(): GroupCallParticipant {
const mainSources = this.currentGroupCall.connections.main.sources;
const presentationSources = this.currentGroupCall.connections.presentation?.sources;
return {
_: 'groupCallParticipant',
pFlags: {
can_self_unmute: true,
self: true
},
source: mainSources.audio.source,
video: this.generateSelfVideo(mainSources.video),
presentation: presentationSources && this.generateSelfVideo(presentationSources.video, presentationSources.audio?.source),
date: tsNow(true),
peer: appPeersManager.getOutputPeer(rootScope.myId)
};
}
public makeSsrcsFromParticipant = (participant: GroupCallParticipant) => {
return [
this.makeSsrcFromParticipant(participant, 'audio', participant.source),
participant.video?.audio_source && this.makeSsrcFromParticipant(participant, 'audio', participant.video.audio_source),
participant.video && this.makeSsrcFromParticipant(participant, 'video', participant.video.source_groups, participant.video.endpoint),
participant.presentation?.audio_source && this.makeSsrcFromParticipant(participant, 'audio', participant.presentation.audio_source),
participant.presentation && this.makeSsrcFromParticipant(participant, 'video', participant.presentation.source_groups, participant.presentation.endpoint)
].filter(Boolean);
};
public makeSsrcFromParticipant(participant: GroupCallParticipant, type: WebRTCLineType, source?: number | GroupCallParticipantVideoSourceGroup[], endpoint?: string): Ssrc {
return generateSsrc(type, source, endpoint);
}
public async getGroupCallParticipants(id: GroupCallId) {
const {nextOffset, setNextOffset} = this.prepareToSavingNextOffset(id);
if(nextOffset !== '') {
await apiManager.invokeApiSingleProcess({
method: 'phone.getGroupParticipants',
params: {
call: this.getGroupCallInput(id),
ids: [],
sources: [],
offset: nextOffset || '',
limit: GET_PARTICIPANTS_LIMIT
},
processResult: (groupCallParticipants) => {
const newNextOffset = groupCallParticipants.count === groupCallParticipants.participants.length ? '' : groupCallParticipants.next_offset;
appChatsManager.saveApiChats(groupCallParticipants.chats);
appUsersManager.saveApiUsers(groupCallParticipants.users);
this.saveApiParticipants(id, groupCallParticipants.participants);
setNextOffset(newNextOffset);
}
});
}
return {
participants: this.getCachedParticipants(id),
isEnd: this.nextOffsets.get(id) === ''
};
}
public async hangUp(groupCallId: GroupCallId, discard = false, rejoin = false) {
this.log(`hangUp start id=${groupCallId} discard=${discard} rejoin=${rejoin}`);
const {currentGroupCall} = this;
if(currentGroupCall?.id !== groupCallId) return;
currentGroupCall.hangUp(discard, rejoin);
}
public toggleMuted(muted?: boolean) {
return this.changeUserMuted(NULL_PEER_ID, muted);
}
public changeUserMuted(peerId: PeerId, muted?: boolean) {
const {currentGroupCall} = this;
if(!currentGroupCall) return;
const participant = currentGroupCall.getParticipantByPeerId(peerId);
if(NULL_PEER_ID === peerId && participant.pFlags.can_self_unmute) {
muted = muted === undefined ? !participant.pFlags.muted : muted;
}
return this.editParticipant(currentGroupCall.id, participant, {muted});
}
}
const appGroupCallsManager = new AppGroupCallsManager();
MOUNT_CLASS_TO && (MOUNT_CLASS_TO.appGroupCallsManager = appGroupCallsManager);
export default appGroupCallsManager;