/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ import IS_SCREEN_SHARING_SUPPORTED from '../../environment/screenSharingSupport'; import {IS_MOBILE} from '../../environment/userAgent'; import {attachClickEvent} from '../../helpers/dom/clickEvent'; import ControlsHover from '../../helpers/dom/controlsHover'; import findUpClassName from '../../helpers/dom/findUpClassName'; import {addFullScreenListener, cancelFullScreen, isFullScreen, requestFullScreen} from '../../helpers/dom/fullScreen'; import replaceContent from '../../helpers/dom/replaceContent'; import MovablePanel from '../../helpers/movablePanel'; import onMediaLoad from '../../helpers/onMediaLoad'; import themeController from '../../helpers/themeController'; import toggleClassName from '../../helpers/toggleClassName'; import CallInstance from '../../lib/calls/callInstance'; import CALL_STATE from '../../lib/calls/callState'; import I18n, {i18n} from '../../lib/langPack'; import wrapEmojiText from '../../lib/richTextProcessor/wrapEmojiText'; import animationIntersector from '../animationIntersector'; import AvatarElement from '../avatar'; import ButtonIcon from '../buttonIcon'; import GroupCallMicrophoneIconMini from '../groupCall/microphoneIconMini'; import {MovableState} from '../movableElement'; import PeerTitle from '../peerTitle'; import PopupElement from '../popups'; import SetTransition from '../singleTransition'; import makeButton from './button'; import CallDescriptionElement from './description'; import callVideoCanvasBlur from './videoCanvasBlur'; const className = 'call'; const MIN_WIDTH = 400; const MIN_HEIGHT = 580; const INIT_STATE: MovableState = { width: MIN_WIDTH, height: MIN_HEIGHT }; let previousState: MovableState = {...INIT_STATE}; export default class PopupCall extends PopupElement { private peerId: PeerId; private description: CallDescriptionElement; private emojisSubtitle: HTMLElement; private partyStates: HTMLElement; private partyMutedState: HTMLElement; private firstButtonsRow: HTMLElement; private secondButtonsRow: HTMLElement; private declineI18nElement: I18n.IntlElement; private makeButton: (options: Parameters[2]) => HTMLElement; private btnAccept: HTMLElement; private btnDecline: HTMLElement; private btnVideo: HTMLElement; private btnScreen: HTMLElement; private btnMute: HTMLElement; private btnFullScreen: HTMLButtonElement; private btnExitFullScreen: HTMLButtonElement; private movablePanel: MovablePanel; private microphoneIcon: GroupCallMicrophoneIconMini; private muteI18nElement: I18n.IntlElement; private videoContainers: { input?: HTMLElement, output?: HTMLElement }; private controlsHover: ControlsHover; constructor(private instance: CallInstance) { super('popup-call', { withoutOverlay: true, closable: true }); this.videoContainers = {}; const {container, listenerSetter} = this; container.classList.add(className, 'night'); const avatarContainer = document.createElement('div'); avatarContainer.classList.add(className + '-avatar'); const peerId = this.peerId = this.instance.interlocutorUserId.toPeerId(); const avatar = new AvatarElement(); avatar.classList.add('avatar-full'); avatar.updateWithOptions({ isBig: true, peerId: peerId }); avatarContainer.append(avatar); const title = new PeerTitle({ peerId }).element; title.classList.add(className + '-title'); const subtitle = document.createElement('div'); subtitle.classList.add(className + '-subtitle'); const description = this.description = new CallDescriptionElement(subtitle); const emojisSubtitle = this.emojisSubtitle = document.createElement('div'); emojisSubtitle.classList.add(className + '-emojis'); container.append(avatarContainer, title, subtitle); if(!IS_MOBILE) { this.btnFullScreen = ButtonIcon('fullscreen'); this.btnExitFullScreen = ButtonIcon('smallscreen hide'); attachClickEvent(this.btnFullScreen, this.onFullScreenClick, {listenerSetter}); attachClickEvent(this.btnExitFullScreen, () => cancelFullScreen(), {listenerSetter}); addFullScreenListener(this.container, this.onFullScreenChange, listenerSetter); this.header.prepend(this.btnExitFullScreen); this.header.append(this.btnFullScreen); container.append(emojisSubtitle); } else { this.header.append(emojisSubtitle); } this.partyStates = document.createElement('div'); this.partyStates.classList.add(className + '-party-states'); this.partyMutedState = document.createElement('div'); this.partyMutedState.classList.add(className + '-party-state'); const stateText = i18n('VoipUserMicrophoneIsOff', [new PeerTitle({peerId, onlyFirstName: true, limitSymbols: 18}).element]); stateText.classList.add(className + '-party-state-text'); const mutedIcon = new GroupCallMicrophoneIconMini(false, true); mutedIcon.setState(false, false); this.partyMutedState.append( mutedIcon.container, stateText ); this.partyStates.append(this.partyMutedState); this.container.append(this.partyStates); this.makeButton = makeButton.bind(null, className, this.listenerSetter); this.constructFirstButtons(); this.constructSecondButtons(); listenerSetter.add(instance)('state', () => { this.updateInstance(); }); listenerSetter.add(instance)('mediaState', () => { this.updateInstance(); }); this.movablePanel = new MovablePanel({ listenerSetter, movableOptions: { minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT, element: this.element, verifyTouchTarget: (e) => { const target = e.target; if(findUpClassName(target, 'call-button') || findUpClassName(target, 'btn-icon') || isFullScreen()) { return false; } return true; } }, // onResize: () => this.toggleBigLayout(), previousState: !this.instance.wasTryingToJoin && !this.instance.isOutgoing ? {...INIT_STATE} : previousState }); const movableElement = this.movablePanel.movable; if(movableElement) { this.listenerSetter.add(movableElement)('resize', () => { this.resizeVideoContainers(); }); } const controlsHover = this.controlsHover = new ControlsHover(); controlsHover.setup({ element: this.container, listenerSetter: this.listenerSetter, showOnLeaveToClassName: 'call-buttons' }); controlsHover.showControls(false); this.addEventListener('close', () => { const {movablePanel} = this; previousState = movablePanel.state; this.microphoneIcon.destroy(); movablePanel.destroy(); }); this.updateInstance(); } public getCallInstance() { return this.instance; } private constructFirstButtons() { const buttons = this.firstButtonsRow = document.createElement('div'); buttons.classList.add(className + '-buttons', 'is-first'); const toggleDisability = toggleClassName.bind(null, 'btn-disabled'); const btnVideo = this.btnVideo = this.makeButton({ text: 'Call.Camera', icon: 'videocamera_filled', callback: () => { const toggle = toggleDisability([btnVideo, btnScreen], true); this.instance.toggleVideoSharing().finally(toggle); } }); const btnScreen = this.btnScreen = this.makeButton({ text: 'Call.Screen', icon: 'sharescreen_filled', callback: () => { const toggle = toggleDisability([btnVideo, btnScreen], true); this.instance.toggleScreenSharing().finally(toggle); } }); if(!IS_SCREEN_SHARING_SUPPORTED) { btnScreen.classList.add('hide'); this.container.classList.add('no-screen'); } this.muteI18nElement = new I18n.IntlElement({ key: 'Call.Mute' }); const btnMute = this.btnMute = this.makeButton({ text: this.muteI18nElement.element, callback: () => { this.instance.toggleMuted(); } }); const microphoneIcon = this.microphoneIcon = new GroupCallMicrophoneIconMini(true, true); btnMute.firstElementChild.append(microphoneIcon.container); // btnVideo.classList.add('disabled'); // btnScreen.classList.add('disabled'); buttons.append(btnVideo, btnScreen, btnMute); this.container.append(buttons); } private constructSecondButtons() { const buttons = this.secondButtonsRow = document.createElement('div'); buttons.classList.add(className + '-buttons', 'is-second'); this.declineI18nElement = new I18n.IntlElement({ key: 'Call.Decline' }); const btnDecline = this.btnDecline = this.makeButton({ text: this.declineI18nElement.element, icon: 'endcall_filled', callback: () => { this.instance.hangUp('phoneCallDiscardReasonHangup'); }, isDanger: true }); const btnAccept = this.btnAccept = this.makeButton({ text: 'Call.Accept', icon: 'phone_filled', callback: () => { this.instance.acceptCall(); }, isConfirm: true }); buttons.append(btnDecline, btnAccept); this.container.append(buttons); } private onFullScreenClick = () => { requestFullScreen(this.container); }; private onFullScreenChange = () => { const isFull = isFullScreen(); const {btnFullScreen, btnExitFullScreen} = this; const wasFullScreen = this.container.classList.contains('is-full-screen'); this.container.classList.toggle('is-full-screen', isFull); btnFullScreen && btnFullScreen.classList.toggle('hide', isFull); btnExitFullScreen && btnExitFullScreen.classList.toggle('hide', !isFull); this.btnClose.classList.toggle('hide', isFull); if(isFull !== wasFullScreen) { animationIntersector.checkAnimations(isFull); themeController.setThemeColor(isFull ? '#000000' : undefined); this.resizeVideoContainers(); } }; private createVideoContainer(video: HTMLVideoElement) { const _className = className + '-video'; const container = document.createElement('div'); container.classList.add(_className + '-container'); video.classList.add(_className); if(video.paused) { video.play(); } attachClickEvent(container, () => { if(!container.classList.contains('small')) { return; } const big = Object.values(this.videoContainers).find((container) => !container.classList.contains('small')); big.classList.add('small'); big.style.cssText = container.style.cssText; container.classList.remove('small'); container.style.cssText = ''; this.resizeVideoContainers(); }); const canvas = callVideoCanvasBlur(video); canvas.classList.add(_className + '-blur'); container.append(canvas, video); return container; } private updateInstance() { const {instance} = this; const {connectionState} = instance; if(connectionState === CALL_STATE.CLOSED) { if(this.container.classList.contains('is-full-screen')) { cancelFullScreen(); } this.btnVideo.classList.add('disabled'); this.hide(); return; } const isPendingIncoming = !instance.isOutgoing && connectionState === CALL_STATE.PENDING; this.declineI18nElement.compareAndUpdate({ key: connectionState === CALL_STATE.PENDING ? 'Call.Decline' : 'Call.End' }); this.btnAccept.classList.toggle('disable', !isPendingIncoming); this.btnAccept.classList.toggle('hide-me', !isPendingIncoming); this.container.classList.toggle('two-button-rows', isPendingIncoming); const isMuted = instance.isMuted; const onFrame = () => { this.btnMute.firstElementChild.classList.toggle('active', isMuted); }; const player = this.microphoneIcon.getItem().player; this.microphoneIcon.setState(!isMuted, !isMuted, onFrame); if(!player) { onFrame(); } this.muteI18nElement.compareAndUpdate({ key: isMuted ? 'VoipUnmute' : 'Call.Mute' }); const isSharingVideo = instance.isSharingVideo; this.btnVideo.firstElementChild.classList.toggle('active', isSharingVideo); const isSharingScreen = instance.isSharingScreen; this.btnScreen.firstElementChild.classList.toggle('active', isSharingScreen); const outputState = instance.getMediaState('output'); SetTransition(this.partyMutedState, 'is-visible', !!outputState?.muted, 300); const containers = this.videoContainers; const oldContainers = {...containers}; ['input' as const, 'output' as const].forEach((type) => { const mediaState = instance.getMediaState(type); const video = instance.getVideoElement(type) as HTMLVideoElement; const hasFrame = !!(video && video.videoWidth && video.videoHeight); if(video && !hasFrame && !video.dataset.hasPromise) { video.dataset.hasPromise = '1'; // container.classList.add('hide'); onMediaLoad(video).then(() => { delete video.dataset.hasPromise; this.updateInstance(); // this.resizeVideoContainers(); // container.classList.remove('hide'); }); } const isActive = !!video && hasFrame && !!(mediaState && (mediaState.videoState === 'active' || mediaState.screencastState === 'active')); let videoContainer = containers[type]; if(isActive && video && !videoContainer) { videoContainer = containers[type] = this.createVideoContainer(video); this.container.append(videoContainer); } if(!isActive && videoContainer) { videoContainer.remove(); delete containers[type]; } }); { const input = containers.input; const output = containers.output; if(Object.keys(oldContainers).length !== Object.keys(containers).length && input) { input.classList.toggle('small', !!output); } if(output && !input) { output.classList.remove('small'); } } this.resizeVideoContainers(); this.container.classList.toggle('no-video', !Object.keys(containers).length); if(!this.emojisSubtitle.textContent && connectionState < CALL_STATE.EXCHANGING_KEYS) { Promise.resolve(instance.getEmojisFingerprint()).then((emojis) => { replaceContent(this.emojisSubtitle, wrapEmojiText(emojis.join(''))); }); } this.setDescription(); } private resizeVideoContainers() { Object.values(this.videoContainers).forEach((container) => { const isSmall = container.classList.contains('small'); if(isSmall) { const video = container.querySelector('video'); const popupWidth = this.movablePanel.state; const MAX_WIDTH_PX = 240; const MAX_HEIGHT_PX = 240; const isVertical = video.videoHeight > video.videoWidth; const MAX_SIZE = isVertical ? MAX_HEIGHT_PX : MAX_WIDTH_PX; const biggestSideSize = 1 / 3 * (isFullScreen() ? 0xFFFF : (isVertical ? popupWidth.height : popupWidth.width)); const widthRatio = isVertical ? video.videoWidth / video.videoHeight : 1; const heightRatio = isVertical ? 1 : video.videoHeight / video.videoWidth; container.style.width = biggestSideSize * widthRatio + 'px'; container.style.height = biggestSideSize * heightRatio + 'px'; container.style.maxWidth = MAX_SIZE * widthRatio + 'px'; container.style.maxHeight = MAX_SIZE * heightRatio + 'px'; } else { container.style.cssText = ''; } }); } private setDescription() { this.description.update(this.instance); } }