600 lines
15 KiB
TypeScript
600 lines
15 KiB
TypeScript
import { DPR, IS_SAFARI, IS_ANDROID } from '../../util/environment';
|
|
import WorkerConnector from '../../util/WorkerConnector';
|
|
import { animate } from '../../util/animation';
|
|
import cycleRestrict from '../../util/cycleRestrict';
|
|
import { fastRaf } from '../../util/schedulers';
|
|
|
|
interface Params {
|
|
noLoop?: boolean;
|
|
size?: number;
|
|
quality?: number;
|
|
isLowPriority?: boolean;
|
|
isMobile?: boolean;
|
|
coords?: { x: number; y: number };
|
|
}
|
|
|
|
const WAITING = Symbol('WAITING');
|
|
type Frame =
|
|
undefined
|
|
| typeof WAITING
|
|
| ImageBitmap;
|
|
|
|
const MAX_WORKERS = 4;
|
|
const HIGH_PRIORITY_QUALITY_MOBILE = 0.75;
|
|
const HIGH_PRIORITY_QUALITY_DESKTOP = 1;
|
|
const LOW_PRIORITY_QUALITY = IS_ANDROID ? 0.5 : 0.75;
|
|
const LOW_PRIORITY_QUALITY_SIZE_THRESHOLD = 24;
|
|
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<string, {
|
|
canvas: HTMLCanvasElement;
|
|
ctx: CanvasRenderingContext2D;
|
|
isLoaded?: boolean;
|
|
isPaused?: boolean;
|
|
isSharedCanvas?: boolean;
|
|
coords?: Params['coords'];
|
|
onLoad?: NoneToVoidFunction;
|
|
}>();
|
|
|
|
private imgSize!: number;
|
|
|
|
private imageData!: ImageData;
|
|
|
|
private msPerFrame = 1000 / 60;
|
|
|
|
private reduceFactor = 1;
|
|
|
|
private cacheModulo!: number;
|
|
|
|
private workerIndex!: number;
|
|
|
|
private frames: Frame[] = [];
|
|
|
|
private framesCount?: 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, canvas, onLoad, id, , params] = 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, canvas, onLoad, params?.coords, params?.isMobile);
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
constructor(
|
|
containerId: string,
|
|
container: HTMLDivElement | HTMLCanvasElement,
|
|
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(containerId, container, onLoad, params.coords, params.isMobile);
|
|
this.initConfig();
|
|
this.initRenderer();
|
|
}
|
|
|
|
public removeContainer(containerId: string) {
|
|
const {
|
|
canvas, ctx, isSharedCanvas, coords,
|
|
} = this.containers.get(containerId)!;
|
|
|
|
if (isSharedCanvas) {
|
|
ctx.clearRect(coords!.x, coords!.y, this.imgSize, this.imgSize);
|
|
} else {
|
|
canvas.remove();
|
|
}
|
|
|
|
this.containers.delete(containerId);
|
|
|
|
if (!this.containers.size) {
|
|
this.destroy();
|
|
}
|
|
}
|
|
|
|
isPlaying() {
|
|
return this.isAnimating || this.isWaiting;
|
|
}
|
|
|
|
play(forceRestart = false, containerId?: string) {
|
|
if (containerId) {
|
|
this.containers.get(containerId)!.isPaused = false;
|
|
}
|
|
|
|
if (this.isEnded && forceRestart) {
|
|
this.approxFrameIndex = Math.floor(0);
|
|
}
|
|
|
|
this.stopFrameIndex = undefined;
|
|
this.direction = 1;
|
|
this.doPlay();
|
|
}
|
|
|
|
pause(containerId?: string) {
|
|
if (containerId) {
|
|
this.containers.get(containerId)!.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;
|
|
}
|
|
|
|
if (!this.params.isLowPriority) {
|
|
this.frames = this.frames.map((frame, i) => {
|
|
if (i === this.prevFrameIndex) {
|
|
return frame;
|
|
} else {
|
|
if (frame && frame !== WAITING) {
|
|
frame.close();
|
|
}
|
|
|
|
return 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;
|
|
}
|
|
|
|
setIsMobile(isMobile?: Params['isMobile']) {
|
|
this.params.isMobile = isMobile;
|
|
}
|
|
|
|
setSharedCanvasCoords(containerId: string, newCoords: Params['coords'], isMobile?: Params['isMobile']) {
|
|
const containerInfo = this.containers.get(containerId)!;
|
|
const {
|
|
canvas, ctx,
|
|
} = containerInfo;
|
|
|
|
if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') {
|
|
this.setIsMobile(isMobile);
|
|
const sizeFactor = this.calcSizeFactor();
|
|
ensureCanvasSize(canvas, sizeFactor);
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
canvas.dataset.isJustCleaned = 'true';
|
|
fastRaf(() => {
|
|
canvas.dataset.isJustCleaned = 'false';
|
|
});
|
|
}
|
|
|
|
containerInfo.coords = {
|
|
x: Math.round((newCoords?.x || 0) * canvas.width),
|
|
y: Math.round((newCoords?.y || 0) * canvas.height),
|
|
};
|
|
|
|
const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex));
|
|
|
|
if (frame && frame !== WAITING) {
|
|
ctx.drawImage(frame, containerInfo.coords.x, containerInfo.coords.y);
|
|
}
|
|
}
|
|
|
|
private addContainer(
|
|
containerId: string,
|
|
container: HTMLDivElement | HTMLCanvasElement,
|
|
onLoad?: NoneToVoidFunction,
|
|
coords?: Params['coords'],
|
|
isMobile?: Params['isMobile'],
|
|
) {
|
|
this.setIsMobile(isMobile);
|
|
const sizeFactor = this.calcSizeFactor();
|
|
|
|
let imgSize: number;
|
|
|
|
if (container instanceof HTMLDivElement) {
|
|
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');
|
|
const ctx = canvas.getContext('2d')!;
|
|
|
|
canvas.style.width = `${size}px`;
|
|
canvas.style.height = `${size}px`;
|
|
|
|
imgSize = Math.round(size * sizeFactor);
|
|
|
|
canvas.width = imgSize;
|
|
canvas.height = imgSize;
|
|
|
|
container.appendChild(canvas);
|
|
|
|
this.containers.set(containerId, {
|
|
canvas, ctx, onLoad,
|
|
});
|
|
} else {
|
|
if (!container.isConnected) {
|
|
throw new Error('[RLottie] Shared canvas is not mounted');
|
|
}
|
|
|
|
const canvas = container;
|
|
const ctx = canvas.getContext('2d')!;
|
|
|
|
ensureCanvasSize(canvas, sizeFactor);
|
|
|
|
imgSize = Math.round(this.params.size! * sizeFactor);
|
|
|
|
this.containers.set(containerId, {
|
|
canvas,
|
|
ctx,
|
|
isSharedCanvas: true,
|
|
coords: {
|
|
x: Math.round((coords?.x || 0) * canvas.width),
|
|
y: Math.round((coords?.y || 0) * canvas.height),
|
|
},
|
|
onLoad,
|
|
});
|
|
}
|
|
|
|
if (!this.imgSize) {
|
|
this.imgSize = imgSize;
|
|
this.imageData = new ImageData(imgSize, imgSize);
|
|
}
|
|
|
|
if (this.isRendererInited) {
|
|
this.doPlay();
|
|
}
|
|
}
|
|
|
|
private calcSizeFactor() {
|
|
const {
|
|
isLowPriority,
|
|
size,
|
|
isMobile,
|
|
// Reduced quality only looks acceptable on big enough images
|
|
quality = isLowPriority && (!size || size > LOW_PRIORITY_QUALITY_SIZE_THRESHOLD)
|
|
? LOW_PRIORITY_QUALITY
|
|
: (isMobile ? HIGH_PRIORITY_QUALITY_MOBILE : HIGH_PRIORITY_QUALITY_DESKTOP),
|
|
} = this.params;
|
|
|
|
// Reduced quality only looks acceptable on high DPR screens
|
|
return Math.max(DPR * quality, 1);
|
|
}
|
|
|
|
private destroy() {
|
|
this.isDestroyed = true;
|
|
this.pause();
|
|
this.clearCache();
|
|
this.destroyRenderer();
|
|
|
|
instancesById.delete(this.id);
|
|
}
|
|
|
|
private clearCache() {
|
|
this.frames.forEach((frame) => {
|
|
if (frame && frame !== WAITING) {
|
|
frame.close();
|
|
}
|
|
});
|
|
|
|
// Help GC
|
|
this.imageData = undefined as any;
|
|
this.frames = [];
|
|
}
|
|
|
|
private initConfig() {
|
|
const { isLowPriority } = this.params;
|
|
|
|
this.cacheModulo = isLowPriority ? LOW_PRIORITY_CACHE_MODULO : HIGH_PRIORITY_CACHE_MODULO;
|
|
}
|
|
|
|
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.customColor,
|
|
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;
|
|
|
|
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.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) {
|
|
const areAllLoaded = Array.from(this.containers.values()).every(({ isLoaded }) => isLoaded);
|
|
if (areAllLoaded) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const frameIndex = Math.round(this.approxFrameIndex);
|
|
const frame = this.getFrame(frameIndex);
|
|
if (!frame || frame === WAITING) {
|
|
if (!frame) {
|
|
this.requestFrame(frameIndex);
|
|
}
|
|
|
|
this.isAnimating = false;
|
|
this.isWaiting = true;
|
|
return false;
|
|
}
|
|
|
|
if (this.cacheModulo && frameIndex % this.cacheModulo === 0) {
|
|
this.cleanupPrevFrame(frameIndex);
|
|
}
|
|
|
|
if (frameIndex !== this.prevFrameIndex) {
|
|
this.containers.forEach((containerData) => {
|
|
const {
|
|
ctx, isLoaded, isPaused, coords: { x, y } = {}, onLoad,
|
|
} = containerData;
|
|
|
|
if (!isLoaded || !isPaused) {
|
|
ctx.clearRect(x || 0, y || 0, this.imgSize, this.imgSize);
|
|
ctx.drawImage(frame, x || 0, y || 0);
|
|
}
|
|
|
|
if (!isLoaded) {
|
|
containerData.isLoaded = 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.requestFrame(nextFrameIndex);
|
|
this.isWaiting = true;
|
|
this.isAnimating = false;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private getFrame(frameIndex: number) {
|
|
return this.frames[frameIndex];
|
|
}
|
|
|
|
private requestFrame(frameIndex: number) {
|
|
this.frames[frameIndex] = WAITING;
|
|
|
|
workers[this.workerIndex].request({
|
|
name: 'renderFrames',
|
|
args: [this.id, frameIndex, this.onFrameLoad.bind(this)],
|
|
});
|
|
}
|
|
|
|
private cleanupPrevFrame(frameIndex: number) {
|
|
if (this.framesCount! < 3) {
|
|
return;
|
|
}
|
|
|
|
const prevFrameIndex = cycleRestrict(this.framesCount!, frameIndex - 1);
|
|
this.frames[prevFrameIndex] = undefined;
|
|
}
|
|
|
|
private onFrameLoad(frameIndex: number, imageBitmap: ImageBitmap) {
|
|
if (this.frames[frameIndex] !== WAITING) {
|
|
return;
|
|
}
|
|
|
|
this.frames[frameIndex] = imageBitmap;
|
|
|
|
if (this.isWaiting) {
|
|
this.doPlay();
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureCanvasSize(canvas: HTMLCanvasElement, sizeFactor: number) {
|
|
const expectedWidth = Math.round(canvas.offsetWidth * sizeFactor);
|
|
const expectedHeight = Math.round(canvas.offsetHeight * sizeFactor);
|
|
if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) {
|
|
canvas.width = expectedWidth;
|
|
canvas.height = expectedHeight;
|
|
}
|
|
}
|
|
|
|
export default RLottie;
|