606 lines
18 KiB
TypeScript
606 lines
18 KiB
TypeScript
/*
|
|
* https://github.com/morethanwords/tweb
|
|
* Copyright (C) 2019-2021 Eduard Kuzmenko
|
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
|
*/
|
|
|
|
import {IS_SAFARI} from '../../environment/userAgent';
|
|
import {animateSingle} from '../../helpers/animation';
|
|
import {ChatAutoDownloadSettings} from '../../helpers/autoDownload';
|
|
import deferredPromise from '../../helpers/cancellablePromise';
|
|
import cancelEvent from '../../helpers/dom/cancelEvent';
|
|
import {attachClickEvent} from '../../helpers/dom/clickEvent';
|
|
import createVideo from '../../helpers/dom/createVideo';
|
|
import isInDOM from '../../helpers/dom/isInDOM';
|
|
import renderImageFromUrl from '../../helpers/dom/renderImageFromUrl';
|
|
import getStrippedThumbIfNeeded from '../../helpers/getStrippedThumbIfNeeded';
|
|
import liteMode from '../../helpers/liteMode';
|
|
import makeError from '../../helpers/makeError';
|
|
import mediaSizes, {ScreenSize} from '../../helpers/mediaSizes';
|
|
import {Middleware} from '../../helpers/middleware';
|
|
import noop from '../../helpers/noop';
|
|
import onMediaLoad from '../../helpers/onMediaLoad';
|
|
import {fastRaf} from '../../helpers/schedulers';
|
|
import throttle from '../../helpers/schedulers/throttle';
|
|
import sequentialDom from '../../helpers/sequentialDom';
|
|
import toHHMMSS from '../../helpers/string/toHHMMSS';
|
|
import {Message, PhotoSize, VideoSize} from '../../layer';
|
|
import {MyDocument} from '../../lib/appManagers/appDocsManager';
|
|
import appDownloadManager from '../../lib/appManagers/appDownloadManager';
|
|
import appImManager from '../../lib/appManagers/appImManager';
|
|
import {AppManagers} from '../../lib/appManagers/managers';
|
|
import {NULL_PEER_ID} from '../../lib/mtproto/mtproto_config';
|
|
import rootScope from '../../lib/rootScope';
|
|
import {ThumbCache} from '../../lib/storages/thumbs';
|
|
import animationIntersector, {AnimationItemGroup} from '../animationIntersector';
|
|
import appMediaPlaybackController, {MediaSearchContext} from '../appMediaPlaybackController';
|
|
import {findMediaTargets} from '../audio';
|
|
import LazyLoadQueue from '../lazyLoadQueue';
|
|
import ProgressivePreloader from '../preloader';
|
|
import wrapPhoto from './photo';
|
|
|
|
const MAX_VIDEO_AUTOPLAY_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
|
|
let roundVideoCircumference = 0;
|
|
mediaSizes.addEventListener('changeScreen', (from, to) => {
|
|
if(to === ScreenSize.mobile || from === ScreenSize.mobile) {
|
|
const elements = Array.from(document.querySelectorAll('.media-round .progress-ring')) as SVGSVGElement[];
|
|
const width = mediaSizes.active.round.width;
|
|
const halfSize = width / 2;
|
|
const radius = halfSize - 7;
|
|
roundVideoCircumference = 2 * Math.PI * radius;
|
|
elements.forEach((element) => {
|
|
element.setAttributeNS(null, 'width', '' + width);
|
|
element.setAttributeNS(null, 'height', '' + width);
|
|
|
|
const circle = element.firstElementChild as SVGCircleElement;
|
|
circle.setAttributeNS(null, 'cx', '' + halfSize);
|
|
circle.setAttributeNS(null, 'cy', '' + halfSize);
|
|
circle.setAttributeNS(null, 'r', '' + radius);
|
|
|
|
circle.style.strokeDasharray = roundVideoCircumference + ' ' + roundVideoCircumference;
|
|
circle.style.strokeDashoffset = '' + roundVideoCircumference;
|
|
});
|
|
}
|
|
});
|
|
|
|
export default async function wrapVideo({doc, container, message, boxWidth, boxHeight, withTail, isOut, middleware, lazyLoadQueue, noInfo, group, onlyPreview, noPreview, withoutPreloader, loadPromises, noPlayButton, photoSize, videoSize, searchContext, autoDownload, managers = rootScope.managers, noAutoplayAttribute}: {
|
|
doc: MyDocument,
|
|
container?: HTMLElement,
|
|
message?: Message.message,
|
|
boxWidth?: number,
|
|
boxHeight?: number,
|
|
withTail?: boolean,
|
|
isOut?: boolean,
|
|
middleware?: Middleware,
|
|
lazyLoadQueue?: LazyLoadQueue,
|
|
noInfo?: boolean,
|
|
noPlayButton?: boolean,
|
|
group?: AnimationItemGroup,
|
|
onlyPreview?: boolean,
|
|
noPreview?: boolean,
|
|
withoutPreloader?: boolean,
|
|
loadPromises?: Promise<any>[],
|
|
autoDownload?: ChatAutoDownloadSettings,
|
|
photoSize?: PhotoSize,
|
|
videoSize?: Extract<VideoSize, VideoSize.videoSize>,
|
|
searchContext?: MediaSearchContext,
|
|
managers?: AppManagers,
|
|
noAutoplayAttribute?: boolean
|
|
}) {
|
|
const autoDownloadSize = autoDownload?.video;
|
|
let noAutoDownload = autoDownloadSize === 0;
|
|
const isAlbumItem = !(boxWidth && boxHeight);
|
|
const canAutoplay = /* doc.sticker || */(
|
|
(
|
|
doc.type !== 'video' || (
|
|
doc.size <= MAX_VIDEO_AUTOPLAY_SIZE &&
|
|
!isAlbumItem
|
|
)
|
|
) && (doc.type === 'gif' ? liteMode.isAvailable('gif') : liteMode.isAvailable('video'))
|
|
);
|
|
let spanTime: HTMLElement, spanPlay: HTMLElement;
|
|
|
|
if(!noInfo) {
|
|
spanTime = document.createElement('span');
|
|
spanTime.classList.add('video-time');
|
|
container.append(spanTime);
|
|
|
|
let needPlayButton = false;
|
|
if(doc.type !== 'gif') {
|
|
spanTime.innerText = toHHMMSS(doc.duration, false);
|
|
|
|
if(!noPlayButton && doc.type !== 'round') {
|
|
if(canAutoplay && !noAutoDownload) {
|
|
spanTime.classList.add('tgico', 'can-autoplay');
|
|
} else {
|
|
needPlayButton = true;
|
|
}
|
|
}
|
|
} else {
|
|
spanTime.innerText = 'GIF';
|
|
|
|
if(!canAutoplay && !noPlayButton) {
|
|
needPlayButton = true;
|
|
noAutoDownload = undefined;
|
|
}
|
|
}
|
|
|
|
if(needPlayButton) {
|
|
spanPlay = document.createElement('span');
|
|
spanPlay.classList.add('video-play', 'tgico-largeplay', 'btn-circle', 'position-center');
|
|
container.append(spanPlay);
|
|
}
|
|
}
|
|
|
|
const res: {
|
|
thumb?: typeof photoRes,
|
|
loadPromise: Promise<any>
|
|
} = {} as any;
|
|
|
|
if(doc.mime_type === 'image/gif') {
|
|
const photoRes = await wrapPhoto({
|
|
photo: doc,
|
|
message,
|
|
container,
|
|
boxWidth,
|
|
boxHeight,
|
|
withTail,
|
|
isOut,
|
|
lazyLoadQueue,
|
|
middleware,
|
|
withoutPreloader,
|
|
loadPromises,
|
|
autoDownloadSize,
|
|
size: photoSize,
|
|
managers
|
|
});
|
|
|
|
res.thumb = photoRes;
|
|
res.loadPromise = photoRes.loadPromises.full;
|
|
return res;
|
|
}
|
|
|
|
/* const video = doc.type === 'round' ? appMediaPlaybackController.addMedia(doc, message.mid) as HTMLVideoElement : document.createElement('video');
|
|
if(video.parentElement) {
|
|
video.remove();
|
|
} */
|
|
|
|
let preloader: ProgressivePreloader; // it must be here, otherwise will get error before initialization in round onPlay
|
|
|
|
const video = createVideo();
|
|
video.classList.add('media-video');
|
|
video.muted = true;
|
|
if(doc.type === 'round') {
|
|
const divRound = document.createElement('div');
|
|
divRound.classList.add('media-round', 'z-depth-1');
|
|
divRound.dataset.mid = '' + message.mid;
|
|
divRound.dataset.peerId = '' + message.peerId;
|
|
(divRound as any).message = message;
|
|
|
|
const size = mediaSizes.active.round;
|
|
const halfSize = size.width / 2;
|
|
const strokeWidth = 3.5;
|
|
const radius = halfSize - (strokeWidth * 2);
|
|
divRound.innerHTML = `<svg class="progress-ring" width="${size.width}" height="${size.width}" style="transform: rotate(-90deg);">
|
|
<circle class="progress-ring__circle" stroke="white" stroke-opacity="0.3" stroke-width="${strokeWidth}" cx="${halfSize}" cy="${halfSize}" r="${radius}" fill="transparent"/>
|
|
</svg>`;
|
|
|
|
const circle = divRound.firstElementChild.firstElementChild as SVGCircleElement;
|
|
if(!roundVideoCircumference) {
|
|
roundVideoCircumference = 2 * Math.PI * radius;
|
|
}
|
|
circle.style.strokeDasharray = roundVideoCircumference + ' ' + roundVideoCircumference;
|
|
circle.style.strokeDashoffset = '' + roundVideoCircumference;
|
|
|
|
spanTime.classList.add('tgico');
|
|
|
|
const isUnread = message.pFlags.media_unread;
|
|
if(isUnread) {
|
|
divRound.classList.add('is-unread');
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = canvas.height = doc.w/* * window.devicePixelRatio */;
|
|
|
|
divRound.prepend(canvas, spanTime);
|
|
divRound.append(video);
|
|
container.append(divRound);
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
/* ctx.beginPath();
|
|
ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2);
|
|
ctx.clip(); */
|
|
|
|
const onLoad = () => {
|
|
const message: Message.message = (divRound as any).message;
|
|
const globalVideo = appMediaPlaybackController.addMedia(message, !noAutoDownload) as HTMLVideoElement;
|
|
const clear = () => {
|
|
(appImManager.chat.setPeerPromise || Promise.resolve()).finally(() => {
|
|
if(isInDOM(globalVideo)) {
|
|
return;
|
|
}
|
|
|
|
globalVideo.removeEventListener('play', onPlay);
|
|
globalVideo.removeEventListener('timeupdate', throttledTimeUpdate);
|
|
globalVideo.removeEventListener('pause', onPaused);
|
|
globalVideo.removeEventListener('ended', onEnded);
|
|
});
|
|
};
|
|
|
|
const onFrame = () => {
|
|
ctx.drawImage(globalVideo, 0, 0);
|
|
|
|
const offset = roundVideoCircumference - globalVideo.currentTime / globalVideo.duration * roundVideoCircumference;
|
|
circle.style.strokeDashoffset = '' + offset;
|
|
|
|
return !globalVideo.paused;
|
|
};
|
|
|
|
const onTimeUpdate = () => {
|
|
if(!globalVideo.duration) {
|
|
return;
|
|
}
|
|
|
|
if(!isInDOM(globalVideo)) {
|
|
clear();
|
|
return;
|
|
}
|
|
|
|
if(globalVideo.paused) {
|
|
onFrame();
|
|
}
|
|
|
|
spanTime.innerText = toHHMMSS(globalVideo.duration - globalVideo.currentTime, false);
|
|
};
|
|
|
|
const throttledTimeUpdate = throttle(() => {
|
|
fastRaf(onTimeUpdate);
|
|
}, 1000, false);
|
|
|
|
const onPlay = () => {
|
|
video.classList.add('hide');
|
|
divRound.classList.remove('is-paused');
|
|
animateSingle(onFrame, canvas);
|
|
|
|
if(preloader && preloader.preloader && preloader.preloader.classList.contains('manual')) {
|
|
preloader.onClick();
|
|
}
|
|
};
|
|
|
|
const onPaused = () => {
|
|
if(!isInDOM(globalVideo)) {
|
|
clear();
|
|
return;
|
|
}
|
|
|
|
divRound.classList.add('is-paused');
|
|
};
|
|
|
|
const onEnded = () => {
|
|
video.classList.remove('hide');
|
|
divRound.classList.add('is-paused');
|
|
|
|
video.currentTime = 0;
|
|
spanTime.innerText = toHHMMSS(globalVideo.duration, false);
|
|
|
|
if(globalVideo.currentTime) {
|
|
globalVideo.currentTime = 0;
|
|
}
|
|
};
|
|
|
|
globalVideo.addEventListener('play', onPlay);
|
|
globalVideo.addEventListener('timeupdate', throttledTimeUpdate);
|
|
globalVideo.addEventListener('pause', onPaused);
|
|
globalVideo.addEventListener('ended', onEnded);
|
|
|
|
attachClickEvent(canvas, (e) => {
|
|
cancelEvent(e);
|
|
|
|
// ! костыль
|
|
if(preloader && !preloader.detached) {
|
|
preloader.onClick();
|
|
}
|
|
|
|
// ! can't use it here. on Safari iOS video won't start.
|
|
/* if(globalVideo.readyState < 2) {
|
|
return;
|
|
} */
|
|
|
|
if(globalVideo.paused) {
|
|
const hadSearchContext = !!searchContext;
|
|
if(appMediaPlaybackController.setSearchContext(searchContext || {
|
|
peerId: NULL_PEER_ID,
|
|
inputFilter: {_: 'inputMessagesFilterEmpty'},
|
|
useSearch: false
|
|
})) {
|
|
const [prev, next] = !hadSearchContext ? [] : findMediaTargets(divRound, message.mid/* , searchContext.useSearch */);
|
|
appMediaPlaybackController.setTargets({peerId: message.peerId, mid: message.mid}, prev, next);
|
|
}
|
|
|
|
globalVideo.play();
|
|
} else {
|
|
globalVideo.pause();
|
|
}
|
|
});
|
|
|
|
if(globalVideo.paused) {
|
|
if(globalVideo.duration && globalVideo.currentTime !== globalVideo.duration && globalVideo.currentTime > 0) {
|
|
onFrame();
|
|
onTimeUpdate();
|
|
video.classList.add('hide');
|
|
} else {
|
|
onPaused();
|
|
}
|
|
} else {
|
|
onPlay();
|
|
}
|
|
};
|
|
|
|
if(message.pFlags.is_outgoing) {
|
|
(divRound as any).onLoad = onLoad;
|
|
divRound.dataset.isOutgoing = '1';
|
|
} else {
|
|
onLoad();
|
|
}
|
|
} else if(!noAutoplayAttribute) {
|
|
video.autoplay = true; // для safari
|
|
}
|
|
|
|
let photoRes: Awaited<ReturnType<typeof wrapPhoto>>;
|
|
if(message) {
|
|
photoRes = await wrapPhoto({
|
|
photo: doc,
|
|
message,
|
|
container,
|
|
boxWidth,
|
|
boxHeight,
|
|
withTail,
|
|
isOut,
|
|
lazyLoadQueue,
|
|
middleware,
|
|
withoutPreloader: true,
|
|
loadPromises,
|
|
autoDownloadSize: autoDownload?.photo,
|
|
size: photoSize,
|
|
managers
|
|
});
|
|
|
|
res.thumb = photoRes;
|
|
|
|
if((!canAutoplay && doc.type !== 'gif') || onlyPreview) {
|
|
res.loadPromise = photoRes.loadPromises.full;
|
|
return res;
|
|
}
|
|
|
|
if(withTail) {
|
|
const foreignObject = (photoRes.images.thumb || photoRes.images.full).parentElement;
|
|
video.width = +foreignObject.getAttributeNS(null, 'width');
|
|
video.height = +foreignObject.getAttributeNS(null, 'height');
|
|
foreignObject.append(video);
|
|
}
|
|
} else if(!noPreview) { // * gifs masonry
|
|
const gotThumb = getStrippedThumbIfNeeded(doc, {} as ThumbCache, true);
|
|
if(gotThumb) {
|
|
const thumbImage = gotThumb.image;
|
|
thumbImage.classList.add('media-poster');
|
|
container.append(thumbImage);
|
|
res.thumb = {
|
|
loadPromises: {
|
|
thumb: gotThumb.loadPromise,
|
|
full: Promise.resolve()
|
|
},
|
|
images: {
|
|
thumb: thumbImage,
|
|
full: null
|
|
},
|
|
preloader: null,
|
|
aspecter: null
|
|
};
|
|
|
|
loadPromises?.push(gotThumb.loadPromise);
|
|
res.loadPromise = gotThumb.loadPromise;
|
|
}
|
|
}
|
|
|
|
if(onlyPreview) {
|
|
return res;
|
|
}
|
|
|
|
if(!video.parentElement && container) {
|
|
(photoRes?.aspecter || container).append(video);
|
|
}
|
|
|
|
let cacheContext: ThumbCache;
|
|
const getCacheContext = async() => {
|
|
return cacheContext = await managers.thumbsStorage.getCacheContext(doc, videoSize?.type);
|
|
};
|
|
|
|
await getCacheContext();
|
|
|
|
const uploadFileName = message?.uploadingFileName;
|
|
if(uploadFileName) { // means upload
|
|
preloader = new ProgressivePreloader({
|
|
attachMethod: 'prepend',
|
|
isUpload: true
|
|
});
|
|
preloader.attachPromise(appDownloadManager.getUpload(uploadFileName));
|
|
preloader.attach(container, false);
|
|
noAutoDownload = undefined;
|
|
} else if(!cacheContext.downloaded && !doc.supportsStreaming && !withoutPreloader) {
|
|
preloader = new ProgressivePreloader({
|
|
attachMethod: 'prepend'
|
|
});
|
|
} else if(doc.supportsStreaming) {
|
|
preloader = new ProgressivePreloader({
|
|
cancelable: false,
|
|
attachMethod: 'prepend'
|
|
});
|
|
}
|
|
|
|
const renderDeferred = deferredPromise<void>();
|
|
video.addEventListener('error', (e) => {
|
|
if(video.error.code !== 4) {
|
|
console.error('Error ' + video.error.code + '; details: ' + video.error.message);
|
|
}
|
|
|
|
if(preloader && !uploadFileName) {
|
|
preloader.detach();
|
|
}
|
|
|
|
if(!renderDeferred.isFulfilled) {
|
|
renderDeferred.resolve();
|
|
}
|
|
}, {once: true});
|
|
|
|
if(doc.type === 'video') {
|
|
const onTimeUpdate = () => {
|
|
if(!video.duration) {
|
|
return;
|
|
}
|
|
|
|
spanTime.innerText = toHHMMSS(video.duration - video.currentTime, false);
|
|
};
|
|
|
|
const throttledTimeUpdate = throttle(() => {
|
|
fastRaf(onTimeUpdate);
|
|
}, 1e3, false);
|
|
|
|
video.addEventListener('timeupdate', throttledTimeUpdate);
|
|
|
|
if(spanPlay) {
|
|
video.addEventListener('timeupdate', () => {
|
|
sequentialDom.mutateElement(spanPlay, () => {
|
|
spanPlay.remove();
|
|
});
|
|
}, {once: true});
|
|
}
|
|
}
|
|
|
|
video.muted = true;
|
|
video.loop = true;
|
|
// video.play();
|
|
if(!noAutoplayAttribute) {
|
|
video.autoplay = true;
|
|
}
|
|
|
|
let loadPhotoThumbFunc = noAutoDownload && photoRes?.preloader?.loadFunc;
|
|
const load = async() => {
|
|
if(preloader && noAutoDownload && !withoutPreloader) {
|
|
preloader.construct();
|
|
preloader.setManual();
|
|
}
|
|
|
|
await getCacheContext();
|
|
let loadPromise: Promise<any> = Promise.resolve();
|
|
if((preloader && !uploadFileName) || withoutPreloader) {
|
|
if(!cacheContext.downloaded && !doc.supportsStreaming) {
|
|
const promise = loadPromise = appDownloadManager.downloadMediaURL({
|
|
media: doc,
|
|
queueId: lazyLoadQueue?.queueId,
|
|
onlyCache: noAutoDownload,
|
|
thumb: videoSize
|
|
});
|
|
|
|
if(preloader) {
|
|
preloader.attach(container, false, promise);
|
|
}
|
|
} else if(doc.supportsStreaming) {
|
|
if(noAutoDownload) {
|
|
loadPromise = Promise.reject(makeError('NO_AUTO_DOWNLOAD'));
|
|
} else if(!cacheContext.downloaded && preloader) { // * check for uploading video
|
|
preloader.attach(container, false, null);
|
|
video.addEventListener(IS_SAFARI ? 'timeupdate' : 'canplay', () => {
|
|
preloader.detach();
|
|
}, {once: true});
|
|
}
|
|
}
|
|
}
|
|
|
|
if(!noAutoDownload && loadPhotoThumbFunc) {
|
|
loadPhotoThumbFunc();
|
|
loadPhotoThumbFunc = null;
|
|
}
|
|
|
|
noAutoDownload = undefined;
|
|
|
|
loadPromise.then(async() => {
|
|
if(middleware && !middleware()) {
|
|
renderDeferred.resolve();
|
|
return;
|
|
}
|
|
|
|
if(doc.type === 'round') {
|
|
appMediaPlaybackController.resolveWaitingForLoadMedia(message.peerId, message.mid, message.pFlags.is_scheduled);
|
|
}
|
|
|
|
await getCacheContext();
|
|
|
|
onMediaLoad(video).then(() => {
|
|
if(group) {
|
|
animationIntersector.addAnimation({
|
|
animation: video,
|
|
group
|
|
});
|
|
}
|
|
|
|
if(preloader && !uploadFileName) {
|
|
preloader.detach();
|
|
}
|
|
|
|
renderDeferred.resolve();
|
|
}, (err) => {
|
|
console.error('video load error', err);
|
|
if(spanTime) {
|
|
spanTime.classList.add('is-error');
|
|
}
|
|
renderDeferred.reject(err);
|
|
});
|
|
|
|
renderImageFromUrl(video, cacheContext.url);
|
|
}, noop);
|
|
|
|
return {download: loadPromise, render: Promise.all([loadPromise, renderDeferred])};
|
|
};
|
|
|
|
if(preloader && !uploadFileName) {
|
|
preloader.setDownloadFunction(load);
|
|
}
|
|
|
|
/* if(doc.size >= 20e6 && !doc.downloaded) {
|
|
let downloadDiv = document.createElement('div');
|
|
downloadDiv.classList.add('download');
|
|
|
|
let span = document.createElement('span');
|
|
span.classList.add('btn-circle', 'tgico-download');
|
|
downloadDiv.append(span);
|
|
|
|
downloadDiv.addEventListener('click', () => {
|
|
downloadDiv.remove();
|
|
loadVideo();
|
|
});
|
|
|
|
container.prepend(downloadDiv);
|
|
|
|
return;
|
|
} */
|
|
|
|
if(doc.type === 'gif' && !canAutoplay) {
|
|
attachClickEvent(container, (e) => {
|
|
cancelEvent(e);
|
|
spanPlay.remove();
|
|
load();
|
|
}, {capture: true, once: true});
|
|
} else {
|
|
res.loadPromise = !lazyLoadQueue ?
|
|
(await load()).render :
|
|
(lazyLoadQueue.push({div: container, load: () => load().then(({render}) => render)}), Promise.resolve());
|
|
}
|
|
|
|
if(res.thumb) {
|
|
await res.thumb.loadPromises.thumb;
|
|
}
|
|
|
|
return res;
|
|
}
|