372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
/*
|
|
* https://github.com/morethanwords/tweb
|
|
* Copyright (C) 2019-2021 Eduard Kuzmenko
|
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
|
*/
|
|
|
|
import { forEachReverse } from "../../helpers/array";
|
|
import throttle from "../../helpers/schedulers/throttle";
|
|
import { Updates, PhoneJoinGroupCall, PhoneJoinGroupCallPresentation, Update } from "../../layer";
|
|
import apiUpdatesManager from "../appManagers/apiUpdatesManager";
|
|
import appGroupCallsManager, { GroupCallConnectionType, JoinGroupCallJsonPayload } from "../appManagers/appGroupCallsManager";
|
|
import apiManager from "../mtproto/mtprotoworker";
|
|
import rootScope from "../rootScope";
|
|
import CallConnectionInstanceBase, { CallConnectionInstanceOptions } from "./callConnectionInstanceBase";
|
|
import GroupCallInstance from "./groupCallInstance";
|
|
import filterServerCodecs from "./helpers/filterServerCodecs";
|
|
import fixLocalOffer from "./helpers/fixLocalOffer";
|
|
import processMediaSection from "./helpers/processMediaSection";
|
|
import { ConferenceEntry } from "./localConferenceDescription";
|
|
import SDP from "./sdp";
|
|
import SDPMediaSection from "./sdp/mediaSection";
|
|
import { WebRTCLineType } from "./sdpBuilder";
|
|
import { UpdateGroupCallConnectionData } from "./types";
|
|
|
|
export default class GroupCallConnectionInstance extends CallConnectionInstanceBase {
|
|
private groupCall: GroupCallInstance;
|
|
public updateConstraints?: boolean;
|
|
private type: GroupCallConnectionType;
|
|
private options: {
|
|
type: Extract<GroupCallConnectionType, 'main'>,
|
|
isMuted?: boolean,
|
|
joinVideo?: boolean,
|
|
rejoin?: boolean
|
|
} | {
|
|
type: Extract<GroupCallConnectionType, 'presentation'>,
|
|
};
|
|
|
|
private updateConstraintsInterval: number;
|
|
public negotiateThrottled: () => void;
|
|
|
|
constructor(options: CallConnectionInstanceOptions & {
|
|
groupCall: GroupCallConnectionInstance['groupCall'],
|
|
type: GroupCallConnectionInstance['type'],
|
|
options: GroupCallConnectionInstance['options'],
|
|
}) {
|
|
super(options);
|
|
|
|
this.negotiateThrottled = throttle(this.negotiate.bind(this), 0, false);
|
|
}
|
|
|
|
public createPeerConnection() {
|
|
return this.connection || super.createPeerConnection({
|
|
iceServers: [],
|
|
iceTransportPolicy: 'all',
|
|
bundlePolicy: 'max-bundle',
|
|
rtcpMuxPolicy: 'require',
|
|
iceCandidatePoolSize: 0,
|
|
// sdpSemantics: "unified-plan",
|
|
// extmapAllowMixed: true,
|
|
});
|
|
}
|
|
|
|
public createDataChannel() {
|
|
if(this.dataChannel) {
|
|
return this.dataChannel;
|
|
}
|
|
|
|
const dataChannel = super.createDataChannel();
|
|
|
|
dataChannel.addEventListener('open', () => {
|
|
this.maybeUpdateRemoteVideoConstraints();
|
|
});
|
|
|
|
dataChannel.addEventListener('close', () => {
|
|
if(this.updateConstraintsInterval) {
|
|
clearInterval(this.updateConstraintsInterval);
|
|
this.updateConstraintsInterval = undefined;
|
|
}
|
|
});
|
|
|
|
return dataChannel;
|
|
}
|
|
|
|
public createDescription() {
|
|
if(this.description) {
|
|
return this.description;
|
|
}
|
|
|
|
const description = super.createDescription();
|
|
|
|
/* const perType = 0;
|
|
const types = ['audio' as const, 'video' as const];
|
|
const count = types.length * perType;
|
|
const init: RTCRtpTransceiverInit = {direction: 'recvonly'};
|
|
types.forEach(type => {
|
|
for(let i = 0; i < perType; ++i) {
|
|
description.createEntry(type).createTransceiver(connection, init);
|
|
}
|
|
}); */
|
|
|
|
return description;
|
|
}
|
|
|
|
public appendStreamToConference() {
|
|
super.appendStreamToConference();/* .then(() => {
|
|
currentGroupCall.connections.main.negotiating = false;
|
|
this.startNegotiation({
|
|
type: type,
|
|
isMuted: muted,
|
|
rejoin
|
|
});
|
|
}); */
|
|
}
|
|
|
|
private async invokeJoinGroupCall(localSdp: SDP, mainChannels: SDPMediaSection[], options: GroupCallConnectionInstance['options']) {
|
|
const {groupCall, description} = this;
|
|
const groupCallId = groupCall.id;
|
|
|
|
const processedChannels = mainChannels.map(section => {
|
|
const processed = processMediaSection(localSdp, section);
|
|
|
|
this.sources[processed.entry.type as 'video' | 'audio'] = processed.entry;
|
|
|
|
return processed;
|
|
});
|
|
|
|
let promise: Promise<Updates>;
|
|
const audioChannel = processedChannels.find(channel => channel.media.mediaType === 'audio');
|
|
const videoChannel = processedChannels.find(channel => channel.media.mediaType === 'video');
|
|
let {source, params} = audioChannel || {};
|
|
const useChannel = videoChannel || audioChannel;
|
|
|
|
const channels: {[type in WebRTCLineType]?: typeof audioChannel} = {
|
|
audio: audioChannel,
|
|
video: videoChannel
|
|
};
|
|
|
|
description.entries.forEach(entry => {
|
|
if(entry.direction === 'sendonly') {
|
|
const channel = channels[entry.type];
|
|
if(!channel) return;
|
|
|
|
description.setEntrySource(entry, channel.sourceGroups || channel.source);
|
|
description.setEntryPeerId(entry, rootScope.myId);
|
|
}
|
|
});
|
|
|
|
// overwrite ssrc with audio in video params
|
|
if(params !== useChannel.params) {
|
|
const data: JoinGroupCallJsonPayload = JSON.parse(useChannel.params.data);
|
|
// data.ssrc = source || data.ssrc - 1; // audio channel can be missed in screensharing
|
|
if(source) data.ssrc = source;
|
|
else delete data.ssrc;
|
|
params = {
|
|
_: 'dataJSON',
|
|
data: JSON.stringify(data)
|
|
};
|
|
}
|
|
|
|
const groupCallInput = appGroupCallsManager.getGroupCallInput(groupCallId);
|
|
if(options.type === 'main') {
|
|
const request: PhoneJoinGroupCall = {
|
|
call: groupCallInput,
|
|
join_as: {_: 'inputPeerSelf'},
|
|
params,
|
|
muted: options.isMuted,
|
|
video_stopped: !options.joinVideo
|
|
};
|
|
|
|
promise = apiManager.invokeApi('phone.joinGroupCall', request);
|
|
this.log(`[api] joinGroupCall id=${groupCallId}`, request);
|
|
} else {
|
|
const request: PhoneJoinGroupCallPresentation = {
|
|
call: groupCallInput,
|
|
params,
|
|
};
|
|
|
|
promise = apiManager.invokeApi('phone.joinGroupCallPresentation', request);
|
|
this.log(`[api] joinGroupCallPresentation id=${groupCallId}`, request);
|
|
}
|
|
|
|
const updates = await promise;
|
|
apiUpdatesManager.processUpdateMessage(updates);
|
|
const update = (updates as Updates.updates).updates.find(update => update._ === 'updateGroupCallConnection') as Update.updateGroupCallConnection;
|
|
|
|
const data: UpdateGroupCallConnectionData = JSON.parse(update.params.data);
|
|
|
|
data.audio = data.audio || groupCall.connections.main.description.audio;
|
|
description.setData(data);
|
|
filterServerCodecs(mainChannels, data);
|
|
|
|
return data;
|
|
}
|
|
|
|
protected async negotiateInternal() {
|
|
const {connection, description} = this;
|
|
const isNewConnection = connection.iceConnectionState === 'new' && !description.getEntryByMid('0').source;
|
|
const log = this.log.bindPrefix('startNegotiation');
|
|
log('start');
|
|
|
|
const originalOffer = await connection.createOffer({iceRestart: false});
|
|
|
|
if(isNewConnection && this.dataChannel) {
|
|
const dataChannelEntry = description.createEntry('application');
|
|
dataChannelEntry.setDirection('sendrecv');
|
|
}
|
|
|
|
const {sdp: localSdp, offer} = fixLocalOffer({
|
|
offer: originalOffer,
|
|
data: description
|
|
});
|
|
|
|
log('[sdp] setLocalDescription', offer.sdp);
|
|
await connection.setLocalDescription(offer);
|
|
|
|
const mainChannels = localSdp.media.filter(media => {
|
|
return media.mediaType !== 'application' && media.isSending;
|
|
});
|
|
|
|
if(isNewConnection) {
|
|
try {
|
|
await this.invokeJoinGroupCall(localSdp, mainChannels, this.options);
|
|
} catch(e) {
|
|
this.log.error('[tdweb] joinGroupCall error', e);
|
|
}
|
|
}
|
|
|
|
/* if(!data) {
|
|
log('abort 0');
|
|
this.closeConnectionAndStream(connection, streamManager);
|
|
return;
|
|
} */
|
|
|
|
/* if(connection.iceConnectionState !== 'new') {
|
|
log(`abort 1 connectionState=${connection.iceConnectionState}`);
|
|
this.closeConnectionAndStream(connection, streamManager);
|
|
return;
|
|
} */
|
|
/* if(this.currentGroupCall !== currentGroupCall || connectionHandler.connection !== connection) {
|
|
log('abort', this.currentGroupCall, currentGroupCall);
|
|
this.closeConnectionAndStream(connection, streamManager);
|
|
return;
|
|
} */
|
|
|
|
const isAnswer = true;
|
|
// const _bundleMids = bundleMids.slice();
|
|
const entriesToDelete: ConferenceEntry[] = [];
|
|
const bundle = localSdp.bundle;
|
|
forEachReverse(bundle, (mid, idx, arr) => {
|
|
const entry = description.getEntryByMid(mid);
|
|
if(entry.shouldBeSkipped(isAnswer)) {
|
|
arr.splice(idx, 1);
|
|
entriesToDelete.push(entry);
|
|
}
|
|
});
|
|
|
|
/* forEachReverse(description.entries, (entry, idx, arr) => {
|
|
const mediaSection = _parsedSdp.media.find(section => section.oa.get('mid').oa === entry.mid);
|
|
const deleted = !mediaSection;
|
|
// const deleted = !_bundleMids.includes(entry.mid); // ! can't use it because certain mid can be missed in bundle
|
|
if(deleted) {
|
|
arr.splice(idx, 1);
|
|
}
|
|
}); */
|
|
|
|
const entries = localSdp.media.map((section) => {
|
|
const mid = section.mid;
|
|
let entry = description.getEntryByMid(mid);
|
|
if(!entry) {
|
|
entry = new ConferenceEntry(mid, section.mediaType);
|
|
entry.setDirection('inactive');
|
|
}
|
|
|
|
return entry;
|
|
});
|
|
|
|
const answerDescription: RTCSessionDescriptionInit = {
|
|
type: 'answer',
|
|
sdp: description.generateSdp({
|
|
bundle,
|
|
entries,
|
|
isAnswer
|
|
})
|
|
};
|
|
|
|
entriesToDelete.forEach(entry => {
|
|
description.deleteEntry(entry);
|
|
});
|
|
|
|
log(`[sdp] setRemoteDescription signaling=${connection.signalingState} ice=${connection.iceConnectionState} gathering=${connection.iceGatheringState} connection=${connection.connectionState}`, answerDescription.sdp);
|
|
await connection.setRemoteDescription(answerDescription);
|
|
|
|
log('end');
|
|
}
|
|
|
|
public negotiate() {
|
|
let promise = this.negotiating;
|
|
if(promise) {
|
|
return promise;
|
|
}
|
|
|
|
promise = super.negotiate();
|
|
|
|
if(this.updateConstraints) {
|
|
promise.then(() => {
|
|
this.maybeUpdateRemoteVideoConstraints();
|
|
this.updateConstraints = false;
|
|
});
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
public maybeUpdateRemoteVideoConstraints() {
|
|
if(this.dataChannel.readyState !== 'open') {
|
|
return;
|
|
}
|
|
|
|
this.log('maybeUpdateRemoteVideoConstraints');
|
|
|
|
// * https://github.com/TelegramMessenger/tgcalls/blob/6f2746e04c9b040f8c8dfc64d916a1853d09c4ce/tgcalls/group/GroupInstanceCustomImpl.cpp#L2549
|
|
type VideoConstraints = {minHeight?: number, maxHeight: number};
|
|
const obj: {
|
|
colibriClass: 'ReceiverVideoConstraints',
|
|
constraints: {[endpoint: string]: VideoConstraints},
|
|
defaultConstraints: VideoConstraints,
|
|
onStageEndpoints: string[]
|
|
} = {
|
|
colibriClass: 'ReceiverVideoConstraints',
|
|
constraints: {},
|
|
defaultConstraints: {maxHeight: 0},
|
|
onStageEndpoints: []
|
|
};
|
|
|
|
for(const entry of this.description.entries) {
|
|
if(entry.direction !== 'recvonly' || entry.type !== 'video') {
|
|
continue;
|
|
}
|
|
|
|
const {endpoint} = entry;
|
|
obj.onStageEndpoints.push(endpoint);
|
|
obj.constraints[endpoint] = {
|
|
minHeight: 180,
|
|
maxHeight: 720
|
|
};
|
|
}
|
|
|
|
this.sendDataChannelData(obj);
|
|
|
|
if(!obj.onStageEndpoints.length) {
|
|
if(this.updateConstraintsInterval) {
|
|
clearInterval(this.updateConstraintsInterval);
|
|
this.updateConstraintsInterval = undefined;
|
|
}
|
|
} else if(!this.updateConstraintsInterval) {
|
|
this.updateConstraintsInterval = window.setInterval(this.maybeUpdateRemoteVideoConstraints.bind(this), 5000);
|
|
}
|
|
}
|
|
|
|
public addInputVideoStream(stream: MediaStream) {
|
|
// const {sources} = this;
|
|
// if(sources?.video) {
|
|
// const source = this.sources.video.source;
|
|
// stream.source = '' + source;
|
|
this.groupCall.saveInputVideoStream(stream, this.type);
|
|
// }
|
|
|
|
this.streamManager.addStream(stream, 'input');
|
|
this.appendStreamToConference(); // replace sender track
|
|
}
|
|
}
|