blob: 051e788481f9fdf3748b45122a5145392e24c238 [file] [log] [blame]
/**
* @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._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);
}
seek(t) {
this._state.timeOrigin = (Date.now() - this.duration() * t);
if (!this.isPlaying()) {
// Force-draw a static frame when paused.
this._updateSeekPoint();
this._drawFrame();
}
}
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(firstFrame) {
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 (firstFrame || !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();
}
}
});