tweb/src/components/appMediaViewerBase.ts

1523 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import { deferredPromise } from "../helpers/cancellablePromise";
import mediaSizes from "../helpers/mediaSizes";
import { IS_TOUCH_SUPPORTED } from "../environment/touchSupport";
import { IS_MOBILE_SAFARI, IS_SAFARI } from "../environment/userAgent";
import appDocsManager, { MyDocument } from "../lib/appManagers/appDocsManager";
import appPhotosManager, { MyPhoto } from "../lib/appManagers/appPhotosManager";
import { logger } from "../lib/logger";
import VideoPlayer from "../lib/mediaPlayer";
import rootScope from "../lib/rootScope";
import animationIntersector from "./animationIntersector";
import appMediaPlaybackController from "./appMediaPlaybackController";
import AvatarElement from "./avatar";
import ButtonIcon from "./buttonIcon";
import { ButtonMenuItemOptions } from "./buttonMenu";
import ButtonMenuToggle from "./buttonMenuToggle";
import { LazyLoadQueueBase } from "./lazyLoadQueue";
import ProgressivePreloader from "./preloader";
import SwipeHandler from "./swipeHandler";
import { formatFullSentTime } from "../helpers/date";
import appNavigationController from "./appNavigationController";
import { Message } from "../layer";
import findUpClassName from "../helpers/dom/findUpClassName";
import renderImageFromUrl, { renderImageFromUrlPromise } from "../helpers/dom/renderImageFromUrl";
import getVisibleRect from "../helpers/dom/getVisibleRect";
import appDownloadManager from "../lib/appManagers/appDownloadManager";
import { cancelEvent } from "../helpers/dom/cancelEvent";
import fillPropertyValue from "../helpers/fillPropertyValue";
import generatePathData from "../helpers/generatePathData";
import replaceContent from "../helpers/dom/replaceContent";
import PeerTitle from "./peerTitle";
import { doubleRaf, fastRaf } from "../helpers/schedulers";
import RangeSelector from "./rangeSelector";
import windowSize from "../helpers/windowSize";
import ListLoader from "../helpers/listLoader";
import EventListenerBase from "../helpers/eventListenerBase";
const ZOOM_STEP = 0.5;
const ZOOM_INITIAL_VALUE = 1;
const ZOOM_MIN_VALUE = 0.5;
const ZOOM_MAX_VALUE = 4;
// TODO: масштабирование картинок (не SVG) при ресайзе, и правильный возврат на исходную позицию
// TODO: картинки "обрезаются" если возвращаются или появляются с места, где есть их перекрытие (топбар, поле ввода)
// TODO: видео в мобильной вёрстке, если показываются элементы управления: если свайпнуть в сторону, то элементы вернутся на место, т.е. прыгнут - это не ок, надо бы замаскировать
export const MEDIA_VIEWER_CLASSNAME = 'media-viewer';
export default class AppMediaViewerBase<
ContentAdditionType extends string,
ButtonsAdditionType extends string,
TargetType extends {element: HTMLElement
}> extends EventListenerBase<{
setMoverBefore: () => void,
setMoverAfter: () => void
}> {
protected wholeDiv: HTMLElement;
protected overlaysDiv: HTMLElement;
protected author: {[k in 'container' | 'avatarEl' | 'nameEl' | 'date']: HTMLElement} = {} as any;
protected content: {[k in 'main' | 'container' | 'media' | 'mover' | ContentAdditionType]: HTMLElement} = {} as any;
protected buttons: {[k in 'download' | 'close' | 'prev' | 'next' | 'mobile-close' | 'zoom' | ButtonsAdditionType]: HTMLElement} = {} as any;
protected topbar: HTMLElement;
protected moversContainer: HTMLElement;
protected tempId = 0;
protected preloader: ProgressivePreloader = null;
protected preloaderStreamable: ProgressivePreloader = null;
//protected targetContainer: HTMLElement = null;
//protected loadMore: () => void = null;
protected log: ReturnType<typeof logger>;
protected isFirstOpen = true;
// protected needLoadMore = true;
protected pageEl = document.getElementById('page-chats') as HTMLDivElement;
protected setMoverPromise: Promise<void>;
protected setMoverAnimationPromise: Promise<void>;
protected lazyLoadQueue: LazyLoadQueueBase;
protected highlightSwitchersTimeout: number;
protected onDownloadClick: (e: MouseEvent) => void;
protected onPrevClick: (target: TargetType) => void;
protected onNextClick: (target: TargetType) => void;
protected videoPlayer: VideoPlayer;
protected zoomElements: {
container: HTMLElement,
btnOut: HTMLElement,
btnIn: HTMLElement,
rangeSelector: RangeSelector
} = {} as any;
// protected zoomValue = ZOOM_INITIAL_VALUE;
protected zoomSwipeHandler: SwipeHandler;
protected zoomSwipeStartX = 0;
protected zoomSwipeStartY = 0;
protected zoomSwipeX = 0;
protected zoomSwipeY = 0;
protected ctrlKeyDown: boolean;
get target() {
return this.listLoader.current;
}
set target(value) {
this.listLoader.current = value;
}
constructor(
protected listLoader: ListLoader<TargetType, any>,
topButtons: Array<keyof AppMediaViewerBase<ContentAdditionType, ButtonsAdditionType, TargetType>['buttons']>
) {
super(false);
this.log = logger('AMV');
this.preloader = new ProgressivePreloader();
this.preloaderStreamable = new ProgressivePreloader({
cancelable: false,
streamable: true
});
this.preloader.construct();
this.preloaderStreamable.construct();
this.lazyLoadQueue = new LazyLoadQueueBase();
this.wholeDiv = document.createElement('div');
this.wholeDiv.classList.add(MEDIA_VIEWER_CLASSNAME + '-whole');
this.overlaysDiv = document.createElement('div');
this.overlaysDiv.classList.add('overlays');
const mainDiv = document.createElement('div');
mainDiv.classList.add(MEDIA_VIEWER_CLASSNAME);
const topbar = this.topbar = document.createElement('div');
topbar.classList.add(MEDIA_VIEWER_CLASSNAME + '-topbar', MEDIA_VIEWER_CLASSNAME + '-appear');
const topbarLeft = document.createElement('div');
topbarLeft.classList.add(MEDIA_VIEWER_CLASSNAME + '-topbar-left');
this.buttons['mobile-close'] = ButtonIcon('close', {onlyMobile: true});
// * author
this.author.container = document.createElement('div');
this.author.container.classList.add(MEDIA_VIEWER_CLASSNAME + '-author', 'no-select');
const authorRight = document.createElement('div');
this.author.avatarEl = new AvatarElement();
this.author.avatarEl.classList.add(MEDIA_VIEWER_CLASSNAME + '-userpic', 'avatar-44');
this.author.nameEl = document.createElement('div');
this.author.nameEl.classList.add(MEDIA_VIEWER_CLASSNAME + '-name');
this.author.date = document.createElement('div');
this.author.date.classList.add(MEDIA_VIEWER_CLASSNAME + '-date');
authorRight.append(this.author.nameEl, this.author.date);
this.author.container.append(this.author.avatarEl, authorRight);
// * buttons
const buttonsDiv = document.createElement('div');
buttonsDiv.classList.add(MEDIA_VIEWER_CLASSNAME + '-buttons');
topButtons.concat(['download', 'zoom', 'close']).forEach(name => {
const button = ButtonIcon(name, {noRipple: true});
this.buttons[name] = button;
buttonsDiv.append(button);
});
this.buttons.zoom.classList.add('zoom-in');
// * zoom
this.zoomElements.container = document.createElement('div');
this.zoomElements.container.classList.add('zoom-container');
this.zoomElements.btnOut = ButtonIcon('zoomout', {noRipple: true});
this.zoomElements.btnOut.addEventListener('click', () => this.changeZoom(false));
this.zoomElements.btnIn = ButtonIcon('zoomin', {noRipple: true});
this.zoomElements.btnIn.addEventListener('click', () => this.changeZoom(true));
this.zoomElements.rangeSelector = new RangeSelector({
step: ZOOM_STEP,
min: ZOOM_MIN_VALUE,
max: ZOOM_MAX_VALUE,
withTransition: true
}, ZOOM_INITIAL_VALUE);
this.zoomElements.rangeSelector.setListeners();
this.zoomElements.rangeSelector.setHandlers({
onScrub: this.setZoomValue,
onMouseUp: () => this.setZoomValue()
});
this.zoomElements.container.append(this.zoomElements.btnOut, this.zoomElements.rangeSelector.container, this.zoomElements.btnIn);
this.wholeDiv.append(this.zoomElements.container);
// * content
this.content.main = document.createElement('div');
this.content.main.classList.add(MEDIA_VIEWER_CLASSNAME + '-content');
this.content.container = document.createElement('div');
this.content.container.classList.add(MEDIA_VIEWER_CLASSNAME + '-container');
this.content.media = document.createElement('div');
this.content.media.classList.add(MEDIA_VIEWER_CLASSNAME + '-media');
this.content.container.append(this.content.media);
this.content.main.append(this.content.container);
mainDiv.append(this.content.main);
this.overlaysDiv.append(mainDiv);
// * overlays end
topbarLeft.append(this.buttons['mobile-close'], this.author.container);
topbar.append(topbarLeft, buttonsDiv);
this.buttons.prev = document.createElement('div');
this.buttons.prev.className = `${MEDIA_VIEWER_CLASSNAME}-switcher ${MEDIA_VIEWER_CLASSNAME}-switcher-left`;
this.buttons.prev.innerHTML = `<span class="tgico-down ${MEDIA_VIEWER_CLASSNAME}-prev-button"></span>`;
this.buttons.next = document.createElement('div');
this.buttons.next.className = `${MEDIA_VIEWER_CLASSNAME}-switcher ${MEDIA_VIEWER_CLASSNAME}-switcher-right`;
this.buttons.next.innerHTML = `<span class="tgico-down ${MEDIA_VIEWER_CLASSNAME}-next-button"></span>`;
this.moversContainer = document.createElement('div');
this.moversContainer.classList.add(MEDIA_VIEWER_CLASSNAME + '-movers');
this.wholeDiv.append(this.overlaysDiv, this.buttons.prev, this.buttons.next, this.topbar, this.moversContainer);
// * constructing html end
this.setNewMover();
}
protected setListeners() {
this.buttons.download.addEventListener('click', this.onDownloadClick);
[this.buttons.close, this.buttons['mobile-close'], this.preloaderStreamable.preloader].forEach(el => {
el.addEventListener('click', this.close.bind(this));
});
([[-1, this.buttons.prev], [1, this.buttons.next]] as [number, HTMLElement][]).forEach(([moveLength, button]) => {
button.addEventListener('click', (e) => {
cancelEvent(e);
if(this.setMoverPromise) return;
this.listLoader.go(moveLength);
});
});
this.buttons.zoom.addEventListener('click', () => {
if(this.isZooming()) this.toggleZoom(false);
else {
this.changeZoom(true);
}
});
this.wholeDiv.addEventListener('click', this.onClick);
this.listLoader.onJump = (item, older) => {
if(older) this.onNextClick(item);
else this.onPrevClick(item);
};
if(IS_TOUCH_SUPPORTED) {
const swipeHandler = new SwipeHandler({
element: this.wholeDiv,
onSwipe: (xDiff, yDiff) => {
if(VideoPlayer.isFullScreen()) {
return;
}
//console.log(xDiff, yDiff);
const percents = Math.abs(xDiff) / windowSize.windowW;
if(percents > .2 || xDiff > 125) {
//console.log('will swipe', xDiff);
if(xDiff < 0) {
this.buttons.prev.click();
} else {
this.buttons.next.click();
}
return true;
}
const percentsY = Math.abs(yDiff) / windowSize.windowH;
if(percentsY > .2 || yDiff > 125) {
this.buttons.close.click();
return true;
}
return false;
},
verifyTouchTarget: (evt) => {
// * Fix for seek input
if((evt.target as HTMLElement).tagName === 'INPUT' || findUpClassName(evt.target, 'media-viewer-caption')) {
return false;
}
return true;
}
});
}
}
protected toggleZoom(enable?: boolean) {
const isVisible = this.isZooming();
if(this.zoomElements.rangeSelector.mousedown || this.ctrlKeyDown) {
enable = true;
}
if(isVisible === enable) return;
if(enable === undefined) {
enable = !isVisible;
}
this.buttons.zoom.classList.toggle('zoom-in', !enable);
this.zoomElements.container.classList.toggle('is-visible', enable);
const zoomValue = enable ? this.zoomElements.rangeSelector.value : 1;
this.setZoomValue(zoomValue);
this.zoomElements.rangeSelector.setProgress(zoomValue);
if(this.videoPlayer) {
this.videoPlayer.lockControls(enable ? false : undefined);
}
if(enable) {
if(!this.zoomSwipeHandler) {
let lastDiffX: number, lastDiffY: number;
const multiplier = -1;
this.zoomSwipeHandler = new SwipeHandler({
element: this.moversContainer,
onFirstSwipe: () => {
lastDiffX = lastDiffY = 0;
this.moversContainer.classList.add('no-transition');
},
onSwipe: (xDiff, yDiff) => {
[xDiff, yDiff] = [xDiff * multiplier, yDiff * multiplier];
this.zoomSwipeX += xDiff - lastDiffX;
this.zoomSwipeY += yDiff - lastDiffY;
[lastDiffX, lastDiffY] = [xDiff, yDiff];
this.setZoomValue();
},
onReset: () => {
this.moversContainer.classList.remove('no-transition');
},
cursor: 'move'
});
} else {
this.zoomSwipeHandler.setListeners();
}
this.zoomElements.rangeSelector.setProgress(zoomValue);
} else if(!enable) {
this.zoomSwipeHandler.removeListeners();
}
}
protected changeZoom(add: boolean) {
this.zoomElements.rangeSelector.addProgress(ZOOM_STEP * (add ? 1 : -1));
this.setZoomValue();
}
protected setZoomValue = (value = this.zoomElements.rangeSelector.value) => {
// this.zoomValue = value;
if(value === ZOOM_INITIAL_VALUE) {
this.zoomSwipeX = 0;
this.zoomSwipeY = 0;
}
this.moversContainer.style.transform = `matrix(${value}, 0, 0, ${value}, ${this.zoomSwipeX}, ${this.zoomSwipeY})`;
this.zoomElements.btnOut.classList.toggle('inactive', value === ZOOM_MIN_VALUE);
this.zoomElements.btnIn.classList.toggle('inactive', value === ZOOM_MAX_VALUE);
this.toggleZoom(value !== ZOOM_INITIAL_VALUE);
};
protected isZooming() {
return this.zoomElements.container.classList.contains('is-visible');
}
protected setBtnMenuToggle(buttons: ButtonMenuItemOptions[]) {
const btnMenuToggle = ButtonMenuToggle({onlyMobile: true}, 'bottom-left', buttons);
this.topbar.append(btnMenuToggle);
}
public close(e?: MouseEvent) {
if(e) {
cancelEvent(e);
}
if(this.setMoverAnimationPromise) return Promise.reject();
appNavigationController.removeByType('media');
this.lazyLoadQueue.clear();
const promise = this.setMoverToTarget(this.target?.element, true).then(({onAnimationEnd}) => onAnimationEnd);
this.listLoader.reset();
(this.listLoader as any).cleanup && (this.listLoader as any).cleanup();
this.setMoverPromise = null;
this.tempId = -1;
(window as any).appMediaViewer = undefined;
if(this.zoomSwipeHandler) {
this.zoomSwipeHandler.removeListeners();
this.zoomSwipeHandler = undefined;
}
/* if(appSidebarRight.historyTabIDs.slice(-1)[0] === AppSidebarRight.SLIDERITEMSIDS.forward) {
promise.then(() => {
appSidebarRight.forwardTab.closeBtn.click();
});
} */
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('wheel', this.onWheel, {capture: true});
promise.finally(() => {
this.wholeDiv.remove();
rootScope.isOverlayActive = false;
animationIntersector.checkAnimations(false);
});
return promise;
}
onClick = (e: MouseEvent) => {
if(this.setMoverAnimationPromise) return;
const target = e.target as HTMLElement;
if(target.tagName === 'A') return;
cancelEvent(e);
if(IS_TOUCH_SUPPORTED) {
if(this.highlightSwitchersTimeout) {
clearTimeout(this.highlightSwitchersTimeout);
} else {
this.wholeDiv.classList.add('highlight-switchers');
}
this.highlightSwitchersTimeout = window.setTimeout(() => {
this.wholeDiv.classList.remove('highlight-switchers');
this.highlightSwitchersTimeout = 0;
}, 3e3);
return;
}
const isZooming = this.isZooming();
let mover: HTMLElement = null;
const classNames = ['ckin__player', 'media-viewer-buttons', 'media-viewer-author', 'media-viewer-caption', 'zoom-container'];
if(isZooming) {
classNames.push('media-viewer-movers');
}
classNames.find(s => {
try {
mover = findUpClassName(target, s);
if(mover) return true;
} catch(err) {return false;}
});
if(/* target === this.mediaViewerDiv */!mover || (!isZooming && (target.tagName === 'IMG' || target.tagName === 'image'))) {
this.buttons.close.click();
}
};
private onKeyDown = (e: KeyboardEvent) => {
//this.log('onKeyDown', e);
if(rootScope.overlaysActive > 1) {
return;
}
let good = true;
if(e.key === 'ArrowRight') {
this.buttons.next.click();
} else if(e.key === 'ArrowLeft') {
this.buttons.prev.click();
} else if(e.key === '-' || e.key === '=') {
if(this.ctrlKeyDown) {
this.changeZoom(e.key === '=');
}
} else {
good = false;
}
if(e.ctrlKey || e.metaKey) {
this.ctrlKeyDown = true;
}
if(good) {
cancelEvent(e);
}
};
private onKeyUp = (e: KeyboardEvent) => {
if(rootScope.overlaysActive > 1) {
return;
}
if(!(e.ctrlKey || e.metaKey)) {
this.ctrlKeyDown = false;
if(this.isZooming()) {
this.setZoomValue();
}
}
};
private onWheel = (e: WheelEvent) => {
if(rootScope.overlaysActive > 1 || (findUpClassName(e.target, 'media-viewer-caption') && !this.ctrlKeyDown)) {
return;
}
cancelEvent(e);
if(this.ctrlKeyDown) {
const scrollingUp = e.deltaY < 0;
// if(!scrollingUp && !this.isZooming()) return;
this.changeZoom(!!scrollingUp);
}
};
protected async setMoverToTarget(target: HTMLElement, closing = false, fromRight = 0) {
this.dispatchEvent('setMoverBefore');
const mover = this.content.mover;
if(!closing) {
mover.innerHTML = '';
//mover.append(this.buttons.prev, this.buttons.next);
}
const zoomValue = this.isZooming() && closing /* && false */ ? this.zoomElements.rangeSelector.value : ZOOM_INITIAL_VALUE;
/* if(!(zoomValue > 1 && closing)) */ this.removeCenterFromMover(mover);
const wasActive = fromRight !== 0;
const delay = rootScope.settings.animationsEnabled ? (wasActive ? 350 : 200) : 0;
//let delay = wasActive ? 350 : 10000;
/* if(wasActive) {
this.moveTheMover(mover);
mover = this.setNewMover();
} */
/* if(DEBUG) {
this.log('setMoverToTarget', target, closing, wasActive, fromRight);
} */
let realParent: HTMLElement;
let rect: DOMRect;
if(target) {
if(target instanceof AvatarElement || target.classList.contains('grid-item')/* || target.classList.contains('document-ico') */) {
realParent = target;
rect = target.getBoundingClientRect();
} else if(target instanceof SVGImageElement || target.parentElement instanceof SVGForeignObjectElement) {
realParent = findUpClassName(target, 'attachment');
rect = realParent.getBoundingClientRect();
} else if(target.classList.contains('profile-avatars-avatar')) {
realParent = findUpClassName(target, 'profile-avatars-container');
rect = realParent.getBoundingClientRect();
// * if not active avatar
if(closing && target.getBoundingClientRect().left !== rect.left) {
target = realParent = rect = undefined;
}
}
}
if(!target) {
target = this.content.media;
}
if(!rect) {
realParent = target.parentElement as HTMLElement;
rect = target.getBoundingClientRect();
}
let needOpacity = false;
if(target !== this.content.media && !target.classList.contains('profile-avatars-avatar')) {
const overflowElement = findUpClassName(realParent, 'scrollable');
const visibleRect = getVisibleRect(realParent, overflowElement);
if(closing && (!visibleRect || visibleRect.overflow.vertical === 2 || visibleRect.overflow.horizontal === 2)) {
target = this.content.media;
realParent = target.parentElement as HTMLElement;
rect = target.getBoundingClientRect();
} else if(visibleRect && (visibleRect.overflow.vertical === 1 || visibleRect.overflow.horizontal === 1)) {
needOpacity = true;
}
}
const containerRect = this.content.media.getBoundingClientRect();
let transform = '';
let left: number;
let top: number;
if(wasActive) {
left = fromRight === 1 ? windowSize.windowW : -containerRect.width;
top = containerRect.top;
} else {
left = rect.left;
top = rect.top;
}
/* if(zoomValue > 1) { // 33
// const diffX = (rect.width * zoomValue - rect.width) / 4;
const diffX = (rect.width * zoomValue - rect.width) / 2;
const diffY = (rect.height * zoomValue - rect.height) / 4;
// left -= diffX;
// top += diffY;
} */
transform += `translate3d(${left}px,${top}px,0) `;
/* if(wasActive) {
left = fromRight === 1 ? appPhotosManager.windowW / 2 : -(containerRect.width + appPhotosManager.windowW / 2);
transform += `translate(${left}px,-50%) `;
} else {
left = rect.left - (appPhotosManager.windowW / 2);
top = rect.top - (appPhotosManager.windowH / 2);
transform += `translate(${left}px,${top}px) `;
} */
let aspecter: HTMLDivElement;
if(target instanceof HTMLImageElement || target instanceof HTMLVideoElement || target.tagName === 'DIV') {
if(mover.firstElementChild && mover.firstElementChild.classList.contains('media-viewer-aspecter')) {
aspecter = mover.firstElementChild as HTMLDivElement;
const player = aspecter.querySelector('.ckin__player');
if(player) {
const video = player.firstElementChild as HTMLVideoElement;
aspecter.append(video);
player.remove();
}
if(!aspecter.style.cssText) { // всё из-за видео, элементы управления скейлятся, так бы можно было этого не делать
mover.classList.remove('active');
this.setFullAspect(aspecter, containerRect, rect);
void mover.offsetLeft; // reflow
mover.classList.add('active');
}
} else {
aspecter = document.createElement('div');
aspecter.classList.add('media-viewer-aspecter'/* , 'disable-hover' */);
mover.prepend(aspecter);
}
aspecter.style.cssText = `width: ${rect.width}px; height: ${rect.height}px; transform: scale3d(${containerRect.width / rect.width}, ${containerRect.height / rect.height}, 1);`;
}
mover.style.width = containerRect.width + 'px';
mover.style.height = containerRect.height + 'px';
// const scaleX = rect.width / (containerRect.width * zoomValue);
// const scaleY = rect.height / (containerRect.height * zoomValue);
const scaleX = rect.width / containerRect.width;
const scaleY = rect.height / containerRect.height;
if(!wasActive) {
transform += `scale3d(${scaleX},${scaleY},1) `;
}
let borderRadius = window.getComputedStyle(realParent).getPropertyValue('border-radius');
const brSplitted = fillPropertyValue(borderRadius) as string[];
borderRadius = brSplitted.map(r => (parseInt(r) / scaleX) + 'px').join(' ');
if(!wasActive) {
mover.style.borderRadius = borderRadius;
}
//let borderRadius = '0px 0px 0px 0px';
if(closing && zoomValue !== 1) {
// const width = this.moversContainer.scrollWidth * scaleX;
// const height = this.moversContainer.scrollHeight * scaleY;
const willBeLeft = windowSize.windowW / 2 - rect.width / 2;
const willBeTop = windowSize.windowH / 2 - rect.height / 2;
const left = rect.left - willBeLeft/* + (width - rect.width) / 2 */;
const top = rect.top - willBeTop/* + (height - rect.height) / 2 */;
this.moversContainer.style.transform = `matrix(${scaleX}, 0, 0, ${scaleY}, ${left}, ${top})`;
} else {
mover.style.transform = transform;
}
needOpacity && (mover.style.opacity = '0'/* !closing ? '0' : '' */);
/* if(wasActive) {
this.log('setMoverToTarget', mover.style.transform);
} */
let path: SVGPathElement;
const isOut = target.classList.contains('is-out');
const deferred = this.setMoverAnimationPromise = deferredPromise<void>();
const ret = {onAnimationEnd: deferred};
const timeout = setTimeout(() => {
if(!deferred.isFulfilled && !deferred.isRejected) {
deferred.resolve();
}
}, 1000);
deferred.finally(() => {
this.dispatchEvent('setMoverAfter');
if(this.setMoverAnimationPromise === deferred) {
this.setMoverAnimationPromise = null;
}
clearTimeout(timeout);
});
if(!closing) {
let mediaElement: HTMLImageElement | HTMLVideoElement;
let src: string;
if(target instanceof HTMLVideoElement) {
const elements = Array.from(target.parentElement.querySelectorAll('img')) as HTMLImageElement[];
if(elements.length) {
target = elements.pop();
}
}
if(target.tagName === 'DIV' || target.tagName === 'AVATAR-ELEMENT') { // useContainerAsTarget
const images = Array.from(target.querySelectorAll('img')) as HTMLImageElement[];
const image = images.pop();
if(image) {
mediaElement = new Image();
src = image.src;
mover.append(mediaElement);
}
/* mediaElement = new Image();
src = target.style.backgroundImage.slice(5, -2); */
} else if(target instanceof HTMLImageElement) {
mediaElement = new Image();
src = target.src;
} else if(target instanceof HTMLVideoElement) {
mediaElement = document.createElement('video');
mediaElement.src = target.src;
} else if(target instanceof SVGSVGElement) {
const clipId = target.dataset.clipId;
const newClipId = clipId + '-mv';
const {width, height} = containerRect;
const newSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
newSvg.setAttributeNS(null, 'width', '' + width);
newSvg.setAttributeNS(null, 'height', '' + height);
// нижние два свойства для масштабирования
newSvg.setAttributeNS(null, 'viewBox', `0 0 ${width} ${height}`);
newSvg.setAttributeNS(null, 'preserveAspectRatio', 'xMidYMid meet');
newSvg.insertAdjacentHTML('beforeend', target.firstElementChild.outerHTML.replace(clipId, newClipId));
newSvg.insertAdjacentHTML('beforeend', target.lastElementChild.outerHTML.replace(clipId, newClipId));
// теперь надо выставить новую позицию для хвостика
const defs = newSvg.firstElementChild;
const use = defs.firstElementChild.firstElementChild as SVGUseElement;
if(use instanceof SVGUseElement) {
let transform = use.getAttributeNS(null, 'transform');
transform = transform.replace(/translate\((.+?), (.+?)\) scale\((.+?), (.+?)\)/, (match, x, y, sX, sY) => {
x = +x;
if(x !== 2) {
x = width - (2 / scaleX);
} else {
x = 2 / scaleX;
}
y = height;
return `translate(${x}, ${y}) scale(${+sX / scaleX}, ${+sY / scaleY})`;
});
use.setAttributeNS(null, 'transform', transform);
// и новый RECT
path = defs.firstElementChild.lastElementChild as SVGPathElement;
// код ниже нужен только чтобы скрыть моргание до момента как сработает таймаут
let d: string;
const br: [number, number, number, number] = borderRadius.split(' ').map(v => parseInt(v)) as any;
if(isOut) d = generatePathData(0, 0, width - 9 / scaleX, height, ...br);
else d = generatePathData(9 / scaleX, 0, width - 9 / scaleX, height, ...br);
path.setAttributeNS(null, 'd', d);
}
const foreignObject = newSvg.lastElementChild;
foreignObject.setAttributeNS(null, 'width', '' + containerRect.width);
foreignObject.setAttributeNS(null, 'height', '' + containerRect.height);
mover.prepend(newSvg);
}
if(aspecter) {
aspecter.style.borderRadius = borderRadius;
if(mediaElement) {
aspecter.append(mediaElement);
}
}
mediaElement = mover.querySelector('video, img');
if(mediaElement instanceof HTMLImageElement) {
mediaElement.classList.add('thumbnail');
if(!aspecter) {
mediaElement.style.width = containerRect.width + 'px';
mediaElement.style.height = containerRect.height + 'px';
}
if(src) {
await renderImageFromUrlPromise(mediaElement, src);
}
}/* else if(mediaElement instanceof HTMLVideoElement && mediaElement.firstElementChild && ((mediaElement.firstElementChild as HTMLSourceElement).src || src)) {
await new Promise((resolve, reject) => {
mediaElement.addEventListener('loadeddata', resolve);
if(src) {
(mediaElement.firstElementChild as HTMLSourceElement).src = src;
}
});
} */
mover.style.display = '';
fastRaf(() => {
mover.classList.add(wasActive ? 'moving' : 'active');
});
} else {
/* if(mover.classList.contains('center')) {
mover.classList.remove('center');
void mover.offsetLeft; // reflow
} */
if(target instanceof SVGSVGElement) {
path = mover.querySelector('path');
if(path) {
this.sizeTailPath(path, containerRect, scaleX, delay, false, isOut, borderRadius);
}
}
if(target.classList.contains('media-viewer-media')) {
mover.classList.add('hiding');
}
this.wholeDiv.classList.add('backwards');
setTimeout(() => {
this.wholeDiv.classList.remove('active');
}, 0);
//return ret;
setTimeout(() => {
mover.style.borderRadius = borderRadius;
if(mover.firstElementChild) {
(mover.firstElementChild as HTMLElement).style.borderRadius = borderRadius;
}
}, delay / 2);
setTimeout(() => {
mover.innerHTML = '';
mover.classList.remove('moving', 'active', 'hiding');
mover.style.cssText = 'display: none;';
deferred.resolve();
}, delay);
mover.classList.remove('opening');
return ret;
}
mover.classList.add('opening');
//await new Promise((resolve) => setTimeout(resolve, 0));
//await new Promise((resolve) => window.requestAnimationFrame(resolve));
// * одного RAF'а недостаточно, иногда анимация с одним не срабатывает (преимущественно на мобильных)
await doubleRaf();
// чтобы проверить установленную позицию - раскомментировать
// throw '';
//await new Promise((resolve) => setTimeout(resolve, 5e3));
mover.style.transform = `translate3d(${containerRect.left}px,${containerRect.top}px,0) scale3d(1,1,1)`;
//mover.style.transform = `translate(-50%,-50%) scale(1,1)`;
needOpacity && (mover.style.opacity = ''/* closing ? '0' : '' */);
if(aspecter) {
this.setFullAspect(aspecter, containerRect, rect);
}
//throw '';
setTimeout(() => {
mover.style.borderRadius = '';
if(mover.firstElementChild) {
(mover.firstElementChild as HTMLElement).style.borderRadius = '';
}
}, 0/* delay / 2 */);
mover.dataset.timeout = '' + setTimeout(() => {
mover.classList.remove('moving', 'opening');
if(aspecter) { // всё из-за видео, элементы управления скейлятся, так бы можно было этого не делать
if(mover.querySelector('video') || true) {
mover.classList.remove('active');
aspecter.style.cssText = '';
void mover.offsetLeft; // reflow
}
//aspecter.classList.remove('disable-hover');
}
// эти строки нужны для установки центральной позиции, в случае ресайза это будет нужно
mover.classList.add('center', 'no-transition');
/* mover.style.left = mover.style.top = '50%';
mover.style.transform = 'translate(-50%, -50%)';
void mover.offsetLeft; // reflow */
// это уже нужно для будущих анимаций
mover.classList.add('active');
delete mover.dataset.timeout;
deferred.resolve();
}, delay);
if(path) {
this.sizeTailPath(path, containerRect, scaleX, delay, true, isOut, borderRadius);
}
return ret;
}
protected setFullAspect(aspecter: HTMLDivElement, containerRect: DOMRect, rect: DOMRect) {
/* let media = aspecter.firstElementChild;
let proportion: number;
if(media instanceof HTMLImageElement) {
proportion = media.naturalWidth / media.naturalHeight;
} else if(media instanceof HTMLVideoElement) {
proportion = media.videoWidth / media.videoHeight;
} */
const proportion = containerRect.width / containerRect.height;
let {width, height} = rect;
/* if(proportion === 1) {
aspecter.style.cssText = '';
} else { */
if(proportion > 0) {
width = height * proportion;
} else {
height = width * proportion;
}
//this.log('will set style aspecter:', `width: ${width}px; height: ${height}px; transform: scale(${containerRect.width / width}, ${containerRect.height / height});`);
aspecter.style.cssText = `width: ${width}px; height: ${height}px; transform: scale3d(${containerRect.width / width}, ${containerRect.height / height}, 1);`;
//}
}
protected sizeTailPath(path: SVGPathElement, rect: DOMRect, scaleX: number, delay: number, upscale: boolean, isOut: boolean, borderRadius: string) {
const start = Date.now();
const {width, height} = rect;
delay = delay / 2;
const br = borderRadius.split(' ').map(v => parseInt(v));
const step = () => {
const diff = Date.now() - start;
let progress = delay ? diff / delay : 1;
if(progress > 1) progress = 1;
if(upscale) progress = 1 - progress;
const _br: [number, number, number, number] = br.map(v => v * progress) as any;
let d: string;
if(isOut) d = generatePathData(0, 0, width - (9 / scaleX * progress), height, ..._br);
else d = generatePathData(9 / scaleX * progress, 0, width/* width - (9 / scaleX * progress) */, height, ..._br);
path.setAttributeNS(null, 'd', d);
if(diff < delay) fastRaf(step);
};
//window.requestAnimationFrame(step);
step();
}
protected removeCenterFromMover(mover: HTMLElement) {
if(mover.classList.contains('center')) {
//const rect = mover.getBoundingClientRect();
const rect = this.content.media.getBoundingClientRect();
mover.style.transform = `translate3d(${rect.left}px,${rect.top}px,0)`;
mover.classList.remove('center');
void mover.offsetLeft; // reflow
mover.classList.remove('no-transition');
}
}
protected moveTheMover(mover: HTMLElement, toLeft = true) {
const windowW = windowSize.windowW;
this.removeCenterFromMover(mover);
//mover.classList.remove('active');
mover.classList.add('moving');
if(mover.dataset.timeout) { // и это тоже всё из-за скейла видео, так бы это не нужно было
clearTimeout(+mover.dataset.timeout);
}
const rect = mover.getBoundingClientRect();
const newTransform = mover.style.transform.replace(/translate3d\((.+?),/, (match, p1) => {
const x = toLeft ? -rect.width : windowW;
//const x = toLeft ? -(rect.right + (rect.width / 2)) : windowW / 2;
return match.replace(p1, x + 'px');
});
////////this.log('set newTransform:', newTransform, mover.style.transform, toLeft);
mover.style.transform = newTransform;
setTimeout(() => {
mover.remove();
}, 350);
}
protected setNewMover() {
const newMover = document.createElement('div');
newMover.classList.add('media-viewer-mover');
newMover.style.display = 'none';
if(this.content.mover) {
const oldMover = this.content.mover;
oldMover.parentElement.append(newMover);
} else {
this.moversContainer.append(newMover);
}
return this.content.mover = newMover;
}
protected updateMediaSource(target: HTMLElement, url: string, tagName: 'video' | 'img') {
//if(target instanceof SVGSVGElement) {
const el = target.tagName.toLowerCase() === tagName ? target : target.querySelector(tagName) as HTMLElement;
if(el) {
if(!target.classList.contains('document-ico') && findUpClassName(target, 'attachment')) {
// two parentElements because element can be contained in aspecter
const preloader = target.parentElement.parentElement.querySelector('.preloader-container') as HTMLElement;
if(preloader) {
if(tagName === 'video') {
if(preloader.classList.contains('manual')) {
preloader.click();
// return;
}
return;
}
preloader.remove();
}
}
renderImageFromUrl(el, url);
// ! костыль, но он тут даже и не нужен
if(el.classList.contains('thumbnail') && el.parentElement.classList.contains('media-container-aspecter')) {
el.classList.remove('thumbnail');
}
}
/* } else {
} */
}
protected setAuthorInfo(fromId: PeerId, timestamp: number) {
replaceContent(this.author.date, formatFullSentTime(timestamp));
replaceContent(this.author.nameEl, new PeerTitle({
peerId: fromId,
dialog: false,
onlyFirstName: false,
plainText: false
}).element);
let oldAvatar = this.author.avatarEl;
this.author.avatarEl = (this.author.avatarEl.cloneNode() as AvatarElement);
this.author.avatarEl.setAttribute('peer', '' + (fromId || rootScope.myId));
oldAvatar.parentElement.replaceChild(this.author.avatarEl, oldAvatar);
}
protected async _openMedia(
media: MyDocument | MyPhoto,
timestamp: number,
fromId: PeerId,
fromRight: number,
target?: HTMLElement,
reverse = false,
prevTargets: TargetType[] = [],
nextTargets: TargetType[] = [],
message?: Message.message
/* , needLoadMore = true */
) {
if(this.setMoverPromise) return this.setMoverPromise;
/* if(DEBUG) {
this.log('openMedia:', media, fromId, prevTargets, nextTargets);
} */
this.setAuthorInfo(fromId, timestamp);
const isDocument = media._ === 'document';
const isVideo = isDocument && media.mime_type && ((['video', 'gif'] as MyDocument['type'][]).includes(media.type) || media.mime_type.indexOf('video/') === 0);
if(this.isFirstOpen) {
//this.targetContainer = targetContainer;
// this.needLoadMore = needLoadMore;
this.isFirstOpen = false;
this.listLoader.setTargets(prevTargets, nextTargets, reverse);
(window as any).appMediaViewer = this;
//this.loadMore = loadMore;
/* if(appSidebarRight.historyTabIDs.slice(-1)[0] === AppSidebarRight.SLIDERITEMSIDS.forward) {
appSidebarRight.forwardTab.closeBtn.click();
await new Promise((resolve) => setTimeout(resolve, 200));
} */
}
/* if(this.nextTargets.length < 10 && this.loadMore) {
this.loadMore();
} */
//if(prevTarget && (!prevTarget.parentElement || !this.isElementVisible(this.targetContainer, prevTarget))) prevTarget = null;
//if(nextTarget && (!nextTarget.parentElement || !this.isElementVisible(this.targetContainer, nextTarget))) nextTarget = null;
this.buttons.prev.classList.toggle('hide', !this.listLoader.previous.length);
this.buttons.next.classList.toggle('hide', !this.listLoader.next.length);
const container = this.content.media;
const useContainerAsTarget = !target || target === container;
if(useContainerAsTarget) target = container;
this.target = {element: target} as any;
const tempId = ++this.tempId;
if(container.firstElementChild) {
container.innerHTML = '';
}
// ok set
const wasActive = fromRight !== 0;
if(wasActive) {
this.moveTheMover(this.content.mover, fromRight === 1);
this.setNewMover();
} else {
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp);
if(!IS_TOUCH_SUPPORTED) window.addEventListener('wheel', this.onWheel, {passive: false, capture: true});
const mainColumns = document.getElementById('main-columns');
this.pageEl.insertBefore(this.wholeDiv, mainColumns);
void this.wholeDiv.offsetLeft; // reflow
this.wholeDiv.classList.add('active');
rootScope.isOverlayActive = true;
animationIntersector.checkAnimations(true);
if(!IS_MOBILE_SAFARI) {
appNavigationController.pushItem({
type: 'media',
onPop: (canAnimate) => {
if(this.setMoverAnimationPromise) {
return false;
}
this.close();
}
});
}
}
////////this.log('wasActive:', wasActive);
const mover = this.content.mover;
const maxWidth = windowSize.windowW;
//const maxWidth = this.pageEl.scrollWidth;
// TODO: const maxHeight = mediaSizes.isMobile ? appPhotosManager.windowH : appPhotosManager.windowH - 100;
let padding = 0;
const windowH = windowSize.windowH;
if(windowH < 1000000 && !mediaSizes.isMobile) {
padding = 120;
}
const maxHeight = windowH - 120 - padding;
let thumbPromise: Promise<any> = Promise.resolve();
const size = appPhotosManager.setAttachmentSize(media, container, maxWidth, maxHeight, mediaSizes.isMobile ? false : true, undefined, !!(isDocument && media.w && media.h)).photoSize;
if(useContainerAsTarget) {
const cacheContext = appDownloadManager.getCacheContext(media, size.type);
let img: HTMLImageElement;
if(cacheContext.downloaded) {
img = new Image();
img.src = cacheContext.url;
} else {
const gotThumb = appPhotosManager.getStrippedThumbIfNeeded(media, cacheContext, true);
if(gotThumb) {
thumbPromise = gotThumb.loadPromise;
img = gotThumb.image;
}
}
if(img) {
img.classList.add('thumbnail');
container.append(img);
}
}
// need after setAttachmentSize
/* if(useContainerAsTarget) {
target = target.querySelector('img, video') || target;
} */
const supportsStreaming: boolean = !!(isDocument && media.supportsStreaming);
const preloader = supportsStreaming ? this.preloaderStreamable : this.preloader;
let setMoverPromise: Promise<void>;
if(isVideo) {
////////this.log('will wrap video', media, size);
// потому что для safari нужно создать элемент из event'а
// const video = document.createElement('video');
const useController = message && media.type !== 'gif';
const video = /* useController ?
appMediaPlaybackController.addMedia(message, false, true) as HTMLVideoElement :
*/document.createElement('video');
const set = () => this.setMoverToTarget(target, false, fromRight).then(({onAnimationEnd}) => {
//return; // set and don't move
//if(wasActive) return;
//return;
const div = mover.firstElementChild && mover.firstElementChild.classList.contains('media-viewer-aspecter') ? mover.firstElementChild : mover;
//const video = mover.querySelector('video') || document.createElement('video');
const moverVideo = mover.querySelector('video');
if(moverVideo) {
moverVideo.remove();
}
//video.src = '';
video.setAttribute('playsinline', 'true');
// * fix for playing video if viewer is closed (https://contest.com/javascript-web-bonus/entry1425#issue11629)
video.addEventListener('timeupdate', () => {
if(this.tempId !== tempId) {
video.pause();
}
});
video.addEventListener('error', () => {
if(video.error.code !== 4) {
this.log.error("Error " + video.error.code + "; details: " + video.error.message);
}
if(preloader) {
preloader.detach();
}
}, {once: true});
this.addEventListener('setMoverAfter', () => {
video.src = '';
video.load();
}, {once: true});
if(IS_SAFARI) {
// test stream
// video.controls = true;
video.autoplay = true;
}
if(media.type === 'gif') {
video.muted = true;
video.autoplay = true;
video.loop = true;
}
// if(!video.parentElement) {
div.append(video);
// }
const canPlayThrough = new Promise((resolve) => {
video.addEventListener('canplay', resolve, {once: true});
});
const createPlayer = () => {
if(media.type !== 'gif') {
video.dataset.ckin = 'default';
video.dataset.overlay = '1';
Promise.all([canPlayThrough, onAnimationEnd]).then(() => {
if(this.tempId !== tempId) {
return;
}
// const play = useController ? appMediaPlaybackController.willBePlayedMedia === video : true;
const play = true;
const player = this.videoPlayer = new VideoPlayer(video, play, supportsStreaming);
player.addEventListener('toggleControls', (show) => {
this.wholeDiv.classList.toggle('has-video-controls', show);
});
this.addEventListener('setMoverBefore', () => {
this.wholeDiv.classList.remove('has-video-controls');
this.videoPlayer.removeListeners();
this.videoPlayer = undefined;
}, {once: true});
if(this.isZooming()) {
this.videoPlayer.lockControls(false);
}
/* div.append(video);
mover.append(player.wrapper); */
});
}
};
if(supportsStreaming) {
onAnimationEnd.then(() => {
if(video.readyState < video.HAVE_FUTURE_DATA) {
preloader.attach(mover, true);
}
/* canPlayThrough.then(() => {
preloader.detach();
}); */
});
const attachCanPlay = () => {
video.addEventListener('canplay', () => {
//this.log('video waited and progress loaded');
preloader.detach();
video.parentElement.classList.remove('is-buffering');
}, {once: true});
};
video.addEventListener('waiting', () => {
const loading = video.networkState === video.NETWORK_LOADING;
const isntEnoughData = video.readyState < video.HAVE_FUTURE_DATA;
//this.log('video waiting for progress', loading, isntEnoughData);
if(loading && isntEnoughData) {
attachCanPlay();
preloader.attach(mover, true);
// поставлю класс для плеера, чтобы убрать большую иконку пока прелоадер на месте
video.parentElement.classList.add('is-buffering');
}
});
attachCanPlay();
}
//if(!video.src || media.url !== video.src) {
const load = () => {
/* if(useController) {
appMediaPlaybackController.resolveWaitingForLoadMedia(message.peerId, message.mid, message.pFlags.is_scheduled);
} */
const cacheContext = appDownloadManager.getCacheContext(media);
const promise: Promise<any> = supportsStreaming ? Promise.resolve() : appDocsManager.downloadDoc(media);
if(!supportsStreaming) {
onAnimationEnd.then(() => {
if(!cacheContext.url) {
preloader.attach(mover, true, promise);
}
});
}
Promise.all([promise, onAnimationEnd]).then(() => {
if(this.tempId !== tempId) {
this.log.warn('media viewer changed video');
return;
}
if(useController) {
const rollback = appMediaPlaybackController.setSingleMedia(video, message);
this.addEventListener('setMoverBefore', () => {
rollback();
}, {once: true});
}
const url = cacheContext.url;
if(target instanceof SVGSVGElement/* && (video.parentElement || !isSafari) */) { // if video exists
//if(!video.parentElement) {
div.firstElementChild.lastElementChild.append(video);
//}
} else {
renderImageFromUrl(video, url);
}
this.updateMediaSource(target, url, 'video');
createPlayer();
});
return promise;
};
this.lazyLoadQueue.unshift({load});
//} else createPlayer();
});
setMoverPromise = thumbPromise.then(set);
} else {
const set = () => this.setMoverToTarget(target, false, fromRight).then(({onAnimationEnd}) => {
//return; // set and don't move
//if(wasActive) return;
//return;
const load = () => {
const cacheContext = appDownloadManager.getCacheContext(media, size.type);
const cancellablePromise = isDocument ? appDocsManager.downloadDoc(media) : appPhotosManager.preloadPhoto(media, size);
onAnimationEnd.then(() => {
if(!cacheContext.url) {
this.preloader.attachPromise(cancellablePromise);
//this.preloader.attach(mover, true, cancellablePromise);
}
});
Promise.all([onAnimationEnd, cancellablePromise]).then(() => {
if(this.tempId !== tempId) {
this.log.warn('media viewer changed photo');
return;
}
///////this.log('indochina', blob);
const url = cacheContext.url;
if(target instanceof SVGSVGElement) {
this.updateMediaSource(target, url, 'img');
this.updateMediaSource(mover, url, 'img');
if(mediaSizes.isMobile) {
const imgs = mover.querySelectorAll('img');
if(imgs && imgs.length) {
imgs.forEach(img => {
img.classList.remove('thumbnail'); // может здесь это вообще не нужно
});
}
}
} else {
const div = mover.firstElementChild && mover.firstElementChild.classList.contains('media-viewer-aspecter') ? mover.firstElementChild : mover;
const haveImage = div.firstElementChild?.tagName === 'IMG' ? div.firstElementChild as HTMLImageElement : null;
if(!haveImage || haveImage.src !== url) {
let image = new Image();
image.classList.add('thumbnail');
//this.log('will renderImageFromUrl:', image, div, target);
renderImageFromUrl(image, url, () => {
this.updateMediaSource(target, url, 'img');
if(haveImage) {
fastRaf(() => {
haveImage.remove();
});
}
div.append(image);
});
}
}
//this.preloader.detach();
}).catch(err => {
this.log.error(err);
this.preloader.attach(mover);
this.preloader.setManual();
});
return cancellablePromise;
};
this.lazyLoadQueue.unshift({load});
});
setMoverPromise = thumbPromise.then(set);
}
return this.setMoverPromise = setMoverPromise.catch(() => {
this.setMoverAnimationPromise = null;
}).finally(() => {
this.setMoverPromise = null;
});
}
}