Browse Source
Fix chat date blinking Fix displaying sent messages to new dialog Scroll to date bubble if message is bigger than viewport Fix releasing keyboard by inline helper Fix clearing self user Fix displaying sent public poll Update contacts counter in dialogs placeholder Improve multiselect animation Disable lottie icon animations if they're disabled Fix changing mtproto transport during authorizationmaster
morethanwords
2 years ago
151 changed files with 5320 additions and 2611 deletions
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { attachClickEvent } from "../../helpers/dom/clickEvent"; |
||||
import ListenerSetter from "../../helpers/listenerSetter"; |
||||
import { i18n, LangPackKey } from "../../lib/langPack"; |
||||
import { ripple } from "../ripple"; |
||||
|
||||
export default function makeButton(className: string, listenerSetter: ListenerSetter, options: { |
||||
text?: LangPackKey | HTMLElement, |
||||
isDanger?: boolean, |
||||
noRipple?: boolean, |
||||
callback?: () => void, |
||||
icon?: string, |
||||
isConfirm?: boolean, |
||||
}) { |
||||
const _className = className + '-button'; |
||||
const buttonDiv = document.createElement('div'); |
||||
buttonDiv.classList.add(_className, 'call-button', 'rp-overflow'); |
||||
|
||||
if(options.icon) { |
||||
buttonDiv.classList.add('tgico-' + options.icon); |
||||
} |
||||
|
||||
if(!options.noRipple) { |
||||
ripple(buttonDiv); |
||||
} |
||||
|
||||
if(options.isDanger) { |
||||
buttonDiv.classList.add(_className + '-red'); |
||||
} |
||||
|
||||
if(options.isConfirm) { |
||||
buttonDiv.classList.add(_className + '-green'); |
||||
} |
||||
|
||||
if(options.callback) { |
||||
attachClickEvent(buttonDiv, options.callback, {listenerSetter}); |
||||
} |
||||
|
||||
let ret = buttonDiv; |
||||
if(options.text) { |
||||
const div = document.createElement('div'); |
||||
div.classList.add(_className + '-container', 'call-button-container'); |
||||
|
||||
const textEl = typeof(options.text) === 'string' ? i18n(options.text) : options.text; |
||||
textEl.classList.add(_className + '-text', 'call-button-text'); |
||||
|
||||
div.append(buttonDiv, textEl); |
||||
|
||||
ret = div; |
||||
} |
||||
|
||||
return ret; |
||||
} |
@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import replaceContent from "../../helpers/dom/replaceContent"; |
||||
import type CallInstance from "../../lib/calls/callInstance"; |
||||
import CALL_STATE from "../../lib/calls/callState"; |
||||
import { i18n, LangPackKey } from "../../lib/langPack"; |
||||
|
||||
export default class CallDescriptionElement { |
||||
private container: HTMLElement; |
||||
private state: CALL_STATE; |
||||
private interval: number; |
||||
|
||||
constructor(private appendTo: HTMLElement) { |
||||
this.container = document.createElement('div'); |
||||
this.container.classList.add('call-description'); |
||||
} |
||||
|
||||
public detach() { |
||||
if(this.interval !== undefined) { |
||||
clearInterval(this.interval); |
||||
this.interval = undefined; |
||||
} |
||||
|
||||
this.container.remove(); |
||||
this.state = undefined; |
||||
} |
||||
|
||||
public update(instance: CallInstance) { |
||||
const {connectionState} = instance; |
||||
|
||||
if(this.state === connectionState) { |
||||
return; |
||||
} |
||||
|
||||
this.state = connectionState; |
||||
|
||||
let element: HTMLElement; |
||||
if(connectionState === CALL_STATE.CONNECTED) { |
||||
element = document.createElement('span'); |
||||
element.classList.add('call-description-duration'); |
||||
|
||||
const setTime = () => { |
||||
element.innerText = ('' + instance.duration).toHHMMSS(true); |
||||
}; |
||||
|
||||
this.interval = window.setInterval(setTime, 1000); |
||||
setTime(); |
||||
} else { |
||||
let langPackKey: LangPackKey; |
||||
switch(connectionState) { |
||||
case CALL_STATE.PENDING: |
||||
langPackKey = instance.isOutgoing ? 'Call.StatusRinging' : 'Call.StatusCalling'; |
||||
break; |
||||
case CALL_STATE.REQUESTING: |
||||
langPackKey = 'Call.StatusRequesting'; |
||||
break; |
||||
case CALL_STATE.EXCHANGING_KEYS: |
||||
langPackKey = 'VoipExchangingKeys'; |
||||
break; |
||||
case CALL_STATE.CLOSED: |
||||
langPackKey = instance.connectedAt !== undefined ? 'Call.StatusEnded' : 'Call.StatusFailed'; |
||||
break; |
||||
default: |
||||
langPackKey = 'Call.StatusConnecting'; |
||||
break; |
||||
} |
||||
|
||||
element = i18n(langPackKey); |
||||
if(this.interval !== undefined) { |
||||
clearInterval(this.interval); |
||||
this.interval = undefined; |
||||
} |
||||
} |
||||
|
||||
replaceContent(this.container, element); |
||||
|
||||
if(!this.container.parentElement) { |
||||
this.appendTo.append(this.container); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { animate } from "../../helpers/animation"; |
||||
|
||||
export default function callVideoCanvasBlur(video: HTMLVideoElement) { |
||||
const canvas = document.createElement('canvas'); |
||||
canvas.classList.add('call-video-blur'); |
||||
const size = 16; |
||||
canvas.width = size; |
||||
canvas.height = size; |
||||
|
||||
const ctx = canvas.getContext('2d'); |
||||
ctx.filter = 'blur(2px)'; |
||||
const renderFrame = () => { |
||||
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, 0, 0, canvas.width, canvas.height); |
||||
}; |
||||
|
||||
animate(() => { |
||||
renderFrame(); |
||||
return canvas.isConnected; |
||||
}); |
||||
|
||||
renderFrame(); |
||||
|
||||
return canvas; |
||||
} |
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { SuperRLottieIcon } from "../superIcon"; |
||||
|
||||
export default class GroupCallMicrophoneIconMini extends SuperRLottieIcon<{ |
||||
PartState: boolean, |
||||
ColorState: boolean, |
||||
Items: { |
||||
name: 'voice_mini' |
||||
}[] |
||||
}> { |
||||
constructor(colored?: boolean, skipAnimation?: boolean) { |
||||
super({ |
||||
width: 36, |
||||
height: 36, |
||||
getPart: (state) => { |
||||
return this.getItem().getPart(state ? 'unmute' : 'mute'); |
||||
}, |
||||
getColor: colored ? (state) => { |
||||
return state ? [255, 255, 255] : [158, 158, 158]; |
||||
} : undefined, |
||||
skipAnimation |
||||
}); |
||||
|
||||
this.add({ |
||||
name: 'voice_mini', |
||||
parts: [{ |
||||
startFrame: 0, |
||||
endFrame: 35, |
||||
name: 'hand-to-muted' |
||||
}, { |
||||
startFrame: 36, |
||||
endFrame: 68, |
||||
name: 'unmute' |
||||
}, { |
||||
startFrame: 69, |
||||
endFrame: 98, |
||||
name: 'mute' |
||||
}, { |
||||
startFrame: 99, |
||||
endFrame: 135, |
||||
name: 'muted-to-hand' |
||||
}, { |
||||
startFrame: 136, |
||||
endFrame: 171, |
||||
name: 'unmuted-to-hand' |
||||
}] |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
import IS_WEBRTC_SUPPORTED from "./webrtcSupport"; |
||||
|
||||
const IS_CALL_SUPPORTED = IS_WEBRTC_SUPPORTED && false; |
||||
|
||||
export default IS_CALL_SUPPORTED; |
@ -1,3 +1,3 @@
@@ -1,3 +1,3 @@
|
||||
const ctx = typeof(window) !== 'undefined' ? window : self; |
||||
|
||||
export default ctx; |
||||
export default ctx; |
||||
|
@ -1,3 +1,3 @@
@@ -1,3 +1,3 @@
|
||||
const IS_EMOJI_SUPPORTED = navigator.userAgent.search(/OS X|iPhone|iPad|iOS/i) !== -1/* && false *//* || true */; |
||||
|
||||
export default IS_EMOJI_SUPPORTED; |
||||
export default IS_EMOJI_SUPPORTED; |
||||
|
@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
|
||||
import { IS_FIREFOX } from "./userAgent"; |
||||
|
||||
const PARALLAX_SUPPORTED = !IS_FIREFOX && false; |
||||
const IS_PARALLAX_SUPPORTED = !IS_FIREFOX && false; |
||||
|
||||
export default PARALLAX_SUPPORTED; |
||||
export default IS_PARALLAX_SUPPORTED; |
||||
|
@ -1,3 +1,3 @@
@@ -1,3 +1,3 @@
|
||||
const IS_WEBP_SUPPORTED = document.createElement('canvas').toDataURL('image/webp').startsWith('data:image/webp'); |
||||
|
||||
export default IS_WEBP_SUPPORTED; |
||||
export default IS_WEBP_SUPPORTED; |
||||
|
@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
const ASSETS_PATH = 'assets/audio/'; |
||||
|
||||
export default class AudioAssetPlayer<AssetName extends string> { |
||||
private audio: HTMLAudioElement; |
||||
private tempId: number; |
||||
|
||||
constructor(private assets: AssetName[]) { |
||||
this.tempId = 0; |
||||
} |
||||
|
||||
public playSound(name: AssetName, loop = false) { |
||||
++this.tempId; |
||||
|
||||
try { |
||||
const audio = this.createAudio(); |
||||
audio.src = ASSETS_PATH + name; |
||||
audio.loop = loop; |
||||
audio.play(); |
||||
} catch(e) { |
||||
console.error('playSound', name, e); |
||||
} |
||||
} |
||||
|
||||
public createAudio() { |
||||
let {audio} = this; |
||||
if(audio) { |
||||
return audio; |
||||
} |
||||
|
||||
audio = this.audio = new Audio(); |
||||
audio.play(); |
||||
return audio; |
||||
} |
||||
|
||||
public stopSound() { |
||||
this.audio.pause(); |
||||
} |
||||
|
||||
public cancelDelayedPlay() { |
||||
++this.tempId; |
||||
} |
||||
|
||||
public playSoundWithTimeout(name: AssetName, loop: boolean, timeout: number) { |
||||
// timeout = 0;
|
||||
const tempId = ++this.tempId; |
||||
setTimeout(() => { |
||||
if(this.tempId !== tempId) { |
||||
return; |
||||
} |
||||
|
||||
this.playSound(name, loop); |
||||
}, timeout); |
||||
} |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import ctx from "../environment/ctx"; |
||||
import noop from "./noop"; |
||||
|
||||
// It's better to use timeout instead of interval, because interval can be corrupted
|
||||
export default function eachTimeout(callback: () => any, getNextTimeout: () => number, runFirst = true) { |
||||
const cancel = () => { |
||||
clearTimeout(timeout); |
||||
}; |
||||
|
||||
// replace callback to run noop and restore after
|
||||
const _callback = callback; |
||||
if(!runFirst) { |
||||
callback = noop; |
||||
} |
||||
|
||||
let timeout: number; |
||||
(function run() { |
||||
callback(); |
||||
timeout = ctx.setTimeout(run, getNextTimeout()); |
||||
})(); |
||||
|
||||
callback = _callback; |
||||
|
||||
return cancel; |
||||
} |
@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import MovableElement, { MovableElementOptions, MovableState } from "../components/movableElement"; |
||||
import { IS_TOUCH_SUPPORTED } from "../environment/touchSupport"; |
||||
import ListenerSetter from "./listenerSetter"; |
||||
import mediaSizes, { ScreenSize } from "./mediaSizes"; |
||||
import { safeAssign } from "./object"; |
||||
|
||||
export default class MovablePanel { |
||||
#movable: MovableElement; |
||||
private listenerSetter: ListenerSetter; |
||||
private previousState: MovableState; |
||||
private onResize: () => void; |
||||
private movableOptions: MovableElementOptions; |
||||
|
||||
constructor(options: { |
||||
listenerSetter: ListenerSetter, |
||||
previousState: MovableState, |
||||
onResize?: () => void, |
||||
movableOptions: MovableElementOptions |
||||
}) { |
||||
safeAssign(this, options); |
||||
|
||||
this.toggleMovable(!IS_TOUCH_SUPPORTED); |
||||
|
||||
this.listenerSetter.add(mediaSizes)('changeScreen', (from, to) => { |
||||
if(to === ScreenSize.mobile || from === ScreenSize.mobile) { |
||||
this.toggleMovable(!IS_TOUCH_SUPPORTED); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
public destroy() { |
||||
const movable = this.movable; |
||||
if(movable) { |
||||
movable.destroy(); |
||||
} |
||||
} |
||||
|
||||
public get movable() { |
||||
return this.#movable; |
||||
} |
||||
|
||||
public get state() { |
||||
return this.movable ? this.movable.state : this.previousState; |
||||
} |
||||
|
||||
public set state(state: MovableState) { |
||||
this.previousState = state; |
||||
} |
||||
|
||||
private toggleMovable(enabled: boolean) { |
||||
let {movable} = this; |
||||
if(enabled) { |
||||
if(movable) { |
||||
return; |
||||
} |
||||
|
||||
movable = this.#movable = new MovableElement(this.movableOptions); |
||||
|
||||
movable.state = this.previousState; |
||||
if(this.previousState.top === undefined) { |
||||
movable.setPositionToCenter(); |
||||
} |
||||
|
||||
if(this.onResize) { |
||||
this.listenerSetter.add(movable)('resize', this.onResize); |
||||
} |
||||
} else { |
||||
if(!movable) { |
||||
return; |
||||
} |
||||
|
||||
this.previousState = movable.state; |
||||
movable.destroyElements(); |
||||
movable.destroy(); |
||||
this.#movable = undefined; |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { safeAssign } from "../../helpers/object"; |
||||
import { logger } from "../logger"; |
||||
import createDataChannel from "./helpers/createDataChannel"; |
||||
import createPeerConnection from "./helpers/createPeerConnection"; |
||||
import LocalConferenceDescription from "./localConferenceDescription"; |
||||
import StreamManager from "./streamManager"; |
||||
import { Ssrc } from "./types"; |
||||
|
||||
export type CallConnectionInstanceOptions = { |
||||
streamManager: StreamManager, |
||||
connection?: RTCPeerConnection, |
||||
log?: ReturnType<typeof logger> |
||||
}; |
||||
|
||||
export default abstract class CallConnectionInstanceBase { |
||||
public connection: RTCPeerConnection; |
||||
public streamManager: StreamManager; |
||||
public dataChannel: RTCDataChannel; |
||||
public description: LocalConferenceDescription; |
||||
public sources: { |
||||
audio: Ssrc, |
||||
video?: Ssrc, |
||||
}; |
||||
protected negotiating: Promise<void>; |
||||
protected log: ReturnType<typeof logger>; |
||||
|
||||
constructor(options: CallConnectionInstanceOptions) { |
||||
safeAssign(this, options); |
||||
|
||||
if(!this.log) { |
||||
this.log = this.connection?.log || logger('CALL-CONNECTION-BASE'); |
||||
} |
||||
|
||||
this.sources = {} as any; |
||||
} |
||||
|
||||
public createPeerConnection(config?: RTCConfiguration) { |
||||
return this.connection || (this.connection = createPeerConnection(config, this.log.bindPrefix('connection')).connection); |
||||
} |
||||
|
||||
public createDataChannel(dict?: RTCDataChannelInit) { |
||||
return this.dataChannel || (this.dataChannel = createDataChannel(this.connection, dict, this.log.bindPrefix('data'))); |
||||
} |
||||
|
||||
public createDescription() { |
||||
return this.description || (this.description = new LocalConferenceDescription(this.connection)); |
||||
} |
||||
|
||||
public appendStreamToConference() { |
||||
return this.streamManager.appendToConference(this.description); |
||||
} |
||||
|
||||
public closeConnection() { |
||||
const {connection} = this; |
||||
if(!connection) { |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
connection.log('close'); |
||||
connection.close(); |
||||
} catch(e) { |
||||
this.log.error(e); |
||||
} |
||||
} |
||||
|
||||
public closeConnectionAndStream(stopStream: boolean) { |
||||
this.closeConnection(); |
||||
stopStream && this.streamManager.stop(); |
||||
} |
||||
|
||||
protected abstract negotiateInternal(): CallConnectionInstanceBase['negotiating']; |
||||
|
||||
public negotiate() { |
||||
let promise = this.negotiating; |
||||
if(promise) { |
||||
return promise; |
||||
} |
||||
|
||||
return this.negotiating = this.negotiateInternal().finally(() => { |
||||
this.negotiating = undefined; |
||||
}); |
||||
} |
||||
|
||||
public sendDataChannelData(data: any) { |
||||
if(this.dataChannel.readyState !== 'open') { |
||||
return; |
||||
} |
||||
|
||||
this.dataChannel.send(JSON.stringify(data)); |
||||
} |
||||
} |
@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import EventListenerBase, { EventListenerListeners } from "../../helpers/eventListenerBase"; |
||||
import noop from "../../helpers/noop"; |
||||
import { logger } from "../logger"; |
||||
import getAudioConstraints from "./helpers/getAudioConstraints"; |
||||
import getStreamCached from "./helpers/getStreamCached"; |
||||
import getVideoConstraints from "./helpers/getVideoConstraints"; |
||||
import LocalConferenceDescription from "./localConferenceDescription"; |
||||
import StreamManager, { StreamItem } from "./streamManager"; |
||||
|
||||
export type TryAddTrackOptions = { |
||||
stream: MediaStream, |
||||
track: MediaStreamTrack, |
||||
type: StreamItem['type'], |
||||
source?: string |
||||
}; |
||||
|
||||
export default abstract class CallInstanceBase<E extends EventListenerListeners> extends EventListenerBase<E> { |
||||
protected log: ReturnType<typeof logger>; |
||||
protected outputDeviceId: string; |
||||
|
||||
protected player: HTMLElement; |
||||
protected elements: Map<string, HTMLMediaElement>; |
||||
|
||||
protected audio: HTMLAudioElement; |
||||
// protected fixedSafariAudio: boolean;
|
||||
|
||||
protected getStream: ReturnType<typeof getStreamCached>; |
||||
|
||||
constructor() { |
||||
super(false); |
||||
|
||||
const player = this.player = document.createElement('div'); |
||||
player.classList.add('call-player'); |
||||
player.style.display = 'none'; |
||||
document.body.append(player); |
||||
|
||||
this.elements = new Map(); |
||||
|
||||
// possible Safari fix
|
||||
const audio = this.audio = new Audio(); |
||||
audio.autoplay = true; |
||||
audio.volume = 1.0; |
||||
this.player.append(audio); |
||||
this.elements.set('audio', audio); |
||||
|
||||
this.fixSafariAudio(); |
||||
|
||||
this.getStream = getStreamCached(); |
||||
} |
||||
|
||||
public get isSharingAudio() { |
||||
return !!this.streamManager.hasInputTrackKind('audio'); |
||||
} |
||||
|
||||
public get isSharingVideo() { |
||||
return !!this.streamManager.hasInputTrackKind('video'); |
||||
} |
||||
|
||||
public abstract get isMuted(): boolean; |
||||
public abstract get isClosing(): boolean; |
||||
|
||||
public fixSafariAudio() { |
||||
// if(this.fixedSafariAudio) return;
|
||||
this.audio.play().catch(noop); |
||||
// this.fixedSafariAudio = true;
|
||||
} |
||||
|
||||
public requestAudioSource(muted: boolean) { |
||||
return this.requestInputSource(true, false, muted); |
||||
} |
||||
|
||||
public requestInputSource(audio: boolean, video: boolean, muted: boolean) { |
||||
const {streamManager} = this; |
||||
if(streamManager) { |
||||
const isAudioGood = !audio || this.isSharingAudio; |
||||
const isVideoGood = !video || this.isSharingVideo; |
||||
if(isAudioGood && isVideoGood) { |
||||
return Promise.resolve(); |
||||
} |
||||
} |
||||
|
||||
const constraints: MediaStreamConstraints = { |
||||
audio: audio && getAudioConstraints(), |
||||
video: video && getVideoConstraints() |
||||
}; |
||||
|
||||
return this.getStream({ |
||||
constraints, |
||||
muted |
||||
}).then(stream => { |
||||
if(stream.getVideoTracks().length) { |
||||
this.saveInputVideoStream(stream, 'main'); |
||||
} |
||||
|
||||
this.onInputStream(stream); |
||||
}); |
||||
} |
||||
|
||||
public getElement(endpoint: number | string) { |
||||
return this.elements.get('' + endpoint); |
||||
} |
||||
|
||||
public abstract get streamManager(): StreamManager; |
||||
public abstract get description(): LocalConferenceDescription; |
||||
public abstract toggleMuted(): Promise<void>; |
||||
|
||||
public cleanup() { |
||||
this.player.textContent = ''; |
||||
this.player.remove(); |
||||
this.elements.clear(); |
||||
|
||||
// can have no connectionInstance but streamManager with input stream
|
||||
this.streamManager.stop(); |
||||
|
||||
super.cleanup(); |
||||
} |
||||
|
||||
public onTrack(event: RTCTrackEvent) { |
||||
this.tryAddTrack({ |
||||
stream: event.streams[0], |
||||
track: event.track, |
||||
type: 'output' |
||||
}); |
||||
} |
||||
|
||||
public saveInputVideoStream(stream: MediaStream, type?: string) { |
||||
const track = stream.getVideoTracks()[0]; |
||||
this.tryAddTrack({ |
||||
stream, |
||||
track, |
||||
type: 'input', |
||||
source: type || 'main' |
||||
}); |
||||
} |
||||
|
||||
public tryAddTrack({stream, track, type, source}: TryAddTrackOptions) { |
||||
if(!source) { |
||||
source = StreamManager.getSource(stream, type); |
||||
} |
||||
|
||||
this.log('tryAddTrack', stream, track, type, source); |
||||
|
||||
const isOutput = type === 'output'; |
||||
|
||||
const {player, elements, streamManager} = this; |
||||
|
||||
const tagName = track.kind as StreamItem['kind']; |
||||
const isVideo = tagName === 'video'; |
||||
|
||||
const elementEndpoint = isVideo ? source : tagName; |
||||
let element = elements.get(elementEndpoint); |
||||
|
||||
if(isVideo) { |
||||
track.addEventListener('ended', () => { |
||||
this.log('[track] onended'); |
||||
elements.delete(elementEndpoint); |
||||
// element.remove();
|
||||
}, {once: true}); |
||||
} |
||||
|
||||
if(isOutput) { |
||||
streamManager.addTrack(stream, track, type); |
||||
} |
||||
|
||||
const useStream = isVideo ? stream : streamManager.outputStream; |
||||
if(!element) { |
||||
element = document.createElement(tagName); |
||||
element.autoplay = true; |
||||
element.srcObject = useStream; |
||||
element.volume = 1.0; |
||||
|
||||
if((element as any).sinkId !== 'undefined') { |
||||
const {outputDeviceId} = this; |
||||
if(outputDeviceId) { |
||||
(element as any).setSinkId(outputDeviceId); |
||||
} |
||||
} |
||||
|
||||
if(!isVideo) { |
||||
player.appendChild(element); |
||||
} |
||||
// audio.play();
|
||||
|
||||
elements.set(elementEndpoint, element); |
||||
} else { |
||||
if(element.paused) { |
||||
element.play().catch(noop); |
||||
} |
||||
|
||||
if(element.srcObject !== useStream) { |
||||
element.srcObject = useStream; |
||||
} |
||||
} |
||||
|
||||
return source; |
||||
} |
||||
|
||||
public setMuted(muted?: boolean) { |
||||
this.streamManager.inputStream.getAudioTracks().forEach((track) => { |
||||
if(track?.kind === 'audio') { |
||||
track.enabled = muted === undefined ? !track.enabled : !muted; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
protected onInputStream(stream: MediaStream): void { |
||||
if(!this.isClosing) { |
||||
const {streamManager, description} = this; |
||||
streamManager.addStream(stream, 'input'); |
||||
|
||||
if(description) { |
||||
streamManager.appendToConference(description); |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
enum CALL_STATE { |
||||
CONNECTED, |
||||
CONNECTING, |
||||
EXCHANGING_KEYS, |
||||
PENDING, |
||||
REQUESTING, |
||||
CLOSING, |
||||
CLOSED |
||||
} |
||||
|
||||
export default CALL_STATE; |
@ -0,0 +1,371 @@
@@ -0,0 +1,371 @@
|
||||
/* |
||||
* 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/apiManager"; |
||||
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
|
||||
} |
||||
} |
@ -0,0 +1,492 @@
@@ -0,0 +1,492 @@
|
||||
/* |
||||
* 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"; |
||||
import { safeAssign } from "../../helpers/object"; |
||||
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/apiManager"; |
||||
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; |
||||
clone.setAttribute('playsinline', 'true'); |
||||
clone.muted = true; |
||||
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(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { Logger, logger } from "../../logger"; |
||||
|
||||
export default function createDataChannel(connection: RTCPeerConnection, dict?: RTCDataChannelInit, log?: Logger) { |
||||
// return;
|
||||
|
||||
if(!log) { |
||||
log = logger('RTCDataChannel'); |
||||
} |
||||
|
||||
const channel = connection.createDataChannel('data', dict); |
||||
|
||||
channel.addEventListener('message', (e) => { |
||||
log('onmessage', e); |
||||
}); |
||||
channel.addEventListener('open', () => { |
||||
log('onopen'); |
||||
}); |
||||
channel.addEventListener('close', () => { |
||||
log('onclose'); |
||||
}); |
||||
|
||||
channel.log = log; |
||||
|
||||
return channel; |
||||
} |
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { GROUP_CALL_AMPLITUDE_ANALYSE_INTERVAL_MS } from "../constants"; |
||||
import StreamManager from "../streamManager"; |
||||
import getAudioConstraints from "./getAudioConstraints"; |
||||
import getStream from "./getStream"; |
||||
import getVideoConstraints from "./getVideoConstraints"; |
||||
|
||||
export default async function createMainStreamManager(muted?: boolean, joinVideo?: boolean) { |
||||
const constraints: MediaStreamConstraints = { |
||||
audio: getAudioConstraints(), |
||||
video: joinVideo && getVideoConstraints() |
||||
}; |
||||
|
||||
const streamManager = new StreamManager(GROUP_CALL_AMPLITUDE_ANALYSE_INTERVAL_MS); |
||||
|
||||
try { |
||||
const stream = await getStream(constraints, muted); |
||||
streamManager.addStream(stream, 'input'); |
||||
} catch(err) { |
||||
console.error('joinGroupCall getStream error', err, constraints); |
||||
streamManager.inputStream = new MediaStream(); |
||||
} |
||||
|
||||
return streamManager; |
||||
} |
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { Logger, logger } from "../../logger"; |
||||
|
||||
export default function createPeerConnection(config: RTCConfiguration, log?: Logger) { |
||||
if(!log) { |
||||
log = logger('RTCPeerConnection'); |
||||
} |
||||
|
||||
log('constructor'); |
||||
|
||||
// @ts-ignore
|
||||
const connection = new RTCPeerConnection(config); |
||||
connection.addEventListener('track', (event) => { |
||||
log('ontrack', event); |
||||
}); |
||||
connection.addEventListener('signalingstatechange', () => { |
||||
log('onsignalingstatechange', connection.signalingState); |
||||
}); |
||||
connection.addEventListener('connectionstatechange', () => { |
||||
log('onconnectionstatechange', connection.connectionState); |
||||
}); |
||||
connection.addEventListener('negotiationneeded', () => { // * will be fired every time input device changes
|
||||
log('onnegotiationneeded', connection.signalingState); |
||||
}); |
||||
connection.addEventListener('icecandidate', (event) => { |
||||
log('onicecandidate', event); |
||||
}); |
||||
connection.addEventListener('iceconnectionstatechange', () => { |
||||
log('oniceconnectionstatechange', connection.iceConnectionState); |
||||
}); |
||||
connection.addEventListener('datachannel', () => { |
||||
log('ondatachannel'); |
||||
}); |
||||
|
||||
connection.log = log; |
||||
|
||||
return {connection}; |
||||
} |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
/* |
||||
* 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 SDPMediaSection from "../sdp/mediaSection"; |
||||
import { UpdateGroupCallConnectionData, Codec } from "../types"; |
||||
|
||||
export default function filterServerCodecs(mainChannels: SDPMediaSection[], data: UpdateGroupCallConnectionData) { |
||||
// ! Need to filter server's extmap for Firefox
|
||||
const performExtmap = (channel: typeof mainChannels[0]) => { |
||||
const out: {[id: string]: string} = {}; |
||||
const extmap = channel.attributes.get('extmap'); |
||||
extmap.forEach((extmap) => { |
||||
const id = extmap.key.split('/', 1)[0]; |
||||
out[id] = extmap.value; |
||||
}); |
||||
|
||||
return out; |
||||
}; |
||||
|
||||
const codecsToPerform: [Codec, 'audio' | 'video'][] = /* flatten([data, dataPresentation].filter(Boolean).map(data => { |
||||
return */['audio' as const, 'video' as const].filter(type => data[type]).map(type => ([data[type], type])); |
||||
// }));
|
||||
|
||||
codecsToPerform.forEach(([codec, type]) => { |
||||
const channel = mainChannels.find(line => line.mediaType === type); |
||||
if(!channel) { |
||||
return; |
||||
} |
||||
|
||||
const extmap = performExtmap(channel); |
||||
forEachReverse(codec["rtp-hdrexts"], (value, index, arr) => { |
||||
if(extmap[value.id] !== value.uri) { |
||||
arr.splice(index, 1); |
||||
console.log(`[sdp] filtered extmap:`, value, index, type); |
||||
} |
||||
}); |
||||
}); |
||||
} |
@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
|
||||
/* |
||||
* 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 { copy } from "../../../helpers/object"; |
||||
import { ConferenceEntry } from "../localConferenceDescription"; |
||||
import { parseSdp, addSimulcast } from "../sdp/utils"; |
||||
import { generateMediaFirstLine, SDPBuilder } from "../sdpBuilder"; |
||||
import { UpdateGroupCallConnectionData } from "../types"; |
||||
import parseMediaSectionInfo from "./parseMediaSectionInfo"; |
||||
|
||||
export default function fixLocalOffer(options: { |
||||
offer: RTCSessionDescriptionInit, |
||||
data: UpdateGroupCallConnectionData, |
||||
skipAddingMulticast?: boolean |
||||
// mids?: string[]
|
||||
}) { |
||||
const {offer, data} = options; |
||||
const sdp = parseSdp(offer.sdp); |
||||
let hasMunged = false; |
||||
|
||||
if(!options.skipAddingMulticast) { |
||||
hasMunged = addSimulcast(sdp) || hasMunged; |
||||
} |
||||
|
||||
// const bundleLine = parsedSdp.session.lines.find(line => line.Ha?.key === 'group');
|
||||
// const bundleMids = bundleLine.value.split(' ').slice(1);
|
||||
|
||||
forEachReverse(sdp.media, (section, idx, arr) => { |
||||
// const mid = section.oa.get('mid').oa;
|
||||
|
||||
// это может случиться при выключении и включении видео. почему-то появится секция уже удалённая
|
||||
// ! нельзя тут модифицировать локальное описание, будет критовать
|
||||
/* if(mids && !mids.includes(mid) && !bundleMids.includes(mid)) { |
||||
console.error('wtf'); |
||||
hasMunged = true; |
||||
arr.splice(idx, 1); |
||||
return; |
||||
} */ |
||||
|
||||
if(/* section.mediaType !== 'video' || */section.isSending) { |
||||
return; |
||||
} |
||||
|
||||
if(section.mediaType === 'application') { |
||||
return; |
||||
} |
||||
|
||||
const mediaLine = section.mediaLine; |
||||
const mediaLineParts = mediaLine.mediaLineParts; |
||||
const mediaCodecIds = mediaLineParts.ids; |
||||
const localMLine = mediaLine.toString(); |
||||
|
||||
const codec = data[section.mediaType]; |
||||
const payloadTypes = codec['payload-types']; |
||||
|
||||
/* forEachReverse(payloadTypes, (payloadType, idx, arr) => { |
||||
if(!mediaCodecIds.includes('' + payloadType.id) && section.mediaType === 'video') { |
||||
// if(payloadType.name === 'H265') {
|
||||
console.warn('[sdp] filtered unsupported codec', payloadType, mediaCodecIds, section.mediaType); |
||||
arr.splice(idx, 1); |
||||
} |
||||
}); */ |
||||
|
||||
const codecIds = payloadTypes.map(payload => '' + payload.id); |
||||
const correctMLine = generateMediaFirstLine(section.mediaType, undefined, codecIds); |
||||
|
||||
if(localMLine !== correctMLine) { |
||||
const sectionInfo = parseMediaSectionInfo(sdp, section); |
||||
|
||||
let newData = {...data}; |
||||
newData.transport = copy(newData.transport); |
||||
newData.transport.ufrag = sectionInfo.ufrag; |
||||
newData.transport.pwd = sectionInfo.pwd; |
||||
newData.transport.fingerprints = [sectionInfo.fingerprint]; |
||||
newData.transport.candidates = []; |
||||
|
||||
const entry = new ConferenceEntry(sectionInfo.mid, mediaLineParts.type); |
||||
entry.setPort(mediaLineParts.port); |
||||
sectionInfo.source && entry.setSource(sectionInfo.sourceGroups || sectionInfo.source); |
||||
entry.setDirection(section.direction); |
||||
|
||||
const newSdp = new SDPBuilder().addSsrcEntry(entry, newData).finalize(); |
||||
|
||||
const newChannel = parseSdp(newSdp).media[0]; |
||||
arr[idx] = newChannel; |
||||
|
||||
hasMunged = true; |
||||
} |
||||
}); |
||||
|
||||
if(hasMunged) { |
||||
const mungedSdp = sdp.toString(); |
||||
offer.sdp = mungedSdp; |
||||
} |
||||
|
||||
return {offer, sdp/* , bundleMids */}; |
||||
} |
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
import constraintSupported, { MyMediaTrackSupportedConstraints } from "../../../environment/constraintSupport"; |
||||
|
||||
export default function getAudioConstraints(): MediaTrackConstraints { |
||||
const constraints: MediaTrackConstraints = { |
||||
channelCount: 2 |
||||
}; |
||||
|
||||
const desirable: (keyof MyMediaTrackSupportedConstraints)[] = [ |
||||
'noiseSuppression', |
||||
'echoCancellation', |
||||
'autoGainControl' |
||||
]; |
||||
|
||||
desirable.forEach(constraint => { |
||||
if(constraintSupported(constraint)) { |
||||
// @ts-ignore
|
||||
constraints[constraint] = true; |
||||
} |
||||
}); |
||||
|
||||
return constraints; |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue