/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ import type { GroupCallConnectionType, GroupCallId, GroupCallOutputSource } from "../appManagers/appGroupCallsManager"; import { IS_SAFARI } from "../../environment/userAgent"; import indexOfAndSplice from "../../helpers/array/indexOfAndSplice"; import safeAssign from "../../helpers/object/safeAssign"; import throttle from "../../helpers/schedulers/throttle"; import { GroupCall, GroupCallParticipant } from "../../layer"; import { logger } from "../logger"; import { NULL_PEER_ID } from "../mtproto/mtproto_config"; import rootScope from "../rootScope"; import CallInstanceBase, { TryAddTrackOptions } from "./callInstanceBase"; import GroupCallConnectionInstance from "./groupCallConnectionInstance"; import GROUP_CALL_STATE from "./groupCallState"; import getScreenConstraints from "./helpers/getScreenConstraints"; import getScreenStream from "./helpers/getScreenStream"; import getStream from "./helpers/getStream"; import getVideoConstraints from "./helpers/getVideoConstraints"; import stopTrack from "./helpers/stopTrack"; import localConferenceDescription from "./localConferenceDescription"; import { WebRTCLineType } from "./sdpBuilder"; import StreamManager from "./streamManager"; import { Ssrc } from "./types"; import getPeerId from "../appManagers/utils/peers/getPeerId"; import { AppManagers } from "../appManagers/managers"; import { generateSelfVideo, makeSsrcFromParticipant, makeSsrcsFromParticipant } from "./groupCallsController"; export default class GroupCallInstance extends CallInstanceBase<{ state: (state: GROUP_CALL_STATE) => void, pinned: (source?: GroupCallOutputSource) => void, }> { public id: GroupCallId; public chatId: ChatId; public handleUpdateGroupCallParticipants: boolean; public updatingSdp: boolean; public isSpeakingMap: Map; public connections: {[k in GroupCallConnectionType]?: GroupCallConnectionInstance}; public groupCall: GroupCall; public participant: GroupCallParticipant; // will be set with negotiation public joined: boolean; private pinnedSources: Array; private participantsSsrcs: Map; private hadAutoPinnedSources: Set; private dispatchPinnedThrottled: () => void; private startVideoSharingPromise: Promise; private startScreenSharingPromise: Promise; private managers: AppManagers; constructor(options: { id: GroupCallInstance['id'], chatId: GroupCallInstance['chatId'], isSpeakingMap?: GroupCallInstance['isSpeakingMap'], connections?: GroupCallInstance['connections'], managers: AppManagers }) { super(); safeAssign(this, options); if(!this.log) { this.log = logger('GROUP-CALL'); } if(!this.connections) { this.connections = {}; } if(!this.isSpeakingMap) { this.isSpeakingMap = new Map(); } this.pinnedSources = []; this.participantsSsrcs = new Map(); this.hadAutoPinnedSources = new Set(); this.dispatchPinnedThrottled = throttle(() => { this.dispatchEvent('pinned', this.pinnedSource); }, 0, false); this.addEventListener('state', (state) => { if(state === GROUP_CALL_STATE.CLOSED) { this.cleanup(); } }); } get connectionState() { return this.connections.main.connection.iceConnectionState; } get state() { const {connectionState} = this; if(connectionState === 'closed') { return GROUP_CALL_STATE.CLOSED; } else if(connectionState !== 'connected' && (!IS_SAFARI || connectionState !== 'completed')) { return GROUP_CALL_STATE.CONNECTING; } else { const {participant} = this; if(!participant.pFlags.can_self_unmute) { return GROUP_CALL_STATE.MUTED_BY_ADMIN; } else if(participant.pFlags.muted) { return GROUP_CALL_STATE.MUTED; } else { return GROUP_CALL_STATE.UNMUTED; } } } get participants() { return this.managers.appGroupCallsManager.getCachedParticipants(this.id); } get isSharingScreen() { return !!this.connections.presentation; } get pinnedSource() { return this.pinnedSources[this.pinnedSources.length - 1]; } public get isMuted() { return this.state !== GROUP_CALL_STATE.UNMUTED; } public get isClosing() { const {state} = this; return state === GROUP_CALL_STATE.CLOSED; } public get streamManager(): StreamManager { return this.connections.main.streamManager; } public get description(): localConferenceDescription { return this.connections.main.description; } public pinSource(source: GroupCallOutputSource) { indexOfAndSplice(this.pinnedSources, source); this.pinnedSources.push(source); this.dispatchPinnedThrottled(); } public unpinSource(source: GroupCallOutputSource) { this.hadAutoPinnedSources.delete(source); indexOfAndSplice(this.pinnedSources, source); this.dispatchPinnedThrottled(); } public unpinAll() { this.pinnedSources.length = 0; this.dispatchPinnedThrottled(); } public async getParticipantByPeerId(peerId: PeerId) { return NULL_PEER_ID === peerId ? this.participant : (await this.participants).get(peerId); } public toggleMuted() { return this.requestAudioSource(true).then(() => this.changeUserMuted(NULL_PEER_ID)); } public async changeUserMuted(peerId: PeerId, muted?: boolean) { const participant = await this.getParticipantByPeerId(peerId); if(NULL_PEER_ID === peerId && participant.pFlags.can_self_unmute) { muted = muted === undefined ? !participant.pFlags.muted : muted; } return this.editParticipant(participant, {muted}); } public getElement(endpoint: GroupCallOutputSource) { return super.getElement(endpoint); } public getVideoElementFromParticipantByType(participant: GroupCallParticipant, type: 'video' | 'presentation') { let source: GroupCallOutputSource; if(participant.pFlags.self) { const connectionType: GroupCallConnectionType = type === 'video' ? 'main' : 'presentation'; source = connectionType; } else { const codec = participant[type]; source = codec.source_groups[0].sources[0]; } const element = this.getElement(source) as HTMLVideoElement; if(!element) return; const clone = element.cloneNode() as typeof element; clone.srcObject = element.srcObject; return {video: clone, source}; } public createConnectionInstance(options: { streamManager: StreamManager, type: GroupCallConnectionType, options: GroupCallConnectionInstance['options'], }) { return this.connections[options.type] = new GroupCallConnectionInstance({ groupCall: this, log: this.log.bindPrefix(options.type), managers: this.managers, ...options }); } public changeRaiseHand(raise: boolean) { return this.editParticipant(this.participant, {raiseHand: raise}); } public async startScreenSharingInternal() { try { const type: GroupCallConnectionType = 'presentation'; const stream = await getScreenStream(getScreenConstraints()); const streamManager = new StreamManager(); const connectionInstance = this.createConnectionInstance({ streamManager, type, options: { type } }); const connection = connectionInstance.createPeerConnection(); connection.addEventListener('negotiationneeded', () => { connectionInstance.negotiate(); }); stream.getVideoTracks()[0].addEventListener('ended', () => { if(this.connections.presentation) { // maybe user has stopped screensharing through browser's ui this.stopScreenSharing(); } }, {once: true}); connectionInstance.createDescription(); connectionInstance.addInputVideoStream(stream); } catch(err) { this.log.error('start screen sharing error', err); } } public startScreenSharing() { return this.startScreenSharingPromise ??= this.startScreenSharingInternal().finally(() => { this.startScreenSharingPromise = undefined; }); } public stopScreenSharing() { const connectionInstance = this.connections.presentation; if(!connectionInstance) { return Promise.resolve(); } delete this.connections.presentation; this.unpinSource('presentation'); connectionInstance.closeConnectionAndStream(true); delete this.participant.presentation; this.managers.appGroupCallsManager.saveApiParticipant(this.id, this.participant); return this.managers.appGroupCallsManager.leaveGroupCallPresentation(this.id); } public toggleScreenSharing() { if(this.isSharingScreen) { return this.stopScreenSharing(); } else { return this.startScreenSharing(); } } public async startVideoSharingInternal() { const constraints: MediaStreamConstraints = { video: getVideoConstraints() }; try { const stream = await getStream(constraints, false); const connectionInstance = this.connections.main; connectionInstance.addInputVideoStream(stream); await this.editParticipant(this.participant, { videoPaused: false, videoStopped: false }); } catch(err) { this.log.error('startVideoSharing error', err, constraints); } } public startVideoSharing() { return this.startVideoSharingPromise ??= this.startVideoSharingInternal().finally(() => { this.startVideoSharingPromise = undefined; }); } public async stopVideoSharing() { const connectionInstance = this.connections.main; const track = connectionInstance.streamManager.inputStream.getVideoTracks()[0]; if(!track) { return; } stopTrack(track); connectionInstance.streamManager.appendToConference(connectionInstance.description); // clear sender track await this.editParticipant(this.participant, { videoStopped: true }); } public toggleVideoSharing() { if(this.isSharingVideo) { return this.stopVideoSharing(); } else { return this.startVideoSharing(); } } public async hangUp(discard = false, rejoin = false, isDiscarded = false) { for(const type in this.connections) { const connection = this.connections[type as GroupCallConnectionType]; connection.closeConnectionAndStream(!rejoin); } this.dispatchEvent('state', this.state); if(isDiscarded) { return; } if(!rejoin) { let d = discard || (this.joined ? this.connections.main.sources.audio.source : undefined); this.managers.appGroupCallsManager.hangUp(this.id, d); } } public tryAddTrack(options: Omit) { const {description} = this; const source = super.tryAddTrack(options); if(options.type === 'output') { const entry = description.getEntryBySource(+source); this.getParticipantByPeerId(entry.peerId).then((participant) => { if(participant) { rootScope.dispatchEvent('group_call_participant', {groupCallId: this.id, participant}); } }); } return source; } public async editParticipant(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 isCurrentCall = true; const isUpdatingMeInCurrentCall = isCurrentCall && participant.pFlags.self; if(isUpdatingMeInCurrentCall) { if(options.muted !== undefined && !this.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 = generateSelfVideo(this.connections.main.sources.video); } if(!participant.pFlags.muted && participant.pFlags.can_self_unmute) { this.setMuted(false); } this.dispatchEvent('state', this.state); } // rootScope.dispatchEvent('group_call_participant', {groupCallId, participant}); /* if(participant.pFlags.self) { processUpdate = false; } */ } return this.managers.appGroupCallsManager.editParticipant(this.id, participant, options); } public onParticipantUpdate(participant: GroupCallParticipant, doNotDispatchParticipantUpdate?: PeerId) { const connectionInstance = this.connections.main; const {connection, description} = connectionInstance; const peerId = getPeerId(participant.peer); const hasLeft = !!participant.pFlags.left; const oldSsrcs = this.participantsSsrcs.get(peerId) || []; if(participant.presentation && !hasLeft) { const {source} = makeSsrcFromParticipant(participant, 'video', participant.presentation.source_groups, participant.presentation.endpoint); if(!this.hadAutoPinnedSources.has(source)) { this.hadAutoPinnedSources.add(source); this.pinSource(participant.pFlags.self ? 'presentation' : source); } } if(participant.pFlags.self) { this.participant = participant; if(connectionInstance.sources.audio.source !== participant.source) { this.hangUp(); } let mute = false; if(!participant.pFlags.can_self_unmute) { this.stopScreenSharing(); this.stopVideoSharing(); mute = true; } else if(participant.pFlags.muted) { mute = true; } if(mute) { this.setMuted(true); } if(doNotDispatchParticipantUpdate !== peerId) { this.dispatchEvent('state', this.state); } return; } const ssrcs = hasLeft ? [] : makeSsrcsFromParticipant(participant); if(!hasLeft) { this.participantsSsrcs.set(peerId, ssrcs); } else { this.participantsSsrcs.delete(peerId); } // const TEST_OLD = false; const modifiedTypes: Set = new Set(); oldSsrcs.forEach((oldSsrc) => { const oldSource = oldSsrc.source; const newSsrc = ssrcs.find((ssrc) => ssrc.source === oldSource); if(!newSsrc) { this.unpinSource(oldSource); const oldEntry = description.getEntryBySource(oldSource); if(oldEntry && oldEntry.direction !== 'inactive') { oldEntry.setDirection('inactive'); modifiedTypes.add(oldEntry.type); } } }); ssrcs.forEach((ssrc) => { let entry = description.getEntryBySource(ssrc.source); if(entry) { if(entry.direction === 'inactive') { entry.setDirection(entry.originalDirection); modifiedTypes.add(entry.type); } return; } entry = description.createEntry(ssrc.type); description.setEntrySource(entry, ssrc.sourceGroups || ssrc.source); description.setEntryPeerId(entry, peerId); // if(TEST_OLD) { // description.bundleMids.push(entry.mid); // entry.setDirection('recvonly'); // } else { ssrc.type === 'video' && entry.setEndpoint(ssrc.endpoint); entry.createTransceiver(connection, {direction: 'recvonly'}); // } modifiedTypes.add(entry.type); }); /* if(TEST_OLD) { this.setRemoteOffer({ connection, description, ssrcs }); } else */if(modifiedTypes.size) { if(modifiedTypes.has('video')) { connectionInstance.updateConstraints = true; } connectionInstance.negotiateThrottled(); } } }