TelegramPWA/src/lib/rlottie/RLottie.ts
2022-11-01 18:53:23 +01:00

534 lines
13 KiB
TypeScript

import {
DPR,
IS_SINGLE_COLUMN_LAYOUT,
IS_SAFARI,
IS_ANDROID,
} from '../../util/environment';
import WorkerConnector from '../../util/WorkerConnector';
import { animate } from '../../util/animation';
import cycleRestrict from '../../util/cycleRestrict';
interface Params {
noLoop?: boolean;
size?: number;
quality?: number;
isLowPriority?: boolean;
}
type Frames = ArrayBuffer[];
type Chunks = (Frames | undefined)[];
// TODO Consider removing chunks
const CHUNK_SIZE = 1;
const MAX_WORKERS = 4;
const HIGH_PRIORITY_QUALITY = IS_SINGLE_COLUMN_LAYOUT ? 0.75 : 1;
const LOW_PRIORITY_QUALITY = IS_ANDROID ? 0.5 : 0.75;
const HIGH_PRIORITY_CACHE_MODULO = IS_SAFARI ? 2 : 4;
const LOW_PRIORITY_CACHE_MODULO = 0;
const instancesById = new Map<string, RLottie>();
const workers = new Array(MAX_WORKERS).fill(undefined).map(
() => new WorkerConnector(new Worker(new URL('./rlottie.worker.ts', import.meta.url))),
);
let lastWorkerIndex = -1;
class RLottie {
// Config
private containers = new Map<HTMLDivElement, {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
onLoad?: NoneToVoidFunction;
isOnLoadFired?: boolean;
isPaused?: boolean;
}>();
private imgSize!: number;
private msPerFrame = 1000 / 60;
private reduceFactor = 1;
private cacheModulo!: number;
private chunkSize!: number;
private workerIndex!: number;
private chunks: Chunks = [];
private framesCount?: number;
private chunksCount?: number;
// State
private isAnimating = false;
private isWaiting = true;
private isEnded = false;
private isDestroyed = false;
private isRendererInited = false;
private approxFrameIndex = 0;
private prevFrameIndex = -1;
private stopFrameIndex? = 0;
private speed = 1;
private direction: 1 | -1 = 1;
private lastRenderAt?: number;
static init(...args: ConstructorParameters<typeof RLottie>) {
const [container, onLoad, id] = args;
let instance = instancesById.get(id);
if (!instance) {
// eslint-disable-next-line prefer-rest-params
instance = new RLottie(...args);
instancesById.set(id, instance);
} else {
instance.addContainer(container, onLoad);
}
return instance;
}
constructor(
container: HTMLDivElement,
onLoad: NoneToVoidFunction | undefined,
private id: string,
private tgsUrl: string,
private params: Params = {},
private customColor?: [number, number, number],
private onEnded?: (isDestroyed?: boolean) => void,
private onLoop?: () => void,
) {
this.addContainer(container, onLoad);
this.initConfig();
this.initRenderer();
}
public removeContainer(container: HTMLDivElement) {
this.containers.get(container)!.canvas.remove();
this.containers.delete(container);
if (!this.containers.size) {
this.destroy();
}
}
isPlaying() {
return this.isAnimating || this.isWaiting;
}
play(forceRestart = false, container?: HTMLDivElement) {
if (container) {
this.containers.get(container)!.isPaused = false;
const areAllContainersPlaying = Array.from(this.containers.values()).every(({ isPaused }) => !isPaused);
if (!areAllContainersPlaying) {
return;
}
}
if (this.isEnded && forceRestart) {
this.approxFrameIndex = Math.floor(0);
}
this.stopFrameIndex = undefined;
this.direction = 1;
this.doPlay();
}
pause(container?: HTMLDivElement) {
if (container) {
this.containers.get(container)!.isPaused = true;
const areAllContainersPaused = Array.from(this.containers.values()).every(({ isPaused }) => isPaused);
if (!areAllContainersPaused) {
return;
}
}
if (this.isWaiting) {
this.stopFrameIndex = this.approxFrameIndex;
} else {
this.isAnimating = false;
}
const currentChunkIndex = this.getChunkIndex(this.approxFrameIndex);
this.chunks = this.chunks.map((chunk, i) => (i === currentChunkIndex ? chunk : undefined));
}
playSegment([startFrameIndex, stopFrameIndex]: [number, number]) {
this.approxFrameIndex = Math.floor(startFrameIndex / this.reduceFactor);
this.stopFrameIndex = Math.floor(stopFrameIndex / this.reduceFactor);
this.direction = startFrameIndex < stopFrameIndex ? 1 : -1;
this.doPlay();
}
setSpeed(speed: number) {
this.speed = speed;
}
setNoLoop(noLoop: boolean) {
this.params.noLoop = noLoop;
}
private addContainer(container: HTMLDivElement, onLoad?: NoneToVoidFunction) {
if (!(container.parentNode instanceof HTMLElement)) {
throw new Error('[RLottie] Container is not mounted');
}
let { size } = this.params;
if (!size) {
size = (
container.offsetWidth
|| parseInt(container.style.width, 10)
|| container.parentNode.offsetWidth
);
if (!size) {
throw new Error('[RLottie] Failed to detect width from container');
}
}
const canvas = document.createElement('canvas');
canvas.dataset.id = this.id;
const ctx = canvas.getContext('2d')!;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
const { isLowPriority, quality = isLowPriority ? LOW_PRIORITY_QUALITY : HIGH_PRIORITY_QUALITY } = this.params;
// Reduced quality only looks acceptable on high DPR screens
const imgSize = Math.round(size * Math.max(DPR * quality, 1));
canvas.width = imgSize;
canvas.height = imgSize;
container.appendChild(canvas);
if (!this.imgSize) {
this.imgSize = imgSize;
}
this.containers.set(container, { canvas, ctx, onLoad });
if (this.isRendererInited) {
this.doPlay();
}
}
private destroy() {
this.isDestroyed = true;
this.pause();
this.destroyRenderer();
instancesById.delete(this.id);
}
private initConfig() {
const { isLowPriority } = this.params;
this.cacheModulo = isLowPriority ? LOW_PRIORITY_CACHE_MODULO : HIGH_PRIORITY_CACHE_MODULO;
this.chunkSize = CHUNK_SIZE;
}
setColor(newColor: [number, number, number] | undefined) {
this.customColor = newColor;
}
private initRenderer() {
this.workerIndex = cycleRestrict(MAX_WORKERS, ++lastWorkerIndex);
workers[this.workerIndex].request({
name: 'init',
args: [
this.id,
this.tgsUrl,
this.imgSize,
this.params.isLowPriority,
this.onRendererInit.bind(this),
],
});
}
private destroyRenderer() {
workers[this.workerIndex].request({
name: 'destroy',
args: [this.id],
});
}
private onRendererInit(reduceFactor: number, msPerFrame: number, framesCount: number) {
this.isRendererInited = true;
this.reduceFactor = reduceFactor;
this.msPerFrame = msPerFrame;
this.framesCount = framesCount;
this.chunksCount = Math.ceil(framesCount / this.chunkSize);
if (this.isWaiting) {
this.doPlay();
}
}
changeData(tgsUrl: string) {
this.pause();
this.tgsUrl = tgsUrl;
this.initConfig();
workers[this.workerIndex].request({
name: 'changeData',
args: [
this.id,
this.tgsUrl,
this.params.isLowPriority,
this.onChangeData.bind(this),
],
});
}
private onChangeData(reduceFactor: number, msPerFrame: number, framesCount: number) {
this.reduceFactor = reduceFactor;
this.msPerFrame = msPerFrame;
this.framesCount = framesCount;
this.chunksCount = Math.ceil(framesCount / this.chunkSize);
this.isWaiting = false;
this.isAnimating = false;
this.doPlay();
}
private doPlay() {
if (!this.framesCount) {
return;
}
if (this.isDestroyed) {
return;
}
if (this.isAnimating) {
return;
}
if (!this.isWaiting) {
this.lastRenderAt = undefined;
}
this.isEnded = false;
this.isAnimating = true;
this.isWaiting = false;
animate(() => {
if (this.isDestroyed) {
return false;
}
// Paused from outside
if (!this.isAnimating) {
return false;
}
const frameIndex = Math.round(this.approxFrameIndex);
const chunkIndex = this.getChunkIndex(frameIndex);
const chunk = this.chunks[chunkIndex];
if (!chunk || chunk.length === 0) {
this.requestChunk(chunkIndex);
this.isAnimating = false;
this.isWaiting = true;
return false;
}
if (this.cacheModulo && chunkIndex % this.cacheModulo === 0) {
this.cleanupPrevChunk(chunkIndex);
}
if (frameIndex !== this.prevFrameIndex) {
const frame = this.getFrame(frameIndex);
if (!frame) {
this.isAnimating = false;
this.isWaiting = true;
return false;
}
const arr = new Uint8ClampedArray(frame);
if (this.customColor) {
for (let i = 0; i < arr.length; i += 4) {
/* eslint-disable prefer-destructuring */
arr[i] = this.customColor[0];
arr[i + 1] = this.customColor[1];
arr[i + 2] = this.customColor[2];
/* eslint-enable prefer-destructuring */
}
}
const imageData = new ImageData(arr, this.imgSize, this.imgSize);
this.containers.forEach((containerData) => {
const {
ctx, onLoad, isOnLoadFired, isPaused,
} = containerData;
if (isPaused) {
return;
}
ctx.putImageData(imageData, 0, 0);
if (onLoad && !isOnLoadFired) {
containerData.isOnLoadFired = true;
onLoad();
}
});
this.prevFrameIndex = frameIndex;
}
const now = Date.now();
const currentSpeed = this.lastRenderAt ? this.msPerFrame / (now - this.lastRenderAt) : 1;
const delta = Math.min(1, (this.direction * this.speed) / currentSpeed);
const expectedNextFrameIndex = Math.round(this.approxFrameIndex + delta);
this.lastRenderAt = now;
// Forward animation finished
if (delta > 0 && (frameIndex === this.framesCount! - 1 || expectedNextFrameIndex > this.framesCount! - 1)) {
if (this.params.noLoop) {
this.isAnimating = false;
this.isEnded = true;
this.onEnded?.();
return false;
}
this.onLoop?.();
this.approxFrameIndex = 0;
// Backward animation finished
} else if (delta < 0 && (frameIndex === 0 || expectedNextFrameIndex < 0)) {
if (this.params.noLoop) {
this.isAnimating = false;
this.isEnded = true;
this.onEnded?.();
return false;
}
this.onLoop?.();
this.approxFrameIndex = this.framesCount! - 1;
// Stop frame reached
} else if (
this.stopFrameIndex !== undefined
&& (frameIndex === this.stopFrameIndex
|| (
(delta > 0 && expectedNextFrameIndex > this.stopFrameIndex)
|| (delta < 0 && expectedNextFrameIndex < this.stopFrameIndex)
))
) {
this.stopFrameIndex = undefined;
this.isAnimating = false;
return false;
// Preparing next frame
} else {
this.approxFrameIndex += delta;
}
const nextFrameIndex = Math.round(this.approxFrameIndex);
if (!this.getFrame(nextFrameIndex)) {
this.requestChunk(this.getChunkIndex(nextFrameIndex));
this.isWaiting = true;
this.isAnimating = false;
return false;
}
return true;
});
}
private getFrame(frameIndex: number) {
const chunkIndex = this.getChunkIndex(frameIndex);
const indexInChunk = this.getFrameIndexInChunk(frameIndex);
const chunk = this.chunks[chunkIndex];
if (!chunk) {
return undefined;
}
return chunk[indexInChunk];
}
private getFrameIndexInChunk(frameIndex: number) {
const chunkIndex = this.getChunkIndex(frameIndex);
return frameIndex - chunkIndex * this.chunkSize;
}
private getChunkIndex(frameIndex: number) {
return Math.floor(frameIndex / this.chunkSize);
}
private requestChunk(chunkIndex: number) {
if (this.chunks[chunkIndex] && this.chunks[chunkIndex]?.length !== 0) {
return;
}
this.chunks[chunkIndex] = [];
const fromIndex = chunkIndex * this.chunkSize;
const toIndex = Math.min(fromIndex + this.chunkSize - 1, this.framesCount! - 1);
workers[this.workerIndex].request({
name: 'renderFrames',
args: [this.id, fromIndex, toIndex, this.onFrameLoad.bind(this)],
});
}
private cleanupPrevChunk(chunkIndex: number) {
if (this.chunksCount! < 3) {
return;
}
const prevChunkIndex = cycleRestrict(this.chunksCount!, chunkIndex - 1);
this.chunks[prevChunkIndex] = undefined;
}
private requestNextChunk(chunkIndex: number) {
if (this.chunksCount === 1) {
return;
}
const nextChunkIndex = cycleRestrict(this.chunksCount!, chunkIndex + 1);
if (!this.chunks[nextChunkIndex]) {
this.requestChunk(nextChunkIndex);
}
}
private onFrameLoad(frameIndex: number, arrayBuffer: ArrayBuffer) {
const chunkIndex = this.getChunkIndex(frameIndex);
const chunk = this.chunks[chunkIndex];
// Frame can be skipped and chunk can be already cleaned up
if (!chunk) {
return;
}
chunk[this.getFrameIndexInChunk(frameIndex)] = arrayBuffer;
if (this.isWaiting) {
this.doPlay();
}
}
}
export default RLottie;