/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ // * zoom part from WebZ // * https://github.com/Ajaxy/telegram-tt/blob/069f4f5b2f2c7c22529ccced876842e7f9cb81f4/src/components/mediaViewer/MediaViewerSlides.tsx import type {MyDocument} from '../lib/appManagers/appDocsManager'; import type {MyPhoto} from '../lib/appManagers/appPhotosManager'; import deferredPromise from '../helpers/cancellablePromise'; import mediaSizes from '../helpers/mediaSizes'; import IS_TOUCH_SUPPORTED from '../environment/touchSupport'; import {IS_MOBILE, IS_MOBILE_SAFARI, IS_SAFARI} from '../environment/userAgent'; import {logger} from '../lib/logger'; import VideoPlayer from '../lib/mediaPlayer'; import rootScope from '../lib/rootScope'; import animationIntersector from './animationIntersector'; import appMediaPlaybackController, {AppMediaPlaybackController} from './appMediaPlaybackController'; import AvatarElement from './avatar'; import ButtonIcon from './buttonIcon'; import {ButtonMenuItemOptions} from './buttonMenu'; import ButtonMenuToggle from './buttonMenuToggle'; import ProgressivePreloader from './preloader'; import SwipeHandler, {ZoomDetails} from './swipeHandler'; import {formatFullSentTime} from '../helpers/date'; import appNavigationController, {NavigationItem} from './appNavigationController'; import {Message, PhotoSize} from '../layer'; import findUpClassName from '../helpers/dom/findUpClassName'; import renderImageFromUrl, {renderImageFromUrlPromise} from '../helpers/dom/renderImageFromUrl'; import getVisibleRect from '../helpers/dom/getVisibleRect'; import cancelEvent from '../helpers/dom/cancelEvent'; import fillPropertyValue from '../helpers/fillPropertyValue'; import generatePathData from '../helpers/generatePathData'; import replaceContent from '../helpers/dom/replaceContent'; 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'; import {MyMessage} from '../lib/appManagers/appMessagesManager'; import {NULL_PEER_ID} from '../lib/mtproto/mtproto_config'; import {isFullScreen} from '../helpers/dom/fullScreen'; import {attachClickEvent, hasMouseMovedSinceDown} from '../helpers/dom/clickEvent'; import SearchListLoader from '../helpers/searchListLoader'; import createVideo from '../helpers/dom/createVideo'; import {AppManagers} from '../lib/appManagers/managers'; import getStrippedThumbIfNeeded from '../helpers/getStrippedThumbIfNeeded'; import setAttachmentSize from '../helpers/setAttachmentSize'; import wrapEmojiText from '../lib/richTextProcessor/wrapEmojiText'; import LazyLoadQueueBase from './lazyLoadQueueBase'; import overlayCounter from '../helpers/overlayCounter'; import appDownloadManager from '../lib/appManagers/appDownloadManager'; import wrapPeerTitle from './wrappers/peerTitle'; import {toastNew} from './toast'; import clamp from '../helpers/number/clamp'; import debounce from '../helpers/schedulers/debounce'; import isBetween from '../helpers/number/isBetween'; import findUpAsChild from '../helpers/dom/findUpAsChild'; import liteMode from '../helpers/liteMode'; const ZOOM_STEP = 0.5; const ZOOM_INITIAL_VALUE = 1; const ZOOM_MIN_VALUE = 0.5; const ZOOM_MAX_VALUE = 4; export const MEDIA_VIEWER_CLASSNAME = 'media-viewer'; type Transform = { x: number; y: number; scale: number; }; 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 log: ReturnType; protected isFirstOpen = true; protected pageEl = document.getElementById('page-chats') as HTMLDivElement; protected setMoverPromise: Promise; protected setMoverAnimationPromise: Promise; 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 transform: Transform = {x: 0, y: 0, scale: ZOOM_INITIAL_VALUE}; protected isZooming: boolean; protected isGesturingNow: boolean; protected isZoomingNow: boolean; protected draggingType: 'wheel' | 'touchmove' | 'mousemove'; protected initialContentRect: DOMRect; protected ctrlKeyDown: boolean; protected releaseSingleMedia: ReturnType; protected navigationItem: NavigationItem; protected managers: AppManagers; protected swipeHandler: SwipeHandler; protected closing: boolean; protected lastTransform: Transform = this.transform; protected lastZoomCenter: {x: number, y: number} = this.transform; protected lastDragOffset: {x: number, y: number} = this.transform; protected lastDragDelta: {x: number, y: number} = this.transform; protected lastGestureTime: number; protected clampZoomDebounced: ReturnType void>>; ignoreNextClick: boolean; get target() { return this.listLoader.current; } set target(value) { this.listLoader.current = value; } constructor( protected listLoader: ListLoader, topButtons: Array['buttons']> ) { super(false); this.managers = rootScope.managers; 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}); attachClickEvent(this.zoomElements.btnOut, () => this.addZoomStep(false)); this.zoomElements.btnIn = ButtonIcon('zoomin', {noRipple: true}); attachClickEvent(this.zoomElements.btnIn, () => this.addZoomStep(true)); this.zoomElements.rangeSelector = new RangeSelector({ step: 0.01, min: ZOOM_MIN_VALUE, max: ZOOM_MAX_VALUE, withTransition: true }, ZOOM_INITIAL_VALUE); this.zoomElements.rangeSelector.setListeners(); this.zoomElements.rangeSelector.setHandlers({ onScrub: (value) => { const add = value - this.transform.scale; this.addZoom(add); this.clampZoomDebounced?.clearTimeout(); }, onMouseDown: () => { this.moversContainer.classList.add('no-transition'); this.zoomElements.rangeSelector.container.classList.remove('with-transition'); }, onMouseUp: () => { this.moversContainer.classList.remove('no-transition'); this.zoomElements.rangeSelector.container.classList.add('with-transition'); this.setZoomValue(); } }); this.zoomElements.container.append(this.zoomElements.btnOut, this.zoomElements.rangeSelector.container, this.zoomElements.btnIn); if(!IS_TOUCH_SUPPORTED) { 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 = ``; this.buttons.next = document.createElement('div'); this.buttons.next.className = `${MEDIA_VIEWER_CLASSNAME}-switcher ${MEDIA_VIEWER_CLASSNAME}-switcher-right`; this.buttons.next.innerHTML = ``; this.moversContainer = document.createElement('div'); this.moversContainer.classList.add(MEDIA_VIEWER_CLASSNAME + '-movers'); this.moversContainer.append(this.buttons.prev, this.buttons.next); this.wholeDiv.append(this.overlaysDiv, /* this.buttons.prev, this.buttons.next, */ this.topbar, this.moversContainer); // * constructing html end this.listLoader.onLoadedMore = () => { this.buttons.prev.classList.toggle('hide', !this.listLoader.previous.length); this.buttons.next.classList.toggle('hide', !this.listLoader.next.length); }; this.setNewMover(); } protected setListeners() { attachClickEvent(this.buttons.download, this.onDownloadClick); [this.buttons.close, this.buttons['mobile-close'], this.preloaderStreamable.preloader].forEach((el) => { attachClickEvent(el, this.close.bind(this)); }); ([[-1, this.buttons.prev], [1, this.buttons.next]] as [number, HTMLElement][]).forEach(([moveLength, button]) => { // attachClickEvent(button, (e) => { button.addEventListener('click', (e) => { cancelEvent(e); if(this.setMoverPromise) return; this.listLoader.go(moveLength); }); }); attachClickEvent(this.buttons.zoom, () => { if(this.isZooming) this.resetZoom(); else { this.addZoomStep(true); } }); // ! cannot use the function because it'll cancel slide event on touch devices // attachClickEvent(this.wholeDiv, this.onClick); this.wholeDiv.addEventListener('click', this.onClick); this.listLoader.onJump = (item, older) => { if(older) this.onNextClick(item); else this.onPrevClick(item); }; const adjustPosition = (xDiff: number, yDiff: number) => { const [x, y] = [xDiff - this.lastDragOffset.x, yDiff - this.lastDragOffset.y]; const [transform, inBoundsX, inBoundsY] = this.calculateOffsetBoundaries({ x: this.transform.x + x, y: this.transform.y + y, scale: this.transform.scale }); this.lastDragDelta = { x, y }; this.lastDragOffset = { x: xDiff, y: yDiff }; this.setTransform(transform); return {inBoundsX, inBoundsY}; }; const setLastGestureTime = debounce(() => { this.lastGestureTime = Date.now(); }, 500, false, true); this.clampZoomDebounced = debounce(() => { this.onSwipeReset(); }, 300, false, true); this.swipeHandler = new SwipeHandler({ element: this.wholeDiv, onReset: this.onSwipeReset, onFirstSwipe: this.onSwipeFirst as any, onSwipe: (xDiff, yDiff, e, cancelDrag) => { if(isFullScreen()) { return; } if(this.isZooming && !this.isZoomingNow) { setLastGestureTime(); this.draggingType = e.type as any; const {inBoundsX, inBoundsY} = adjustPosition(xDiff, yDiff); cancelDrag?.(!inBoundsX, !inBoundsY); return; } if(this.isZoomingNow || !IS_TOUCH_SUPPORTED) { return; } const percents = Math.abs(xDiff) / windowSize.width; if(percents > .2 || Math.abs(xDiff) > 125) { if(xDiff > 0) { this.buttons.prev.click(); } else { this.buttons.next.click(); } return true; } const percentsY = Math.abs(yDiff) / windowSize.height; if(percentsY > .2 || Math.abs(yDiff) > 125) { this.close(); return true; } return false; }, onZoom: this.onZoom, onDoubleClick: ({centerX, centerY}) => { if(this.isZooming) { this.resetZoom(); } else { const scale = ZOOM_INITIAL_VALUE + 2; this.changeZoomByPosition(centerX, centerY, scale); } }, verifyTouchTarget: (e) => { // * Fix for seek input if(isFullScreen() || findUpAsChild(e.target as HTMLElement, this.zoomElements.container) || findUpClassName(e.target, 'ckin__controls') || findUpClassName(e.target, 'media-viewer-caption') || (findUpClassName(e.target, 'media-viewer-topbar') && e.type !== 'wheel')) { return false; } return true; }, cursor: '' // cursor: 'move' }); } protected onSwipeFirst = (e: MouseEvent | TouchEvent | WheelEvent) => { this.lastDragOffset = this.lastDragDelta = {x: 0, y: 0}; this.lastTransform = {...this.transform}; if(e.type !== 'wheel' || !this.ctrlKeyDown) { // keep transition for real mouse wheel this.moversContainer.classList.add('no-transition'); this.zoomElements.rangeSelector.container.classList.remove('with-transition'); } this.isGesturingNow = true; this.lastGestureTime = Date.now(); this.clampZoomDebounced.clearTimeout(); if(!this.lastTransform.x && !this.lastTransform.y && !this.isZooming) { this.initialContentRect = this.content.media.getBoundingClientRect(); } }; protected onSwipeReset = (e?: Event) => { // move this.moversContainer.classList.remove('no-transition'); this.zoomElements.rangeSelector.container.classList.add('with-transition'); this.clampZoomDebounced.clearTimeout(); if(e?.type === 'mouseup' && this.draggingType === 'mousemove') { this.ignoreNextClick = true; } const {draggingType} = this; this.isZoomingNow = false; this.isGesturingNow = false; this.draggingType = undefined; if(this.closing) { return; } if(this.transform.scale > ZOOM_INITIAL_VALUE) { // Get current content boundaries const s1 = Math.min(this.transform.scale, ZOOM_MAX_VALUE); const scaleFactor = s1 / this.transform.scale; // Calculate new position based on the last zoom center to keep the zoom center // at the same position when bouncing back from max zoom let x1 = this.transform.x * scaleFactor + (this.lastZoomCenter.x - scaleFactor * this.lastZoomCenter.x); let y1 = this.transform.y * scaleFactor + (this.lastZoomCenter.y - scaleFactor * this.lastZoomCenter.y); // If scale didn't change, we need to add inertia to pan gesture if(draggingType && draggingType !== 'wheel' && this.lastTransform.scale === this.transform.scale) { // Arbitrary pan velocity coefficient const k = 0.1; // Calculate user gesture velocity const elapsedTime = Math.max(1, Date.now() - this.lastGestureTime); const Vx = Math.abs(this.lastDragOffset.x) / elapsedTime; const Vy = Math.abs(this.lastDragOffset.y) / elapsedTime; // Add extra distance based on gesture velocity and last pan delta x1 -= Math.abs(this.lastDragOffset.x) * Vx * k * -this.lastDragDelta.x; y1 -= Math.abs(this.lastDragOffset.y) * Vy * k * -this.lastDragDelta.y; } const [transform] = this.calculateOffsetBoundaries({x: x1, y: y1, scale: s1}); this.lastTransform = transform; this.setTransform(transform); } else if(this.transform.scale < ZOOM_INITIAL_VALUE) { this.resetZoom(); } }; protected onZoom = ({ initialCenterX, initialCenterY, zoom, zoomAdd, currentCenterX, currentCenterY, dragOffsetX, dragOffsetY, zoomFactor }: ZoomDetails) => { initialCenterX ||= windowSize.width / 2; initialCenterY ||= windowSize.height / 2; currentCenterX ||= windowSize.width / 2; currentCenterY ||= windowSize.height / 2; this.isZoomingNow = true; const zoomMaxBounceValue = ZOOM_MAX_VALUE * 3; const scale = zoomAdd !== undefined ? clamp(this.lastTransform.scale + zoomAdd, ZOOM_MIN_VALUE, zoomMaxBounceValue) : (zoom ?? clamp(this.lastTransform.scale * zoomFactor, ZOOM_MIN_VALUE, zoomMaxBounceValue)); const scaleFactor = scale / this.lastTransform.scale; const offsetX = Math.abs(Math.min(this.lastTransform.x, 0)); const offsetY = Math.abs(Math.min(this.lastTransform.y, 0)); // Save last zoom center for bounce back effect this.lastZoomCenter = { x: currentCenterX, y: currentCenterY }; // Calculate new center relative to the shifted image const scaledCenterX = offsetX + initialCenterX; const scaledCenterY = offsetY + initialCenterY; const {scaleOffsetX, scaleOffsetY} = this.calculateScaleOffset({x: scaledCenterX, y: scaledCenterY, scale: scaleFactor}); const [transform] = this.calculateOffsetBoundaries({ x: this.lastTransform.x + scaleOffsetX + dragOffsetX, y: this.lastTransform.y + scaleOffsetY + dragOffsetY, scale }); this.setTransform(transform); }; protected changeZoomByPosition(x: number, y: number, scale: number) { const {scaleOffsetX, scaleOffsetY} = this.calculateScaleOffset({x, y, scale}); const transform = this.calculateOffsetBoundaries({ x: scaleOffsetX, y: scaleOffsetY, scale })[0]; this.setTransform(transform); } protected setTransform(transform: Transform) { this.transform = transform; this.changeZoom(transform.scale); } // Calculate how much we need to shift the image to keep the zoom center at the same position protected calculateScaleOffset({x, y, scale}: { x: number, y: number, scale: number }) { return { scaleOffsetX: x - scale * x, scaleOffsetY: y - scale * y }; } protected toggleZoom(enable?: boolean) { const isVisible = this.isZooming; const auto = enable === undefined; if(this.zoomElements.rangeSelector.mousedown || this.ctrlKeyDown) { enable = true; } enable ??= !isVisible; if(isVisible === enable) { return; } this.buttons.zoom.classList.toggle('zoom-in', !enable); this.zoomElements.container.classList.toggle('is-visible', this.isZooming = enable); this.wholeDiv.classList.toggle('is-zooming', enable); if(auto || !enable) { const zoomValue = enable ? this.transform.scale : ZOOM_INITIAL_VALUE; this.setZoomValue(zoomValue); this.zoomElements.rangeSelector.setProgress(zoomValue); } if(this.videoPlayer) { this.videoPlayer.lockControls(enable ? false : undefined); } } protected addZoomStep(add: boolean) { this.addZoom(ZOOM_STEP * (add ? 1 : -1)); } protected resetZoom() { this.setTransform({ x: 0, y: 0, scale: ZOOM_INITIAL_VALUE }); } protected changeZoom(value = this.transform.scale) { this.transform.scale = value; this.zoomElements.rangeSelector.setProgress(value); this.setZoomValue(value); } protected addZoom(value: number) { this.lastTransform = this.transform; this.onZoom({ zoomAdd: value, currentCenterX: 0, currentCenterY: 0, initialCenterX: 0, initialCenterY: 0, dragOffsetX: 0, dragOffsetY: 0 }); this.lastTransform = this.transform; this.clampZoomDebounced(); } protected getZoomBounce() { return this.isGesturingNow && IS_TOUCH_SUPPORTED ? 50 : 0; } protected calculateOffsetBoundaries = ( {x, y, scale}: Transform, offsetTop = 0 ): [Transform, boolean, boolean] => { if(!this.initialContentRect) return [{x, y, scale}, true, true]; // Get current content boundaries let inBoundsX = true; let inBoundsY = true; const {minX, maxX, minY, maxY} = this.getZoomBoundaries(scale, offsetTop); inBoundsX = isBetween(x, maxX, minX); x = clamp(x, maxX, minX); inBoundsY = isBetween(y, maxY, minY); y = clamp(y, maxY, minY); return [{x, y, scale}, inBoundsX, inBoundsY]; }; protected getZoomBoundaries(scale = this.transform.scale, offsetTop = 0) { if(!this.initialContentRect) { return {minX: 0, maxX: 0, minY: 0, maxY: 0}; } const centerX = (windowSize.width - windowSize.width * scale) / 2; const centerY = (windowSize.height - windowSize.height * scale) / 2; // If content is outside window we calculate offset boundaries // based on initial content rect and current scale const minX = Math.max(-this.initialContentRect.left * scale, centerX); const maxX = windowSize.width - this.initialContentRect.right * scale; const minY = Math.max(-this.initialContentRect.top * scale + offsetTop, centerY); const maxY = windowSize.height - this.initialContentRect.bottom * scale; return {minX, maxX, minY, maxY}; } protected setZoomValue = (value = this.transform.scale) => { this.initialContentRect ??= this.content.media.getBoundingClientRect(); // this.zoomValue = value; if(value === ZOOM_INITIAL_VALUE) { this.transform.x = 0; this.transform.y = 0; } this.moversContainer.style.transform = `translate3d(${this.transform.x.toFixed(3)}px, ${this.transform.y.toFixed(3)}px, 0px) scale(${value.toFixed(3)})`; 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 setBtnMenuToggle(buttons: ButtonMenuItemOptions[]) { const btnMenuToggle = ButtonMenuToggle({buttonOptions: {onlyMobile: true}, direction: 'bottom-left', buttons}); this.topbar.append(btnMenuToggle); } public close(e?: MouseEvent) { if(e) { cancelEvent(e); } if(this.setMoverAnimationPromise) return Promise.reject(); this.closing = true; this.swipeHandler?.removeListeners(); if(this.navigationItem) { appNavigationController.removeItem(this.navigationItem); } this.lazyLoadQueue.clear(); const promise = this.setMoverToTarget(this.target?.element, true).then(({onAnimationEnd}) => onAnimationEnd); this.listLoader.reset(); (this.listLoader as SearchListLoader).cleanup && (this.listLoader as SearchListLoader).cleanup(); this.setMoverPromise = null; this.tempId = -1; if((window as any).appMediaViewer === this) { (window as any).appMediaViewer = undefined; } /* if(appSidebarRight.historyTabIDs.slice(-1)[0] === AppSidebarRight.SLIDERITEMSIDS.forward) { promise.then(() => { appSidebarRight.forwardTab.closeBtn.click(); }); } */ this.removeGlobalListeners(); promise.finally(() => { this.wholeDiv.remove(); this.toggleOverlay(false); }); return promise; } protected toggleOverlay(active: boolean) { overlayCounter.isOverlayActive = active; animationIntersector.checkAnimations2(active); } protected toggleGlobalListeners(active: boolean) { if(active) this.setGlobalListeners(); else this.removeGlobalListeners(); } protected removeGlobalListeners() { window.removeEventListener('keydown', this.onKeyDown); window.removeEventListener('keyup', this.onKeyUp); } protected setGlobalListeners() { window.addEventListener('keydown', this.onKeyDown); window.addEventListener('keyup', this.onKeyUp); } onClick = (e: MouseEvent) => { if(this.ignoreNextClick) { this.ignoreNextClick = undefined; return; } 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; } if(hasMouseMovedSinceDown(e)) { return; } const isZooming = this.isZooming && false; 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.close(); } }; private onKeyDown = (e: KeyboardEvent) => { // this.log('onKeyDown', e); if(overlayCounter.overlaysActive > 1) { return; } const key = e.key; let good = true; if(key === 'ArrowRight') { !this.isZooming && this.buttons.next.click(); } else if(key === 'ArrowLeft') { !this.isZooming && this.buttons.prev.click(); } else if(key === '-' || key === '=') { if(this.ctrlKeyDown) { this.addZoomStep(key === '='); } } else { good = false; } if(e.ctrlKey || e.metaKey) { this.ctrlKeyDown = true; } if(good) { cancelEvent(e); } }; private onKeyUp = (e: KeyboardEvent) => { if(overlayCounter.overlaysActive > 1) { return; } if(!(e.ctrlKey || e.metaKey)) { this.ctrlKeyDown = false; if(this.isZooming) { this.setZoomValue(); } } }; 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.transform.scale : ZOOM_INITIAL_VALUE; /* if(!(zoomValue > 1 && closing)) */ this.removeCenterFromMover(mover); const wasActive = fromRight !== 0; const delay = liteMode.isAvailable('animations') ? (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, true); 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.width : -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 left = rect.left - (windowSize.width * scaleX - rect.width) / 2; const top = rect.top - (windowSize.height * scaleY - 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(); 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 | HTMLCanvasElement; let src: string; // if(target instanceof HTMLVideoElement) { const selector = 'video, img, .canvas-thumbnail'; const queryFrom = target.matches(selector) ? target.parentElement : target; const elements = Array.from(queryFrom.querySelectorAll(selector)) as HTMLImageElement[]; if(elements.length) { target = elements.pop(); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if(target instanceof HTMLImageElement) { canvas.width = target.naturalWidth; canvas.height = target.naturalHeight; } else if(target instanceof HTMLVideoElement) { canvas.width = target.videoWidth; canvas.height = target.videoHeight; } else if(target instanceof HTMLCanvasElement) { canvas.width = target.width; canvas.height = target.height; } canvas.className = 'canvas-thumbnail thumbnail media-photo'; context.drawImage(target as HTMLImageElement | HTMLCanvasElement, 0, 0); target = canvas; } // } 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 = createVideo(); 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); } else if(target instanceof HTMLCanvasElement) { mediaElement = target; } 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.toggleWholeActive(false); // 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 toggleWholeActive(active: boolean) { if(active) { this.wholeDiv.classList.add('active'); } else { this.wholeDiv.classList.add('backwards'); setTimeout(() => { this.wholeDiv.classList.remove('active'); }, 0); } } 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.width; 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 && !findUpClassName(target, 'document')) { if(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(); } } if((el as HTMLImageElement).src !== url) { renderImageFromUrl(el, url); } // ! костыль, но он тут даже и не нужен if(el.classList.contains('thumbnail') && el.parentElement.classList.contains('media-container-aspecter')) { el.classList.remove('thumbnail'); } } /* } else { } */ } protected setAuthorInfo(fromId: PeerId | string, timestamp: number) { const isPeerId = fromId.isPeerId(); let wrapTitlePromise: Promise | HTMLElement; if(isPeerId) { wrapTitlePromise = wrapPeerTitle({ peerId: fromId as PeerId, dialog: false, onlyFirstName: false, plainText: false }) } else { const title = wrapTitlePromise = document.createElement('span'); title.append(wrapEmojiText(fromId)); title.classList.add('peer-title'); } const oldAvatar = this.author.avatarEl; const newAvatar = this.author.avatarEl = (oldAvatar.cloneNode() as AvatarElement); return Promise.all([ (this.author.avatarEl as AvatarElement).updateWithOptions({ peerId: fromId as PeerId || NULL_PEER_ID, peerTitle: isPeerId ? undefined : '' + fromId }), wrapTitlePromise ]).then(([_, title]) => { if(this.author.avatarEl !== newAvatar) { return; } replaceContent(this.author.date, formatFullSentTime(timestamp)); replaceContent(this.author.nameEl, title); oldAvatar.replaceWith(this.author.avatarEl); }); } protected async _openMedia( media: MyDocument | MyPhoto, timestamp: number, fromId: PeerId | string, fromRight: number, target?: HTMLElement, reverse = false, prevTargets: TargetType[] = [], nextTargets: TargetType[] = [], message?: MyMessage /* , needLoadMore = true */ ) { if(this.setMoverPromise) return this.setMoverPromise; /* if(DEBUG) { this.log('openMedia:', media, fromId, prevTargets, nextTargets); } */ const setAuthorPromise = 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.listLoader.next.length < 10) { setTimeout(() => { this.listLoader.load(true); }, 0); } // 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 { this.navigationItem = { type: 'media', onPop: (canAnimate) => { if(this.setMoverAnimationPromise) { return false; } if(!canAnimate && IS_MOBILE_SAFARI) { this.wholeDiv.remove(); } this.close(); } }; appNavigationController.pushItem(this.navigationItem); this.toggleOverlay(true); this.setGlobalListeners(); await setAuthorPromise; if(!this.wholeDiv.parentElement) { this.pageEl.insertBefore(this.wholeDiv, document.getElementById('main-columns')); void this.wholeDiv.offsetLeft; // reflow } this.toggleWholeActive(true); } // //////this.log('wasActive:', wasActive); const mover = this.content.mover; const maxWidth = windowSize.width; // const maxWidth = this.pageEl.scrollWidth; // TODO: const maxHeight = mediaSizes.isMobile ? appPhotosManager.windowH : appPhotosManager.windowH - 100; let padding = 0; const windowH = windowSize.height; if(windowH < 1000000 && !mediaSizes.isMobile) { padding = 120; } const maxHeight = windowH - 120 - padding; let thumbPromise: Promise = Promise.resolve(); const size = setAttachmentSize(media, container, maxWidth, maxHeight, mediaSizes.isMobile ? false : true, undefined, !!(isDocument && media.w && media.h)).photoSize; if(useContainerAsTarget) { const cacheContext = await this.managers.thumbsStorage.getCacheContext(media, size.type); let img: HTMLImageElement | HTMLCanvasElement; if(cacheContext.downloaded) { img = new Image(); img.src = cacheContext.url; } else { const gotThumb = 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; const getCacheContext = (type = size?.type) => { return this.managers.thumbsStorage.getCacheContext(media, type); }; let setMoverPromise: Promise; 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 : */createVideo({pip: useController}); if(this.wholeDiv.classList.contains('no-forwards')) { video.addEventListener('contextmenu', cancelEvent); } 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(); } }); 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; } else if(media.duration < 60) { 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, streamable: supportsStreaming, onPlaybackRackMenuToggle: (open) => { this.wholeDiv.classList.toggle('hide-caption', !!open); }, onPip: (pip) => { const otherMediaViewer = (window as any).appMediaViewer; if(!pip && otherMediaViewer && otherMediaViewer !== this) { this.releaseSingleMedia = undefined; this.close(); return; } const mover = this.moversContainer.lastElementChild as HTMLElement; mover.classList.toggle('hiding', pip); this.toggleWholeActive(!pip); this.toggleOverlay(!pip); this.toggleGlobalListeners(!pip); if(this.navigationItem) { if(pip) appNavigationController.removeItem(this.navigationItem); else appNavigationController.pushItem(this.navigationItem); } if(useController) { if(pip) { // appMediaPlaybackController.toggleSwitchers(true); this.releaseSingleMedia(false); this.releaseSingleMedia = undefined; appMediaPlaybackController.setPictureInPicture(video); } else { this.releaseSingleMedia = appMediaPlaybackController.setSingleMedia(video, message as Message.message); } } }, onPipClose: () => { // this.target = undefined; // this.toggleWholeActive(false); // this.toggleOverlay(false); this.close(); } }); player.addEventListener('toggleControls', (show) => { this.wholeDiv.classList.toggle('has-video-controls', show); }); this.addEventListener('setMoverBefore', () => { this.wholeDiv.classList.remove('has-video-controls'); this.videoPlayer.cleanup(); 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) { // console.log('ppp 1'); preloader.attach(mover, true); } /* canPlayThrough.then(() => { preloader.detach(); }); */ }); const attachCanPlay = () => { video.addEventListener('canplay', () => { // console.log('ppp 2'); 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(); // console.log('ppp 3'); preloader.attach(mover, true); // поставлю класс для плеера, чтобы убрать большую иконку пока прелоадер на месте video.parentElement.classList.add('is-buffering'); } }); if(this.wholeDiv.classList.contains('no-forwards')) { video.addEventListener('contextmenu', (e) => { cancelEvent(e); }); } attachCanPlay(); } // if(!video.src || media.url !== video.src) { const load = async() => { /* if(useController) { appMediaPlaybackController.resolveWaitingForLoadMedia(message.peerId, message.mid, message.pFlags.is_scheduled); } */ const promise: Promise = supportsStreaming ? Promise.resolve() : appDownloadManager.downloadMediaURL({media}); if(!supportsStreaming) { onAnimationEnd.then(async() => { if(!(await getCacheContext()).url) { // console.log('ppp 4'); preloader.attach(mover, true, promise); } }); } Promise.all([promise, onAnimationEnd]).then(async() => { if(this.tempId !== tempId) { this.log.warn('media viewer changed video'); return; } const url = (await getCacheContext()).url; video.addEventListener('error', () => { toastNew({ langPackKey: IS_MOBILE ? 'Video.Unsupported.Mobile' : 'Video.Unsupported.Desktop' }); if(video.error.code !== 4) { this.log.error('Error ' + video.error.code + '; details: ' + video.error.message); } preloader?.detach(); }, {once: true}); if(target instanceof SVGSVGElement/* && (video.parentElement || !isSafari) */) { // if video exists // if(!video.parentElement) { div.firstElementChild.lastElementChild.append(video); // } } else { renderImageFromUrl(video, url); } // * have to set options (especially playbackRate) after src // * https://github.com/videojs/video.js/issues/2516 if(useController) { this.releaseSingleMedia = appMediaPlaybackController.setSingleMedia(video, message as Message.message); this.addEventListener('setMoverBefore', () => { if(this.releaseSingleMedia) { this.releaseSingleMedia(); this.releaseSingleMedia = undefined; } }, {once: true}); } 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 = async() => { const cancellablePromise = isDocument ? appDownloadManager.downloadMediaURL({media}) : appDownloadManager.downloadMediaURL({media, thumb: size}); const photoSizes = !isDocument && media.sizes.slice().filter((size) => (size as PhotoSize.photoSize).w) as PhotoSize.photoSize[]; photoSizes && photoSizes.sort((a, b) => b.size - a.size); const fullPhotoSize = photoSizes?.[0]; const cancellableFullPromise = !isDocument && fullPhotoSize !== size && appDownloadManager.downloadMediaURL({media, thumb: fullPhotoSize}); onAnimationEnd.then(async() => { if(!(await getCacheContext()).url) { this.preloader.attachPromise(cancellablePromise); // this.preloader.attach(mover, true, cancellablePromise); } }); Promise.all([onAnimationEnd, cancellablePromise]).then(async() => { if(this.tempId !== tempId) { this.log.warn('media viewer changed photo'); return; } const url = (await getCacheContext()).url; if(target instanceof SVGSVGElement) { this.updateMediaSource(target, url, 'img'); this.updateMediaSource(mover, url, 'img'); if(mediaSizes.isMobile) { const imgs = mover.querySelectorAll('img'); imgs.forEach((img) => { img.classList.remove('thumbnail'); // может здесь это вообще не нужно }); } } else { const div = mover.firstElementChild && mover.firstElementChild.classList.contains('media-viewer-aspecter') ? mover.firstElementChild : mover; const haveImage = ['CANVAS', 'IMG'].includes(div.firstElementChild?.tagName) ? div.firstElementChild as HTMLElement : null; if((haveImage as HTMLImageElement)?.src !== url) { const image = new Image(); image.classList.add('thumbnail'); renderImageFromUrl(image, url, () => { fastRaf(() => { this.updateMediaSource(target, url, 'img'); if(haveImage) { fastRaf(() => { haveImage.remove(); }); } div.append(image); }); }, false); cancellableFullPromise && cancellableFullPromise.then((url) => { const fullImage = new Image(); fullImage.classList.add('thumbnail'); renderImageFromUrl(fullImage, url, () => { fastRaf(() => { image.replaceWith(fullImage); }); }, false); }); } } // 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; }); } }