blob: 81ae5df4edbe07f1fe92cfe97c78bc082b2da2ec [file] [log] [blame]
/**
* @module skottie-sk
* @description <h2><code>skottie-sk</code></h2>
*
* <p>
* The main application element for skottie.
* </p>
*
*/
import '../skottie-config-sk'
import '../skottie-player-sk'
import 'elements-sk/collapse-sk'
import 'elements-sk/error-toast-sk'
import { $$ } from 'common-sk/modules/dom'
import { SKIA_VERSION } from '../../build/version.js'
import { errorMessage } from 'elements-sk/errorMessage'
import { html, render } from 'lit-html'
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'
import { setupListeners, onUserEdit, reannotate} from '../lottie-annotations'
const JSONEditor = require('jsoneditor/dist/jsoneditor-minimalist.js');
const bodymovin = require('lottie-web/build/player/lottie.min.js');
const DIALOG_MODE = 1;
const LOADING_MODE = 2;
const LOADED_MODE = 3;
const displayDialog = (ele) => html`
<skottie-config-sk state=${ele._state}></skottie-config-sk>
`;
const skottiePlayer = (ele) => html`
<skottie-player-sk paused width=${ele._state.width} height=${ele._state.height}>
</skottie-player-sk>
<figcaption>
skottie-wasm
</figcaption>`;
const livePreview = (ele) => {
if (ele._hasEdits) {
return html`
<figure>
<div id=live title=live-preview
style='width: ${ele._state.width}px; height: ${ele._state.height}px'></div>
<figcaption>Preview [lottie-web]</figcaption>
</figure>`;
} else {
return '';
}
}
const iframeDirections = (ele) => {
return `<iframe width="${ele._state.width}" height="${ele._state.height}" src="${window.location.origin}/e/${ele._hash}" scrolling=no>`;
}
const inlineDirections = (ele) => {
return `<skottie-inline-sk width="${ele._state.width}" height="${ele._state.height}" src="${window.location.origin}/_/j/${ele._hash}"></skottie-inline-sk>`;
}
const displayLoaded = (ele) => html`
<button class=edit-config @click=${ ele._startEdit}>${ele._state.filename} ${ele._state.width}x${ele._state.height} ${ele._state.fps} fps ...</button>
<button @click=${ele._rewind}>Rewind</button>
<button id=playpause @click=${ele._playpause}>Pause</button>
<button ?hidden=${!ele._hasEdits} @click=${ele._applyEdits}>Apply Edits</button>
<div class=download>
<a target=_blank download=${ele._state.filename} href=${ele._downloadUrl}>
JSON
</a>
${ele._hasEdits? '(without edits)': ''}
</div>
<button @click=${ele._toggleEmbed}>Embed</button>
<collapse-sk id=embed closed>
<p>
<label>Embed using an iframe: <input size=120 value="${iframeDirections(ele)}" scrolling=no></label>
</p>
<p>
<label>Embed on skia.org: <input size=140 value="${inlineDirections(ele)}" scrolling=no></label>
</p>
</collapse-sk>
<section class=figures>
<figure>
${skottiePlayer(ele)}
</figure>
<figure>
<div id=container title=lottie-web
style='width: ${ele._state.width}px; height: ${ele._state.height}px'></div>
<figcaption>lottie-web (${bodymovin.version})</figcaption>
</figure>
${livePreview(ele)}
</section>
<section class=editor>
<div id=json_editor></div>
</section>
`;
const displayLoading = (ele) => html`
<div class=loading>
<spinner-sk active></spinner-sk><span>Loading...</span>
</div>
`;
// pick the right part of the UI to display based on ele._ui.
const pick = (ele) => {
switch (ele._ui) {
case DIALOG_MODE:
return displayDialog(ele);
case LOADING_MODE:
return displayLoading(ele);
case LOADED_MODE:
return displayLoaded(ele);
}
};
const redir = (ele) => {
if (window.location.hostname != 'skottie-internal.skia.org') {
return html`<div>Googlers should use <a href="https://skottie-internal.skia.org">skottie-internal.skia.org</a>.</div>`;
} else {
return html``;
}
};
const template = (ele) => html`
<header>
<h2>Skottie</h2><span><a href='https://skia.googlesource.com/skia/+/${SKIA_VERSION}'>${SKIA_VERSION.slice(0, 7)}</a></span>
</header>
<main>
${pick(ele)}
</main>
<footer>
<error-toast-sk></error-toast-sk>
${redir(ele)}
</footer>
`;
window.customElements.define('skottie-sk', class extends HTMLElement {
constructor() {
super();
this._state = {
filename: '',
lottie: null,
width: 256,
height: 256,
fps: 30,
};
// One of 'dialog', 'loading', or 'loaded'
this._ui = DIALOG_MODE;
this._hash = '';
this._lottie = null;
this._live = null;
this._playing = true;
this._downloadUrl = null; // The URL to download the lottie JSON from.
this._editor = null;
this._editorLoaded = false;
this._hasEdits = false;
this._duration = 0; // _duration = 0 is a sentinel value for "player not loaded yet"
// The wasm animation computes how long it has been since it started and
// use arithmetic to figure out where to seek (i.e. which frame to draw).
this._firstFrameTime = null;
// used for remembering where we were in the animation when paused.
this._wasmTimePassed = 0;
}
connectedCallback() {
this._reflectFromURL();
this.addEventListener('skottie-selected', this)
this.addEventListener('cancelled', this)
window.addEventListener('popstate', this)
this._render();
// Start a continous animation loop.
const drawFrame = () => {
window.requestAnimationFrame(drawFrame);
// Elsewhere, the _firstFrameTime is set to null to restart
// the animation. If null, we assume the user hit re-wind
// and restart both the Skottie animation and the lottie-web one.
// This avoids the (small) boot-up lag while we wait for the
// skottie animation to be parsed and loaded.
if (!this._firstFrameTime && this._playing) {
this._firstFrameTime = Date.now();
}
if (this._playing && this._duration > 0) {
let progress = (Date.now() - this._firstFrameTime) % this._duration;
// If we want to have synchronized playing, it's best to force
// all players to draw the same frame rather than letting them play
// on their own timeline.
let player = this.querySelector('skottie-player-sk');
player && player.seek(progress / this._duration);
this._lottie.goToAndStop(progress);
this._live && this._live.goToAndStop(progress);
}
}
window.requestAnimationFrame(drawFrame);
}
disconnectedCallback() {
this.removeEventListener('skottie-selected', this)
this.removeEventListener('cancelled', this)
}
attributeChangedCallback(name, oldValue, newValue) {
this._render();
}
_initializePlayer() {
let player = this.querySelector('skottie-player-sk');
player.initialize({
width: this._state.width,
height: this._state.height,
lottie: this._state.lottie,
}).then(() => {
this._duration = player.duration();
});
}
_reflectFromURL() {
// Check URL.
let match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/);
if (!match) {
// Make this the hash of the lottie file you want to play on startup.
this._hash = '1112d01d28a776d777cebcd0632da15b'; // gear.json
} else {
this._hash = match[1];
}
this._ui = LOADING_MODE;
this._render();
// Run this on the next micro-task to allow mocks to be set up if needed.
setTimeout(() => {
fetch(`/_/j/${this._hash}`, {
credentials: 'include',
}).then(jsonOrThrow).then(json => {
this._state = json;
this._ui = LOADED_MODE;
this._render();
this._initializePlayer();
}).catch((msg) => {
errorMessage(msg);
window.history.pushState(null, '', '/');
this._ui = DIALOG_MODE;
this._render();
});
});
}
_applyEdits() {
if (!this._editor || !this._editorLoaded || !this._hasEdits) {
return;
}
this._state.lottie = this._editor.get();
this._upload();
}
_startEdit() {
this._ui = DIALOG_MODE;
this._render();
}
_upload() {
// POST the JSON along with options to /_/upload
this._hash = '';
this._hasEdits = false;
this._editorLoaded = false;
this._editor = null;
// Clean up the old animation and other wasm objects
this._render();
fetch('/_/upload', {
credentials: 'include',
body: JSON.stringify(this._state),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
}).then(jsonOrThrow).then(json => {
// Should return with the hash.
this._ui = LOADED_MODE;
this._hash = json.hash;
window.history.pushState(null, '', '/' + this._hash);
this._render();
this._initializePlayer();
// Re-sync all players
this._rewind();
}).catch(msg => {
errorMessage(msg);
window.history.pushState(null, '', '/');
this._ui = DIALOG_MODE;
this._render();
});
this._ui = LOADED_MODE;
this._render();
}
_playpause() {
if (this._playing) {
this._wasmTimePassed = Date.now() - this._firstFrameTime;
this._lottie.pause();
this._live && this._live.pause();
$$("#playpause").textContent = 'Play';
} else {
this._lottie.play();
this._live && this._live.play();
this._firstFrameTime = Date.now() - (this._wasmTimePassed || 0);
$$("#playpause").textContent = 'Pause';
}
this._playing = !this._playing;
}
_rewind(e) {
// Handle rewinding when paused.
this._wasmTimePassed = 0;
if (!this._playing) {
this._live && this._live.goToAndStop(0);
this._lottie.goToAndStop(0);
this._firstFrameTime = null;
this.querySelector('skottie-player-sk').seek(0);
} else {
this._live && this._live.goToAndPlay(0);
this._lottie.goToAndPlay(0);
this._firstFrameTime = null;
}
}
_render() {
if (this._downloadUrl) {
URL.revokeObjectURL(this._downloadUrl);
}
this._downloadUrl = URL.createObjectURL(new Blob([JSON.stringify(this._state.lottie, null, ' ')]));
render(template(this), this, {eventContext: this});
if (this._ui == LOADED_MODE) {
// Don't re-start the animation while the user edits.
if (!this._hasEdits) {
$$('#container').innerHTML = '';
this._lottie = bodymovin.loadAnimation({
container: $$('#container'),
renderer: 'svg',
loop: true,
autoplay: this._playing,
// Apparently the lottie player modifies the data as it runs?
animationData: JSON.parse(JSON.stringify(this._state.lottie)),
rendererSettings: {
preserveAspectRatio:'xMidYMid meet'
},
});
this._live = null;
} else {
// we have edits, update the live preview version.
// It will re-start from the very beginning, but the user can
// hit "rewind" to re-sync them.
$$('#live').innerHTML = '';
this._live = bodymovin.loadAnimation({
container: $$('#live'),
renderer: 'svg',
loop: true,
autoplay: this._playing,
// Apparently the lottie player modifies the data as it runs?
animationData: JSON.parse(JSON.stringify(this._editor.get())),
rendererSettings: {
preserveAspectRatio:'xMidYMid meet'
},
});
}
let editorContainer = $$('#json_editor');
// See https://github.com/josdejong/jsoneditor/blob/master/docs/api.md
// for documentation on this editor.
let editorOptions = {
sortObjectKeys: true,
// There are sometimes a few onChange events that happen
// during the initial .set(), so we have a safety variable
// _editorLoaded to prevent a bunch of recursion
onChange: () => {
if (!this._editorLoaded) {
return;
}
this._hasEdits = true;
onUserEdit(editorContainer, this._editor.get());
this._render();
}
};
if (!this._editor) {
this._editorLoaded = false;
editorContainer.innerHTML = '';
this._editor = new JSONEditor(editorContainer, editorOptions);
setupListeners(editorContainer);
}
if (!this._hasEdits) {
this._editorLoaded = false;
// Only set the JSON when it is loaded, either because it's
// the first time we got it from the server or because the user
// hit applyEdits.
this._editor.set(this._state.lottie);
}
reannotate(editorContainer, this._state.lottie);
// We are now pretty confident that the onChange events will only be
// when the user modifies the JSON.
this._editorLoaded = true;
}
}
_toggleEmbed() {
let collapse = $$('#embed', this);
collapse.closed = !collapse.closed;
}
handleEvent(e) {
if (e.type == 'skottie-selected') {
this._state = e.detail;
this._upload();
} else if (e.type == 'cancelled') {
this._ui = LOADED_MODE;
this._editor = null;
this._render();
} else if (e.type == 'popstate') {
this._reflectFromURL();
}
}
});