/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ import type {LiteModeKey} from '../helpers/liteMode'; import {CustomEmojiElement, CustomEmojiRendererElement} from '../lib/richTextProcessor/wrapRichText'; import rootScope from '../lib/rootScope'; import {IS_SAFARI} from '../environment/userAgent'; import {MOUNT_CLASS_TO} from '../config/debug'; import isInDOM from '../helpers/dom/isInDOM'; import RLottiePlayer from '../lib/rlottie/rlottiePlayer'; import indexOfAndSplice from '../helpers/array/indexOfAndSplice'; import forEachReverse from '../helpers/array/forEachReverse'; import idleController from '../helpers/idleController'; import appMediaPlaybackController from './appMediaPlaybackController'; import {fastRaf} from '../helpers/schedulers'; import {Middleware} from '../helpers/middleware'; export type AnimationItemGroup = '' | 'none' | 'chat' | 'lock' | 'STICKERS-POPUP' | 'emoticons-dropdown' | 'STICKERS-SEARCH' | 'GIFS-SEARCH' | `CHAT-MENU-REACTIONS-${number}` | 'INLINE-HELPER' | 'GENERAL-SETTINGS' | 'STICKER-VIEWER' | 'EMOJI' | 'EMOJI-STATUS' | `chat-${number}`; export interface AnimationItem { el: HTMLElement, group: AnimationItemGroup, animation: AnimationItemWrapper, liteModeKey?: LiteModeKey, controlled?: boolean | Middleware }; export interface AnimationItemWrapper { remove: () => void; paused: boolean; pause: () => any; play: () => any; autoplay: boolean; loop: boolean | number; // onVisibilityChange?: (visible: boolean) => boolean; }; export class AnimationIntersector { private observer: IntersectionObserver; private visible: Set; private overrideIdleGroups: Set; private byGroups: {[group in AnimationItemGroup]?: AnimationItem[]}; private byPlayer: Map; private lockedGroups: {[group in AnimationItemGroup]?: true}; private onlyOnePlayableGroup: AnimationItemGroup; private intersectionLockedGroups: {[group in AnimationItemGroup]?: true}; private videosLocked: boolean; constructor() { this.observer = new IntersectionObserver((entries) => { // if(rootScope.idle.isIDLE) return; for(const entry of entries) { const target = entry.target; for(const group in this.byGroups) { if(this.intersectionLockedGroups[group as AnimationItemGroup]) { continue; } const animation = this.byGroups[group as AnimationItemGroup].find((p) => p.el === target); if(!animation) { continue; } if(entry.isIntersecting) { this.visible.add(animation); this.checkAnimation(animation, false); /* if(animation instanceof HTMLVideoElement && animation.dataset.src) { animation.src = animation.dataset.src; animation.load(); } */ } else { this.visible.delete(animation); this.checkAnimation(animation, true); const _animation = animation.animation; if(_animation instanceof RLottiePlayer/* && animation.cachingDelta === 2 */) { // console.warn('will clear cache', player); _animation.clearCache(); }/* else if(animation instanceof HTMLVideoElement && animation.src) { animation.dataset.src = animation.src; animation.src = ''; animation.load(); } */ } break; } } }); this.visible = new Set(); this.overrideIdleGroups = new Set(); this.byGroups = {}; this.byPlayer = new Map(); this.lockedGroups = {}; this.onlyOnePlayableGroup = ''; this.intersectionLockedGroups = {}; this.videosLocked = false; appMediaPlaybackController.addEventListener('play', ({doc}) => { if(doc.type === 'round') { this.videosLocked = true; this.checkAnimations2(); } }); appMediaPlaybackController.addEventListener('pause', () => { if(this.videosLocked) { this.videosLocked = false; this.checkAnimations2(); } }); idleController.addEventListener('change', (idle) => { this.checkAnimations2(idle); }); } public setOverrideIdleGroup(group: string, override: boolean) { if(override) this.overrideIdleGroups.add(group); else this.overrideIdleGroups.delete(group); } public getAnimations(element: HTMLElement) { const found: AnimationItem[] = []; for(const group in this.byGroups) { for(const player of this.byGroups[group as AnimationItemGroup]) { if(player.el === element) { found.push(player); } } } return found; } public removeAnimation(player: AnimationItem) { const {el, animation} = player; if(!(animation instanceof HTMLVideoElement)) { animation.remove(); } if(animation instanceof HTMLVideoElement && IS_SAFARI) { setTimeout(() => { // TODO: очистка по очереди, а не все вместе с этим таймаутом animation.src = ''; animation.load(); }, 1e3); } const group = this.byGroups[player.group]; if(group) { indexOfAndSplice(group, player); if(!group.length) { delete this.byGroups[player.group]; } } this.observer.unobserve(el); this.visible.delete(player); this.byPlayer.delete(animation); } public removeAnimationByPlayer(player: AnimationItemWrapper) { const item = this.byPlayer.get(player); if(item) { this.removeAnimation(item); } } public addAnimation(options: { animation: AnimationItem['animation'], group?: AnimationItemGroup, observeElement?: HTMLElement, controlled?: AnimationItem['controlled'], liteModeKey?: LiteModeKey }) { let {animation, group = '', observeElement, controlled, liteModeKey} = options; if(group === 'none' || this.byPlayer.has(animation)) { return; } if(!observeElement) { if(animation instanceof RLottiePlayer) { observeElement = animation.el[0]; } else if(animation instanceof CustomEmojiRendererElement) { observeElement = animation.canvas; } else if(animation instanceof CustomEmojiElement) { observeElement = animation.placeholder ?? animation; } else if(animation instanceof HTMLElement) { observeElement = animation; } } const item: AnimationItem = { el: observeElement, animation: animation, group, controlled, liteModeKey }; if(controlled && typeof(controlled) !== 'boolean') { controlled.onClean(() => { this.removeAnimationByPlayer(animation); }); } if(animation instanceof RLottiePlayer) { if(!rootScope.settings.stickers.loop && animation.loop) { animation.loop = rootScope.settings.stickers.loop; } } (this.byGroups[group as AnimationItemGroup] ??= []).push(item); this.observer.observe(item.el); this.byPlayer.set(animation, item); } public checkAnimations( blurred?: boolean, group?: AnimationItemGroup, destroy?: boolean, imitateIntersection?: boolean ) { // if(rootScope.idle.isIDLE) return; if(group !== undefined && !this.byGroups[group]) { // console.warn('no animation group:', group); return; } const groups = group !== undefined /* && false */ ? [group] : Object.keys(this.byGroups) as AnimationItemGroup[]; for(const group of groups) { if(imitateIntersection && this.intersectionLockedGroups[group]) { continue; } const animations = this.byGroups[group]; forEachReverse(animations, (animation) => { this.checkAnimation(animation, blurred, destroy); }); } } public checkAnimations2(blurred?: boolean) { this.checkAnimations(blurred, undefined, undefined, true); } public checkAnimation(player: AnimationItem, blurred?: boolean, destroy?: boolean) { const {el, animation, group} = player; // return; if(destroy || (!this.lockedGroups[group] && !isInDOM(el))) { if(!player.controlled || destroy) { this.removeAnimation(player); } return; } if(blurred || (this.onlyOnePlayableGroup && this.onlyOnePlayableGroup !== group) || (animation instanceof HTMLVideoElement && this.videosLocked) ) { if(!animation.paused) { // console.warn('pause animation:', animation); animation.pause(); } } else if(animation.paused && this.visible.has(player) && animation.autoplay && (!this.onlyOnePlayableGroup || this.onlyOnePlayableGroup === group) && (!idleController.isIdle || this.overrideIdleGroups.has(player.group)) ) { // console.warn('play animation:', animation); animation.play(); } } public getOnlyOnePlayableGroup() { return this.onlyOnePlayableGroup; } public setOnlyOnePlayableGroup(group: AnimationItemGroup = '') { this.onlyOnePlayableGroup = group; } public lockGroup(group: AnimationItemGroup) { this.lockedGroups[group] = true; } public unlockGroup(group: AnimationItemGroup) { delete this.lockedGroups[group]; this.checkAnimations(undefined, group); } public refreshGroup(group: AnimationItemGroup) { const animations = this.byGroups[group]; if(!animations?.length) { return; } animations.forEach((animation) => { this.observer.unobserve(animation.el); }); fastRaf(() => { animations.forEach((animation) => { this.observer.observe(animation.el); }); }); } public lockIntersectionGroup(group: AnimationItemGroup) { this.intersectionLockedGroups[group] = true; } public unlockIntersectionGroup(group: AnimationItemGroup) { delete this.intersectionLockedGroups[group]; this.refreshGroup(group); } public toggleIntersectionGroup(group: AnimationItemGroup, lock: boolean) { if(lock) this.lockIntersectionGroup(group); else this.unlockIntersectionGroup(group); } } const animationIntersector = new AnimationIntersector(); MOUNT_CLASS_TO && (MOUNT_CLASS_TO.animationIntersector = animationIntersector); export default animationIntersector;