Telegram Web K with changes to work inside I2P
https://web.telegram.i2p/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
490 lines
15 KiB
490 lines
15 KiB
/* |
|
* https://github.com/morethanwords/tweb |
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE |
|
*/ |
|
|
|
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, Updates } from "../../layer"; |
|
import apiUpdatesManager from "../appManagers/apiUpdatesManager"; |
|
import appGroupCallsManager, { GroupCallConnectionType, GroupCallId, GroupCallOutputSource } from "../appManagers/appGroupCallsManager"; |
|
import appPeersManager from "../appManagers/appPeersManager"; |
|
import { logger } from "../logger"; |
|
import apiManager from "../mtproto/mtprotoworker"; |
|
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"; |
|
|
|
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<any, any>; |
|
public connections: {[k in GroupCallConnectionType]?: GroupCallConnectionInstance}; |
|
public groupCall: GroupCall; |
|
public participant: GroupCallParticipant; |
|
|
|
// will be set with negotiation |
|
public joined: boolean; |
|
|
|
private pinnedSources: Array<GroupCallOutputSource>; |
|
private participantsSsrcs: Map<PeerId, Ssrc[]>; |
|
private hadAutoPinnedSources: Set<GroupCallOutputSource>; |
|
private dispatchPinnedThrottled: () => void; |
|
private startVideoSharingPromise: Promise<void>; |
|
private startScreenSharingPromise: Promise<void>; |
|
|
|
constructor(options: { |
|
id: GroupCallInstance['id'], |
|
chatId: GroupCallInstance['chatId'], |
|
isSpeakingMap?: GroupCallInstance['isSpeakingMap'], |
|
connections?: GroupCallInstance['connections'] |
|
}) { |
|
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 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 getParticipantByPeerId(peerId: PeerId) { |
|
return NULL_PEER_ID === peerId ? this.participant : this.participants.get(peerId); |
|
} |
|
|
|
public toggleMuted() { |
|
return this.requestAudioSource(true).then(() => appGroupCallsManager.toggleMuted()); |
|
} |
|
|
|
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), |
|
...options |
|
}); |
|
} |
|
|
|
public changeRaiseHand(raise: boolean) { |
|
return appGroupCallsManager.editParticipant(this.id, 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.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; |
|
appGroupCallsManager.saveApiParticipant(this.id, this.participant); |
|
|
|
return apiManager.invokeApi('phone.leaveGroupCallPresentation', { |
|
call: appGroupCallsManager.getGroupCallInput(this.id) |
|
}).then(updates => { |
|
apiUpdatesManager.processUpdateMessage(updates); |
|
}); |
|
} |
|
|
|
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 appGroupCallsManager.editParticipant(this.id, this.participant, { |
|
videoPaused: false, |
|
videoStopped: false |
|
}); |
|
} catch(err) { |
|
this.log.error('startVideoSharing error', err, constraints); |
|
} |
|
} |
|
|
|
public startVideoSharing() { |
|
return this.startVideoSharingPromise || (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 appGroupCallsManager.editParticipant(this.id, 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 promise: Promise<Updates>; |
|
const groupCallInput = appGroupCallsManager.getGroupCallInput(this.id); |
|
|
|
if(discard) { |
|
this.log(`[api] discardGroupCall id=${this.id}`); |
|
promise = apiManager.invokeApi('phone.discardGroupCall', { |
|
call: groupCallInput |
|
}); |
|
} else if(this.joined) { |
|
this.log(`[api] leaveGroupCall id=${this.id}`); |
|
const connectionInstance = this.connections.main; |
|
promise = apiManager.invokeApi('phone.leaveGroupCall', { |
|
call: groupCallInput, |
|
source: connectionInstance.sources.audio.source |
|
}); |
|
} else { |
|
this.log(`[api] id=${this.id} payload=null`); |
|
promise = apiManager.invokeApi('phone.joinGroupCall', { |
|
call: groupCallInput, |
|
join_as: {_: 'inputPeerSelf'}, |
|
muted: true, |
|
video_stopped: true, |
|
params: { |
|
_: 'dataJSON', |
|
data: '' |
|
} |
|
}); |
|
} |
|
|
|
const updates = await promise; |
|
apiUpdatesManager.processUpdateMessage(updates); |
|
} |
|
} |
|
|
|
public tryAddTrack(options: Omit<TryAddTrackOptions, 'streamManager'>) { |
|
const {description} = this; |
|
const source = super.tryAddTrack(options); |
|
|
|
if(options.type === 'output') { |
|
const entry = description.getEntryBySource(+source); |
|
const participant = this.participants.get(entry.peerId); |
|
if(participant) { |
|
rootScope.dispatchEvent('group_call_participant', {groupCallId: this.id, participant}); |
|
} |
|
} |
|
|
|
return source; |
|
} |
|
|
|
public onParticipantUpdate(participant: GroupCallParticipant, doNotDispatchParticipantUpdate?: PeerId) { |
|
const connectionInstance = this.connections.main; |
|
const {connection, description} = connectionInstance; |
|
|
|
const peerId = appPeersManager.getPeerId(participant.peer); |
|
const hasLeft = !!participant.pFlags.left; |
|
const oldSsrcs = this.participantsSsrcs.get(peerId) || []; |
|
|
|
if(participant.presentation && !hasLeft) { |
|
const {source} = appGroupCallsManager.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 ? [] : appGroupCallsManager.makeSsrcsFromParticipant(participant); |
|
|
|
if(!hasLeft) { |
|
this.participantsSsrcs.set(peerId, ssrcs); |
|
} else { |
|
this.participantsSsrcs.delete(peerId); |
|
} |
|
|
|
// const TEST_OLD = false; |
|
|
|
const modifiedTypes: Set<WebRTCLineType> = 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(); |
|
} |
|
} |
|
}
|
|
|