import { isInDOM } from "./utils"; import { isApple } from "./config"; let convert = (value: number) => { return Math.round(Math.min(Math.max(value, 0), 1) * 255); }; type RLottiePlayerListeners = 'enterFrame' | 'ready'; type RLottieOptions = { container: HTMLElement, autoplay?: boolean, animationData: any, loop?: boolean, width?: number, height?: number }; export class RLottiePlayer { public static reqId = 0; public reqId = 0; public curFrame: number; public frameCount: number; public fps: number; public worker: QueryableWorker; private width = 0; private height = 0; public listeners: Partial<{ [k in RLottiePlayerListeners]: (res: any) => void }> = {}; public listenerResults: Partial<{ [k in RLottiePlayerListeners]: any }> = {}; public el: HTMLElement; public canvas: HTMLCanvasElement; public context: CanvasRenderingContext2D; public paused = true; public direction = 1; public speed = 1; public autoplay = true; public loop = true; private frInterval: number; private frThen: number; private rafId: number; //private playedTimes = 0; constructor({el, worker, options}: { el: HTMLElement, worker: QueryableWorker, options: RLottieOptions }) { this.reqId = ++RLottiePlayer['reqId']; this.el = el; this.worker = worker; for(let i in options) { if(this.hasOwnProperty(i)) { // @ts-ignore this[i] = options[i]; } } //console.log("RLottiePlayer width:", this.width, this.height, options); if(window.devicePixelRatio > 1) { if(isApple) { this.width *= window.devicePixelRatio; this.height *= window.devicePixelRatio; } else { this.width *= (window.devicePixelRatio - 1.5); this.height *= (window.devicePixelRatio - 1.5); } } this.canvas = document.createElement('canvas'); this.canvas.classList.add('rlottie'); this.canvas.width = this.width; this.canvas.height = this.height; this.context = this.canvas.getContext('2d'); } public addListener(name: RLottiePlayerListeners, callback: (res?: any) => void) { if(this.listenerResults.hasOwnProperty(name)) return Promise.resolve(this.listenerResults[name]); this.listeners[name] = callback; } public setListenerResult(name: RLottiePlayerListeners, value?: any) { this.listenerResults[name] = value; if(this.listeners[name]) { this.listeners[name](value); } } public sendQuery(methodName: string, ...args: any[]) { this.worker.sendQuery(methodName, this.reqId, ...args); } public loadFromData(json: any) { this.sendQuery('loadFromData', json, this.width, this.height, { paused: this.paused, direction: this.direction, speed: this.speed }); } public play() { if(!this.paused) return; this.paused = false; this.setMainLoop(); } public pause() { if(this.paused) return; this.paused = true; window.cancelAnimationFrame(this.rafId); } public stop() { this.pause(); this.curFrame = this.direction == 1 ? 0 : this.frameCount; this.sendQuery('renderFrame', this.curFrame); } public restart() { this.stop(); this.play(); } public setSpeed(speed: number) { this.speed = speed; if(!this.paused) { this.setMainLoop(); } } public setDirection(direction: number) { this.direction = direction; if(!this.paused) { this.setMainLoop(); } } public destroy() { lottieLoader.onDestroy(this.reqId); this.pause(); this.sendQuery('destroy'); } public renderFrame(frame: Uint8ClampedArray, frameNo: number) { try { this.context.putImageData(new ImageData(frame, this.width, this.height), 0, 0/* , 0, 0, this.canvas.width, this.canvas.height */); } catch(err) { console.error('RLottiePlayer renderFrame error:', err, frame, this.width, this.height); this.autoplay = false; this.pause(); } this.setListenerResult('enterFrame', frameNo); } private mainLoop(method: RLottiePlayer['mainLoopForwards'] | RLottiePlayer['mainLoopBackwards']) { let r = () => { if(this.paused) { return; } const now = Date.now(), delta = now - this.frThen; if(delta > this.frInterval) { this.frThen = now - (delta % this.frInterval); const canContinue = method(); if(!canContinue && !this.loop && this.autoplay) { this.autoplay = false; } } this.rafId = window.requestAnimationFrame(r); }; //this.rafId = window.requestAnimationFrame(r); r(); } private mainLoopForwards() { this.sendQuery('renderFrame', this.curFrame++); if(this.curFrame >= this.frameCount) { //this.playedTimes++; if(!this.loop) return false; this.curFrame = 0; } return true; }; private mainLoopBackwards() { this.sendQuery('renderFrame', this.curFrame--); if(this.curFrame < 0) { //this.playedTimes++; if(!this.loop) return false; this.curFrame = this.frameCount - 1; } return true; }; public setMainLoop() { window.cancelAnimationFrame(this.rafId); this.frInterval = 1000 / this.fps / this.speed; this.frThen = Date.now(); //console.trace('setMainLoop', this.frInterval, this.direction, this); const method = (this.direction == 1 ? this.mainLoopForwards : this.mainLoopBackwards).bind(this); this.mainLoop(method); }; } class QueryableWorker { private worker: Worker; private listeners: {[name: string]: (...args: any[]) => void} = {}; constructor(url: string, private defaultListener: (data: any) => void = () => {}, onError?: (error: any) => void) { this.worker = new Worker(url); if(onError) { this.worker.onerror = onError; } this.worker.onmessage = (event) => { if(event.data instanceof Object && event.data.hasOwnProperty('queryMethodListener') && event.data.hasOwnProperty('queryMethodArguments')) { this.listeners[event.data.queryMethodListener].apply(this, event.data.queryMethodArguments); } else { this.defaultListener.call(this, event.data); } } } public postMessage(message: any) { this.worker.postMessage(message); } public terminate() { this.worker.terminate(); } public addListener(name: string, listener: (...args: any[]) => void) { this.listeners[name] = listener; } public removeListener(name: string) { delete this.listeners[name]; } public sendQuery(queryMethod: string, ...args: any[]) { this.worker.postMessage({ 'queryMethod': queryMethod, 'queryMethodArguments': args }); } } class LottieLoader { public loadPromise: Promise; public loaded = false; private static COLORREPLACEMENTS = [ [ [0xf77e41, 0xca907a], [0xffb139, 0xedc5a5], [0xffd140, 0xf7e3c3], [0xffdf79, 0xfbefd6], ], [ [0xf77e41, 0xaa7c60], [0xffb139, 0xc8a987], [0xffd140, 0xddc89f], [0xffdf79, 0xe6d6b2], ], [ [0xf77e41, 0x8c6148], [0xffb139, 0xad8562], [0xffd140, 0xc49e76], [0xffdf79, 0xd4b188], ], [ [0xf77e41, 0x6e3c2c], [0xffb139, 0x925a34], [0xffd140, 0xa16e46], [0xffdf79, 0xac7a52], ] ]; private workersLimit = 4; private players: {[reqId: number]: RLottiePlayer} = {}; private byGroups: {[group: string]: RLottiePlayer[]} = {}; private workers: QueryableWorker[] = []; private curWorkerNum = 0; private observer: IntersectionObserver; private visible: Set = new Set(); private debug = true; constructor() { this.observer = new IntersectionObserver((entries) => { for(const entry of entries) { const target = entry.target; for(const group in this.byGroups) { const player = this.byGroups[group].find(p => p.el == target); if(player) { if(entry.isIntersecting) { this.visible.add(player); this.checkAnimation(player, false); } else { this.visible.delete(player); this.checkAnimation(player, true); } break; } } } }); } public loadLottieWorkers() { if(this.loadPromise) return this.loadPromise; const onFrame = this.onFrame.bind(this); const onPlayerLoaded = this.onPlayerLoaded.bind(this); return this.loadPromise = new Promise((resolve, reject) => { let remain = this.workersLimit; for(let i = 0; i < this.workersLimit; ++i) { const worker = this.workers[i] = new QueryableWorker('rlottie.worker.js'); worker.addListener('ready', () => { console.log('worker #' + i + ' ready'); worker.addListener('frame', onFrame); worker.addListener('loaded', onPlayerLoaded); --remain; if(!remain) { console.log('workers ready'); resolve(); this.loaded = true; } }); } }); } private applyReplacements(object: any, toneIndex: number) { const replacements = LottieLoader.COLORREPLACEMENTS[toneIndex - 2]; const iterateIt = (it: any) => { for(let smth of it) { switch(smth.ty) { case 'st': case 'fl': let k = smth.c.k; let color = convert(k[2]) | (convert(k[1]) << 8) | (convert(k[0]) << 16); let foundReplacement = replacements.find(p => p[0] == color); if(foundReplacement) { k[0] = ((foundReplacement[1] >> 16) & 255) / 255; k[1] = ((foundReplacement[1] >> 8) & 255) / 255; k[2] = (foundReplacement[1] & 255) / 255; } //console.log('foundReplacement!', foundReplacement, color.toString(16), k); break; } if(smth.hasOwnProperty('it')) { iterateIt(smth.it); } } }; for(let layer of object.layers) { if(!layer.shapes) continue; for(let shape of layer.shapes) { iterateIt(shape.it); } } } public async loadAnimationWorker(params: RLottieOptions, group = '', toneIndex = -1) { params.autoplay = true; if(toneIndex >= 1 && toneIndex <= 5) { this.applyReplacements(params.animationData, toneIndex); } if(!this.loaded) { await this.loadLottieWorkers(); } if(!params.width || !params.height) { params.width = parseInt(params.container.style.width); params.height = parseInt(params.container.style.height); } if(!params.width || !params.height) { throw new Error('No size for sticker!'); } const player = this.initPlayer(params.container, params); (this.byGroups[group] ?? (this.byGroups[group] = [])).push(player); return player; } public checkAnimations(blurred?: boolean, group?: string, destroy = false) { const groups = group /* && false */ ? [group] : Object.keys(this.byGroups); if(group && !this.byGroups[group]) { console.warn('no animation group:', group); this.byGroups[group] = []; //return; } for(const group of groups) { const animations = this.byGroups[group]; animations.forEach(player => { this.checkAnimation(player, blurred, destroy); }); } } public checkAnimation(player: RLottiePlayer, blurred = false, destroy = false) { if(destroy || (!isInDOM(player.el) && player.listenerResults.hasOwnProperty('ready'))) { //console.log('destroy animation'); player.destroy(); return; } if(blurred) { if(!player.paused) { this.debug && console.log('pause animation', player); player.pause(); } } else if(player.paused && this.visible.has(player) && player.autoplay) { this.debug && console.log('play animation', player); player.play(); } } private onPlayerLoaded(reqId: number, frameCount: number, fps: number) { const rlPlayer = this.players[reqId]; if(!rlPlayer) { this.debug && console.warn('onPlayerLoaded on destroyed player:', reqId, frameCount); return; } rlPlayer.el.appendChild(rlPlayer.canvas); rlPlayer.curFrame = rlPlayer.direction == 1 ? 0 : frameCount - 1; rlPlayer.frameCount = frameCount; rlPlayer.fps = fps; rlPlayer.sendQuery('renderFrame', 0); rlPlayer.setListenerResult('ready'); this.observer.observe(rlPlayer.el); } private onFrame(reqId: number, frameNo: number, frame: Uint8ClampedArray) { const rlPlayer = this.players[reqId]; if(!rlPlayer) { this.debug && console.warn('onFrame on destroyed player:', reqId, frameNo); return; } rlPlayer.renderFrame(frame, frameNo); } public onDestroy(reqId: number) { let player = this.players[reqId]; for(let group in this.byGroups) { this.byGroups[group].findAndSplice(p => p == player); } delete this.players[player.reqId]; this.observer.unobserve(player.el); this.visible.delete(player); } public destroyWorkers() { this.workers.forEach((worker, idx) => { worker.terminate(); console.log('worker #' + idx + ' terminated'); }); console.log('workers destroyed'); this.workers.length = 0; } private initPlayer(el: HTMLElement, options: RLottieOptions) { const rlPlayer = new RLottiePlayer({ el, worker: this.workers[this.curWorkerNum++], options }); this.players[rlPlayer.reqId] = rlPlayer; if(this.curWorkerNum >= this.workers.length) { this.curWorkerNum = 0; } rlPlayer.loadFromData(options.animationData); return rlPlayer; } } const lottieLoader = new LottieLoader(); (window as any).LottieLoader = lottieLoader; export default lottieLoader;