| /** |
| * @module skottie-player-sk |
| * @description <h2><code>skottie-player-sk</code></h2> |
| * |
| * <p> |
| * Displays a CanvasKit-based Skottie animation and provides various controls. |
| * </p> |
| * |
| */ |
| import { $$ } from 'common-sk/modules/dom'; |
| import 'elements-sk/icon/pause-icon-sk'; |
| import 'elements-sk/icon/play-arrow-icon-sk'; |
| import 'elements-sk/icon/settings-icon-sk'; |
| import 'elements-sk/spinner-sk'; |
| import { define } from 'elements-sk/define'; |
| import { html, render } from 'lit-html'; |
| import { repeat } from 'lit-html/directives/repeat'; |
| |
| const CanvasKitInit = require('../../build/canvaskit/canvaskit.js'); |
| |
| const loadingTemplate = (ele) => html` |
| <div class=player-loading title="Loading animation and engine." |
| style='width: ${ele._config.width}px; height: ${ele._config.height}px;'> |
| <div>Loading</div> |
| <spinner-sk active></spinner-sk> |
| </div>`; |
| |
| const settingsTemplate = (ele) => html` |
| <div class=skottie-player-settings-container ?hidden=${!ele._state.showSettings}> |
| <div class=skottie-player-settings-row> |
| <div class=skottie-player-settings-label>Colors</div> |
| <select id=color-prop-select class=skottie-player-property-select |
| @input=${ele._onPropertySelect} ?disabled=${ele._props.color.empty()}> |
| ${repeat(ele._props.color.list, (c) => c.key, (c, index) => html` |
| <option value=${index}>${c.key}</option> |
| `)} |
| <select> |
| <input type=color class=skottie-player-picker id=color-picker |
| value=${hexColor(ele._props.color.current().value)} |
| @input=${ele._onColorInput} ?disabled=${ele._props.color.empty()}> |
| <hr class=skottie-player-settings-divider> |
| </div> |
| <div class=skottie-player-settings-row> |
| <div class=skottie-player-settings-label>Opacity</div> |
| <select id=opacity-prop-select class=skottie-player-property-select |
| @input=${ele._onPropertySelect} ?disabled=${ele._props.opacity.empty()}> |
| ${repeat(ele._props.opacity.list, (o) => o.key, (o, index) => html` |
| <option value=${index}>${o.key}</option> |
| `)} |
| <select> |
| <input type=range min=0 max=100 class=skottie-player-picker id=opacity-picker |
| value=${ele._props.opacity.current().value} |
| @input=${ele._onOpacityInput} ?disabled=${ele._props.opacity.empty()}> |
| <hr class=skottie-player-settings-divider> |
| </div> |
| <div class=skottie-player-settings-row> |
| <div class=skottie-player-settings-label>Segments</div> |
| <select id=segment-prop-select class=skottie-player-property-select |
| style='width: 100%' @input=${ele._onPropertySelect}> |
| ${repeat(ele._props.segments, (s) => s.name, (s, index) => html` |
| <option value=${index}>${segmentLabel(s)}</option> |
| `)} |
| <select> |
| <hr class=skottie-player-settings-divider> |
| </div> |
| <div class=skottie-player-settings-row> |
| <input type=button value=Close @click=${ele._onSettings}> |
| </div> |
| </div> |
| `; |
| |
| function segmentLabel(s) { |
| return `${s.name} [${s.t0.toFixed(2)} .. ${s.t1.toFixed(2)}]`; |
| } |
| |
| function hexColor(c) { |
| const rgb = c & 0x00ffffff; |
| return `#${rgb.toString(16).padStart(6, '0')}`; |
| } |
| |
| function skRectIsEmpty(rect) { |
| if (!rect) { |
| return true; |
| } |
| if (rect.constructor === Float32Array) { |
| return rect[2] <= rect[0] || rect[3] <= rect[1]; |
| } |
| // TODO(kjlubick) remove this deprecated rectangle format after the array version lands in the |
| // Skia repo. |
| return rect.fRight <= rect.fLeft || rect.fBottom <= rect.fTop; |
| } |
| |
| const runningTemplate = (ele) => html` |
| <div class=container> |
| <div class=wrapper> |
| <canvas class=skottie-canvas id=skottie |
| width=${ele._config.width * window.devicePixelRatio} |
| height=${ele._config.height * window.devicePixelRatio} |
| style='width: ${ele._config.width}px; height: ${ele._config.height}px; background-color: ${ele._config.bgColor}'> |
| Your browser does not support the canvas tag. |
| </canvas> |
| <div class=controls ?hidden=${!ele._config.controls}> |
| <play-arrow-icon-sk @click=${ele._onPlay} ?hidden=${!ele._state.paused}></play-arrow-icon-sk> |
| <pause-icon-sk @click=${ele._onPause} ?hidden=${ele._state.paused}></pause-icon-sk> |
| <input type=range min=0 max=100 @input=${ele._onScrub} @change=${ele._onScrubEnd} |
| class=skottie-player-scrubber> |
| <settings-icon-sk @click=${ele._onSettings}></settings-icon-sk> |
| </div> |
| </div> |
| ${settingsTemplate(ele)} |
| </div>`; |
| |
| // This element might be loaded from a different site, and that means we need |
| // to be careful about how we construct the URL back to the canvas.wasm file. |
| // Start by recording the script origin. |
| const scriptOrigin = new URL(document.currentScript.src).origin; |
| |
| const canvasReady = CanvasKitInit({ |
| locateFile: (file) => `${scriptOrigin}/static/${file}`, |
| }); |
| |
| define('skottie-player-sk', class extends HTMLElement { |
| constructor() { |
| super(); |
| |
| this._engine = { |
| kit: null, // CanvasKit instance |
| context: null, // CK context. |
| animation: null, // Skottie Animation instance |
| surface: null, // SkSurface |
| canvas: null, // Cached SkCanvas (surface.getCanvas()). |
| }; |
| |
| this._state = { |
| loading: true, |
| paused: this.hasAttribute('paused'), |
| scrubPlaying: false, // Animation was playing when the user started scrubbing. |
| duration: 0, // Animation duration (ms). |
| nativeFps: 0, // Animation fps. |
| timeOrigin: 0, // Animation start time (ms). |
| seekPoint: 0, // Normalized [0..1] animation progress. |
| showSettings: (new URL(document.location)).searchParams.has('settings'), |
| currentSegment: { name: '', t0: 0, t1: 1 }, // One of the _props.segments |
| }; |
| |
| function PropList(list, defaultVal) { |
| this.list = list; |
| this.defaultVal = defaultVal; |
| this.index = 0; |
| this.empty = () => !this.list.length; |
| this.current = () => (this.index >= this.list.length |
| ? this.defaultVal |
| : this.list[this.index]); |
| } |
| |
| this._props = { |
| color: new PropList([], 0.0), // Configurable color properties |
| opacity: new PropList([], 1.0), // Configurable opacity properties |
| segments: [], // Selectable animation segments |
| }; |
| } |
| |
| connectedCallback() { |
| const params = (new URL(document.location)).searchParams; |
| this._config = { |
| width: this.hasAttribute('width') ? this.getAttribute('width') : 256, |
| height: this.hasAttribute('height') ? this.getAttribute('height') : 256, |
| controls: params.has('controls'), |
| bgColor: params.has('bg') ? params.get('bg') : '#fff', |
| }; |
| this._render(); |
| } |
| |
| initialize(config) { |
| this._config.width = config.width; |
| this._config.height = config.height; |
| this._config.fps = config.fps; |
| this._animationName = config.lottie.nm; |
| |
| this._render(); |
| return canvasReady.then((ck) => { |
| // Set a large-ish decode cache limit to accommodate potentially large images. |
| const CACHE_SIZE = 512 * 1024 * 1024; |
| ck.setDecodeCacheLimitBytes(CACHE_SIZE); |
| |
| this._engine.kit = ck; |
| this._initializeSkottie(config.lottie, config.assets, config.soundMap); |
| this._render(); |
| }); |
| } |
| |
| duration() { |
| return this._state.duration * (this._state.currentSegment.t1 - this._state.currentSegment.t0); |
| } |
| |
| fps() { |
| return this._state.nativeFps; |
| } |
| |
| animationName() { |
| return this._animationName; |
| } |
| |
| canvas() { |
| return this.querySelector(".skottie-canvas"); |
| } |
| |
| seek(t, forceRender = false) { |
| this._state.timeOrigin = (Date.now() - this.duration() * t); |
| |
| if (!this.isPlaying()) { |
| // Force-draw a static frame when paused. |
| this._updateSeekPoint(); |
| this._drawFrame(forceRender); |
| } |
| } |
| |
| isPlaying() { |
| return !this._state.paused; |
| } |
| |
| pause() { |
| if (this.isPlaying()) { |
| this._state.paused = true; |
| // Save the exact/current seek point at pause time. |
| this._updateSeekPoint(); |
| } |
| } |
| |
| play() { |
| if (!this.isPlaying()) { |
| this._state.paused = false; |
| // Shift timeOrigin to continue from where we paused. |
| this.seek(this._state.seekPoint); |
| this._drawFrame(); |
| } |
| } |
| |
| _initializeSkottie(lottieJSON, assets, soundMap) { |
| this._state.loading = false; |
| |
| // Rebuild the surface only if needed. |
| if (!this._engine.surface |
| || this._engine.surface.width !== this._config.width |
| || this._engine.surface.height !== this._config.height) { |
| this._render(); |
| |
| if (this._engine.surface) { |
| this._engine.surface.delete(); |
| } |
| const canvasEle = $$('#skottie', this); |
| this._engine.surface = this._engine.kit.MakeCanvasSurface(canvasEle); |
| if (!this._engine.surface) { |
| throw new Error('Could not make SkSurface.'); |
| } |
| // We don't need to call .delete() on the canvas because |
| // the parent surface will do that for us. |
| this._engine.canvas = this._engine.surface.getCanvas(); |
| |
| this._engine.context = this._engine.kit.currentContext(); |
| } |
| |
| if (this._engine.animation) { |
| this._engine.animation.delete(); |
| } |
| |
| this._engine.animation = this._engine.kit.MakeManagedAnimation( |
| JSON.stringify(lottieJSON), assets, null, soundMap |
| ); |
| if (!this._engine.animation) { |
| throw new Error('Could not parse Lottie JSON.'); |
| } |
| |
| this._state.duration = this._engine.animation.duration() * 1000; |
| this._state.nativeFps = this._engine.animation.fps(); |
| this.seek(0); |
| |
| this._props.color.list = this._engine.animation.getColorProps(); |
| this._props.opacity.list = this._engine.animation.getOpacityProps(); |
| this._props.segments = [{ name: 'Full timeline', t0: 0, t1: 1 }] |
| .concat(this._engine.animation.getMarkers()); |
| this._currentSegment = this._props.segments[0]; |
| |
| this._render(); // re-render for animation-dependent elements (properties, etc). |
| |
| this._drawFrame(true); |
| } |
| |
| _updateSeekPoint() { |
| // t is in animation segment domain. |
| const t = ((Date.now() - this._state.timeOrigin) / this.duration()) % 1; |
| |
| // map to the global animation timeline |
| this._state.seekPoint = this._state.currentSegment.t0 |
| + t * (this._state.currentSegment.t1 - this._state.currentSegment.t0); |
| if (this._config.controls) { |
| const scrubber = this.querySelector('.skottie-player-scrubber'); |
| if (scrubber) { |
| scrubber.value = this._state.seekPoint * 100; |
| } |
| } |
| } |
| |
| _drawFrame(forceRender) { |
| if (!this._engine.animation || !this._engine.canvas) { |
| return; |
| } |
| |
| // When paused, the progress is fully controlled externally. |
| if (this.isPlaying()) { |
| this._updateSeekPoint(); |
| window.requestAnimationFrame(this._drawFrame.bind(this)); |
| } |
| |
| let frame = this._state.seekPoint * this._state.duration * this._state.nativeFps / 1000; |
| if (this._config.fps) { |
| // When a render FPS is specified, quantize to the desired rate. |
| const fpsScale = this._config.fps / this._state.nativeFps; |
| frame = Math.trunc(frame * fpsScale) / fpsScale; |
| } |
| |
| this._engine.kit.setCurrentContext(this._engine.context); |
| const damage = this._engine.animation.seekFrame(frame); |
| // Only draw frames when the content changes. |
| if (forceRender || !skRectIsEmpty(damage)) { |
| const bounds = this._engine.kit.LTRBRect(0, 0, this._config.width * window.devicePixelRatio, |
| this._config.height * window.devicePixelRatio); |
| this._engine.animation.render(this._engine.canvas, bounds); |
| this._engine.surface.flush(); |
| } |
| } |
| |
| _render() { |
| render(this._state.loading |
| ? loadingTemplate(this) |
| : runningTemplate(this), |
| this, { eventContext: this }); |
| } |
| |
| _onPlay() { |
| this.play(); |
| this._render(); |
| } |
| |
| _onPause() { |
| this.pause(); |
| this._render(); |
| } |
| |
| // This fires every time the user moves the scrub slider. |
| _onScrub(e) { |
| this.seek(e.currentTarget.value / 100); |
| |
| // Pause the animation while dragging the slider. |
| if (this.isPlaying()) { |
| this._state.scrubPlaying = true; |
| this.pause(); |
| } |
| } |
| |
| // This fires when the user releases the scrub slider. |
| _onScrubEnd(e) { |
| if (this._state.scrubPlaying) { |
| this._state.scrubPlaying = false; |
| this.play(); |
| } |
| } |
| |
| _onSettings() { |
| this._state.showSettings = !this._state.showSettings; |
| this._render(); |
| } |
| |
| _onPropertySelect(e) { |
| switch (e.target.id) { |
| case 'color-prop-select': |
| this._props.color.index = e.target.value; |
| this.querySelector('#color-picker').value = hexColor(this._props.color.current().value); |
| break; |
| case 'opacity-prop-select': |
| this._props.opacity.index = e.target.value; |
| this.querySelector('#opacity-picker').value = this._props.opacity.current().value; |
| break; |
| case 'segment-prop-select': |
| this._state.currentSegment = this._props.segments[e.target.value]; |
| this.seek(0); |
| this._render(); |
| break; |
| } |
| } |
| |
| _onColorInput(e) { |
| const val = e.target.value; |
| const prop = this._props.color.current(); |
| prop.value = this._engine.kit.Color(parseInt(val.substring(1, 3), 16), |
| parseInt(val.substring(3, 5), 16), |
| parseInt(val.substring(5, 7), 16), |
| 1.0); // Treat colors as fully opaque. |
| |
| this._engine.animation.setColor(prop.key, prop.value); |
| this._render(); |
| |
| if (!this.isPlaying()) { |
| this._drawFrame(); |
| } |
| } |
| |
| _onOpacityInput(e) { |
| const prop = this._props.opacity.current(); |
| prop.value = Number(e.target.value); |
| |
| this._engine.animation.setOpacity(prop.key, prop.value); |
| this._render(); |
| |
| if (!this.isPlaying()) { |
| this._drawFrame(); |
| } |
| } |
| }); |