blob: 2b1d15a69496f64fb6b19c808172b3dbe027d33c [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/modules/checkbox-sk';
import '../../../elements-sk/modules/collapse-sk';
import '../../../elements-sk/modules/error-toast-sk';
import Ajv from 'ajv/dist/2020';
import { html, TemplateResult } from 'lit/html.js';
import {
JSONEditor,
toJSONContent,
createAjvValidator,
JSONEditorPropsOptional,
} from 'vanilla-jsoneditor';
import LottiePlayer from 'lottie-web';
import { RendererType } from 'lottie-web';
import { $$ } from '../../../infra-sk/modules/dom';
import { errorMessage } from '../../../elements-sk/modules/errorMessage';
import { define } from '../../../elements-sk/modules/define';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import { stateReflector } from '../../../infra-sk/modules/stateReflector';
import { CollapseSk } from '../../../elements-sk/modules/collapse-sk/collapse-sk';
import { SkottieGifExporterSk } from '../skottie-gif-exporter-sk/skottie-gif-exporter-sk';
import '../skottie-gif-exporter-sk';
import '../skottie-text-editor-sk';
import '../skottie-library-sk';
import { SoundMap, AudioPlayer } from '../audio';
import '../skottie-performance-sk';
import '../skottie-compatibility-sk';
import { renderByDomain } from '../helpers/templates';
import { isDomain } from '../helpers/domains';
import '../skottie-audio-sk';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import {
SkottieConfigEventDetail,
SkottieConfigState,
} from '../skottie-config-sk/skottie-config-sk';
import { SkottiePlayerSk } from '../skottie-player-sk/skottie-player-sk';
import { SkottiePerformanceSk } from '../skottie-performance-sk/skottie-performance-sk';
import { FontAsset, LottieAnimation, LottieAsset, ViewMode } from '../types';
import { SkottieLibrarySk } from '../skottie-library-sk/skottie-library-sk';
import { AudioStartEventDetail, SkottieAudioSk } from '../skottie-audio-sk/skottie-audio-sk';
import {
SkottieTextEditorSk,
TextEditEventDetail,
} from '../skottie-text-editor-sk/skottie-text-editor-sk';
import '../skottie-shader-editor-sk';
import {
ShaderEditApplyEventDetail,
ShaderEditorSk,
} from '../skottie-shader-editor-sk/skottie-shader-editor-sk';
import '../../../infra-sk/modules/theme-chooser-sk';
import '../../../infra-sk/modules/app-sk';
import { replaceShaders } from '../skottie-shader-editor-sk/shader-replace';
import '../../../elements-sk/modules/icons/expand-less-icon-sk';
import '../../../elements-sk/modules/icons/expand-more-icon-sk';
import '../../../elements-sk/modules/icons/play-arrow-icon-sk';
import '../../../elements-sk/modules/icons/pause-icon-sk';
import '../../../elements-sk/modules/icons/replay-icon-sk';
import '../../../elements-sk/modules/icons/file-download-icon-sk';
import '../skottie-button-sk';
import '../skottie-dropdown-sk';
import { DropdownSelectEvent } from '../skottie-dropdown-sk/skottie-dropdown-sk';
import '../skottie-exporter-sk';
import { ExportType, SkottieExporterSk } from '../skottie-exporter-sk/skottie-exporter-sk';
import '../skottie-file-settings-sk';
import {
SkottieFileSettingsSk,
SkottieFileSettingsEventDetail,
} from '../skottie-file-settings-sk/skottie-file-settings-sk';
import '../skottie-file-form-sk';
import { SkottieFilesEventDetail } from '../skottie-file-form-sk/skottie-file-form-sk';
import '../skottie-background-settings-sk';
import { SkottieBackgroundSettingsEventDetail } from '../skottie-background-settings-sk/skottie-background-settings-sk';
import '../skottie-color-manager-sk';
import '../skottie-slot-manager-sk';
import { SkottieTemplateEventDetail } from '../skottie-color-manager-sk/skottie-color-manager-sk';
import { isBinaryAsset } from '../helpers/animation';
import '../window/window';
import { SkottieSlotManagerSk } from '../skottie-slot-manager-sk/skottie-slot-manager-sk';
import { lottieSchema } from '../skottie-compatibility-sk/schemas/lottie.schema';
// It is assumed that this symbol is being provided by a version.js file loaded in before this
// file.
declare const SKIA_VERSION: string;
interface BodymovinPlayer {
goToAndStop(t: number): void;
goToAndPlay(t: number): void;
pause(): void;
play(): void;
destroy(): void;
}
interface LoadedAsset {
name: string;
bytes?: ArrayBuffer;
player?: AudioPlayer;
}
const GOOGLE_WEB_FONTS_HOST = 'https://storage.googleapis.com/skia-cdn/google-web-fonts';
const PRODUCTION_ASSETS_PATH = '/_/a';
// Make this the hash of the lottie file you want to play on startup.
const DEFAULT_LOTTIE_FILE = '5c1c5cc9aa4aabe4acc1f12a7bac60fb'; // gear.json
// SCRUBBER_RANGE is the input range for the scrubbing control.
// This is an arbitrary value, and is treated as a re-scaled duration.
const SCRUBBER_RANGE = 1000;
// window.skottie is null in tests.
const SUPPORTED_DOMAINS = {
SKOTTIE_INTERNAL: window.skottie
? window.skottie.internal_site_domain
: 'skottie-internal.corp.goog',
SKOTTIE_TENOR: window.skottie ? window.skottie.tenor_site_domain : 'skottie-tenor.corp.goog',
SKOTTIE: window.skottie ? window.skottie.public_site_domain : 'skottie.skia.org',
LOCALHOST: 'localhost',
};
const AUDIO_SUPPORTED_DOMAINS = [
SUPPORTED_DOMAINS.SKOTTIE_INTERNAL,
SUPPORTED_DOMAINS.SKOTTIE_TENOR,
SUPPORTED_DOMAINS.LOCALHOST,
];
type UIMode = 'loading' | 'loaded' | 'idle' | 'draft' | 'unsynced' | 'synced';
type ToolType =
| 'none'
| 'skottie-library'
| 'text-edits'
| 'shader-edits'
| 'background-color'
| 'json-editor'
| 'color-manager'
| 'skottie-font'
| 'skottie-player'
| 'lottie-player'
| 'slot-manager';
const caption = (text: string, mode: ViewMode) => {
if (mode === 'presentation') {
return null;
}
return html` <figcaption>${text}</figcaption> `;
};
const redir = () =>
renderByDomain(
html` <div>
Googlers should use
<a href="https://${SUPPORTED_DOMAINS.SKOTTIE_INTERNAL}"
>${SUPPORTED_DOMAINS.SKOTTIE_INTERNAL}</a
>.
</div>`,
Object.values(SUPPORTED_DOMAINS).filter(
(domain: string) => domain !== SUPPORTED_DOMAINS.SKOTTIE_INTERNAL
)
);
const displayLoading = () => html`
<div class="loading"><spinner-sk active></spinner-sk><span>Loading...</span></div>
`;
export class SkottieSk extends ElementSk {
private static template = (ele: SkottieSk) => html`
<app-sk>
<div class="app-container">
<header>
<h2>Skottie Web Player</h2>
<span>
<a
href="https://skia.googlesource.com/skia/+show/${SKIA_VERSION}"
class="header__skia-version">
${SKIA_VERSION.slice(0, 7)}
</a>
<skottie-dropdown-sk
id="view-exporter"
.name="dropdown-exporter"
.options=${[
{ id: '', value: 'Export' },
{ id: 'gif', value: 'GIF' },
{ id: 'webM', value: 'WebM' },
{ id: 'png', value: 'PNG sequence' },
]}
reset
@select=${ele.exportSelectHandler}
border>
</skottie-dropdown-sk>
<skottie-button-sk
id="view-perf-chart"
@select=${ele.togglePerformanceChart}
type="outline"
.content=${'Performance chart'}
.classes=${['header__button', ele.showPerformanceChart ? 'active-dialog' : '']}>
</skottie-button-sk>
${ele.compatibilityReportOpen()}
<skottie-button-sk
id="view-json-layers"
@select=${ele.toggleEditor}
type="outline"
.content=${'View JSON code'}
.classes=${['header__button', ele.showJSONEditor ? 'active-dialog' : '']}>
</skottie-button-sk>
${ele.renderApplyChanges()}
<theme-chooser-sk></theme-chooser-sk>
</span>
</header>
<main>${ele.pick()}</main>
<footer>
<error-toast-sk></error-toast-sk>
${redir()}
</footer>
</div>
<skottie-exporter-sk
@start=${ele.onExportStart}
.downloadFileName=${ele.state.filename || 'Download'}>
</skottie-exporter-sk>
</app-sk>
`;
// pick the right part of the UI to display based on ele._ui.
private pick = () => {
switch (this.ui) {
default:
case 'idle':
return this.displayIdle();
case 'loading':
return displayLoading();
case 'loaded':
case 'unsynced':
case 'synced':
case 'draft':
return this.displayLoaded();
}
};
private renderApplyChanges = (): TemplateResult | null => {
if (!this.areChangesUploaded()) {
return html`<skottie-button-sk
id="view-json-layers"
@select=${this.applyEdits}
type="outline"
.content=${'Save all changes'}
.classes=${['header__button']}>
</skottie-button-sk>`;
}
return null;
};
private displayDialog = () => html`
<skottie-config-sk
.state=${this.state}
.width=${this.width}
.height=${this.height}
.fps=${this.fps}
.backgroundColor=${this.backgroundColor}
@skottie-selected=${this.skottieFileSelected}
@cancelled=${this.selectionCancelled}></skottie-config-sk>
`;
private displayIdle = () => html`
<div class="threecol">
<div class="left-panel">${this.leftControls()}</div>
<div class="main-panel"></div>
<div class="right-panel">${this.rightControls()}</div>
</div>
`;
private displayLoaded = () => html`
<div class="threecol">
<div class="left-panel">${this.leftControls()}</div>
<div class="main-panel">${this.mainContent()}</div>
<div class="right-panel">${this.rightControls()}</div>
</div>
`;
private mainContent = () => html`
<div class="players">
<figure class="players-container">
${this.skottiePlayerTemplate()} ${this.lottiePlayerTemplate()}
</figure>
</div>
<div class="playback">
<div class="playback-content">
<skottie-button-sk
id="playpause"
.content=${html`<play-arrow-icon-sk id="playpause-play"></play-arrow-icon-sk>
<pause-icon-sk id="playpause-pause"></pause-icon-sk>`}
.classes=${['playback-content__button']}
@select=${this.playpause}></skottie-button-sk>
<div class="scrub">
<input
id="scrub"
type="range"
min="0"
max=${SCRUBBER_RANGE}
step="0.1"
@input=${this.onScrub}
@change=${this.onScrubEnd} />
<label class="number">
Frame:
<input
type="number"
id="frameInput"
class="playback-content-frameInput"
@focus=${this.onFrameFocus}
@change=${this.onFrameChange} /><!--
--><span class="playback-content-frameTotal" id="frameTotal">of 0</span>
</label>
</div>
<skottie-button-sk
id="rewind"
.content=${html`<replay-icon-sk></replay-icon-sk>`}
.classes=${['playback-content__button']}
@select=${this.rewind}></skottie-button-sk>
</div>
</div>
<div @click=${this.onChartClick}>${this.performanceChartTemplate()}</div>
${this.jsonEditor()} ${this.gifExporter()} ${this.compatibilityReportTemplate()}
<collapse-sk id="volume" closed>
<p>Volume:</p>
<input
id="volume-slider"
type="range"
min="0"
max="1"
step=".05"
value="1"
@input=${this.onVolumeChange} />
</collapse-sk>
`;
private embedDialog() {
return html`
<details class="embed expando">
<summary id="embed-open">
<span>Embed</span><expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<label>
Embed using an iframe
<input value=${this.iframeDirections()} />
</label>
<label>
Embed on skia.org
<input value=${this.inlineDirections()} />
</label>
</details>
`;
}
private slotManager() {
return html`
<details class="expando">
<summary>
<span>Slot manager</span>
<expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<skottie-slot-manager-sk
.player=${this.skottiePlayer}
.animation=${this.state.lottie}
@slot-manager-change=${this.onSlotManagerUpdated}>
</skottie-slot-manager-sk>
</details>
`;
}
private compatibilityReportOpen() {
if (!this.enableCompatibilityReport) {
return null;
}
return html` <skottie-button-sk
id="view-compatibility-report"
@select=${this.toggleCompatibilityReport}
type="outline"
.content=${'Compatibility Report (Beta)'}
.classes=${['header__button', this.showCompatibilityReport ? 'active-dialog' : '']}>
</skottie-button-sk>`;
}
private compatibilityReportTemplate() {
return html`
<dialog class="compatibility-report" ?open=${this.showCompatibilityReport}>
<div class="top-ribbon">
<span>Compatibility Report</span>
<button @click=${this.toggleCompatibilityReport}>Close</button>
</div>
<skottie-compatibility-sk .animation=${this.state.lottie}> </skottie-compatibility-sk>
</dialog>
`;
}
private performanceChartTemplate() {
return html`
<dialog class="perf-chart" ?open=${this.showPerformanceChart}>
<div class="top-ribbon">
<span>Performance Chart</span>
<button @click=${this.togglePerformanceChart}>Close</button>
</div>
<skottie-performance-sk id="chart"></skottie-performance-sk>
</dialog>
`;
}
private leftControls = () => {
if (this.viewMode === 'presentation') {
return null;
}
return html` <div class="json-chooser">
<div class="title">JSON File</div>
${this.renderDownload()}
<skottie-file-form-sk @files-selected=${this.skottieFilesSelected}></skottie-file-form-sk>
</div>
${this.fileSettingsDialog()} ${this.slotManager()} ${this.colorManager()}
${this.backgroundDialog()} ${this.audioDialog()} ${this.optionsDialog()}`;
};
private rightControls = () => html`
${this.jsonTextEditor()} ${this.library()} ${this.embedDialog()}
`;
private renderDownload() {
if (this.state.lottie) {
return html`
<div class="upload-download">
<div class="large edit-config">
${this.state.filename} ${this.width}x${this.height} ...
</div>
<div class="download">
<a target="_blank" download=${this.state.filename} href=${this.downloadURL}>
<file-download-icon-sk></file-download-icon-sk>
</a>
${!this.areChangesUploaded() ? '(without edits)' : ''}
</div>
</div>
`;
}
return null;
}
private optionsDialog = () => html`
<details class="expando">
<summary id="options-open">
<span>Options</span><expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<div class="options-container">
<checkbox-sk
label="Show lottie-web"
?checked=${this.showLottie}
@click=${this.toggleLottie}>
</checkbox-sk>
${this.showLottie
? html`
<skottie-dropdown-sk
.name=${'lottie-renderer'}
.options=${[
{
id: 'svg',
value: 'SVG',
selected: this.lottiePlayerRenderer === 'svg',
},
{
id: 'canvas',
value: 'Canvas',
selected: this.lottiePlayerRenderer === 'canvas',
},
]}
@select=${this.onLottieRendererSelect}
full>
</skottie-dropdown-sk>
`
: ''}
</div>
</details>
`;
private audioDialog = () =>
renderByDomain(
html`
<details
class="expando"
?open=${this.showAudio}
@toggle=${(e: Event) => this.toggleAudio((e.target! as HTMLDetailsElement).open)}>
<summary id="audio-open">
<span>Audio</span><expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<skottie-audio-sk .animation=${this.state.lottie} @apply=${this.applyAudioSync}>
</skottie-audio-sk>
</details>
`,
AUDIO_SUPPORTED_DOMAINS
);
private fileSettingsDialog = () => html`
<details
class="expando"
?open=${this.showFileSettings}
@toggle=${(e: Event) => this.toggleFileSettings((e.target! as HTMLDetailsElement).open)}>
<summary id="fileSettings-open">
<span>File Settings</span><expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<skottie-file-settings-sk
.width=${this.width}
.height=${this.height}
.fps=${this.fps}
@settings-change=${this.skottieFileSettingsUpdated}></skottie-file-settings-sk>
</details>
`;
private backgroundDialog = () => html`
<details
class="expando"
?open=${this.showBackgroundSettings}
@toggle=${(e: Event) =>
this.toggleBackgroundSettings((e.target! as HTMLDetailsElement).open)}>
<summary>
<span>Background color</span>
<expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<skottie-background-settings-sk
@background-change=${this.skottieBackgroundUpdated}></skottie-background-settings-sk>
</details>
`;
private colorManager = () => html`
<details class="expando">
<summary>
<span>Color manager</span>
<expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<skottie-color-manager-sk
.animation=${this.state.lottie}
@animation-updated=${this.onColorManagerUpdated}></skottie-color-manager-sk>
</details>
`;
private iframeDirections = () =>
`<iframe width="${this.width}" height="${this.height}" src="${window.location.origin}/e/${this.hash}?w=${this.width}&h=${this.height}" scrolling=no>`;
private inlineDirections = () =>
`<skottie-inline-sk width="${this.width}" height="${this.height}" src="${window.location.origin}/_/j/${this.hash}"></skottie-inline-sk>`;
private skottiePlayerTemplate = () =>
html` <figure class="players-container-player">
<skottie-player-sk paused width=${this.width} height=${this.height}> </skottie-player-sk>
${this.wasmCaption()}
</figure>`;
private lottiePlayerTemplate = () => {
if (!this.showLottie) {
return '';
}
return html` <figure class="players-container-player">
<div
id="container"
title="lottie-web"
style="width:${this.width}px;height:${this.height}px;background-color:${this
.backgroundColor}"></div>
${caption('lottie-web', this.viewMode)}
</figure>`;
};
private library = () =>
html` <details
class="expando"
?open=${this.showLibrary}
@toggle=${(e: Event) => this.toggleLibrary((e.target! as HTMLDetailsElement).open)}>
<summary id="library-open">
<span>Library</span><expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<skottie-library-sk @select=${this.updateAnimation}> </skottie-library-sk>
</details>`;
private jsonEditor = (): TemplateResult =>
html` <dialog class="editor" ?open=${this.showJSONEditor}>
<div class="top-ribbon">
<span>Layer Information</span>
<button @click=${this.toggleEditor}>Close</button>
</div>
<div id="json_editor"></div>
</dialog>`;
private gifExporter = () => html`
<dialog class="export" ?open=${this.showGifExporter}>
<div class="top-ribbon">
<span>Export</span>
<button @click=${this.toggleGifExporter}>Close</button>
</div>
<skottie-gif-exporter-sk @start=${this.onExportStart}> </skottie-gif-exporter-sk>
</dialog>
`;
private jsonTextEditor = () => html`
<skottie-text-editor-sk
.animation=${this.state.lottie}
.mode=${this.viewMode}
@text-change=${this.onTextChange}>
</skottie-text-editor-sk>
`;
private shaderEditor = () => html`
<details
class="expando"
?open=${this.showShaderEditor}
@toggle=${(e: Event) => this.toggleShaderEditor((e.target! as HTMLDetailsElement).open)}>
<summary>
<span>Edit Shader</span><expand-less-icon-sk></expand-less-icon-sk>
<expand-more-icon-sk></expand-more-icon-sk>
</summary>
<skottie-shader-editor-sk
.animation=${this.state.lottie}
.mode=${this.viewMode}
@apply=${this.applyShaderEdits}>
</skottie-shader-editor-sk>
</details>
`;
private buildFileName = () => {
const fileName = this.state.filename || this.state.lottie?.metadata?.filename;
if (fileName) {
return html`<div title="${fileName}">${fileName}</div>`;
}
return null;
};
private wasmCaption = () => {
if (this.viewMode === 'presentation') {
return null;
}
return html` <figcaption style="max-width: ${this.width}px;">
<div>skottie-wasm</div>
${this.buildFileName()}
</figcaption>`;
};
private assetsPath = PRODUCTION_ASSETS_PATH; // overridable for testing
private backgroundColor: string = 'rgba(255,255,255,1)';
// The URL referring to the lottie JSON Blob.
private downloadURL: string = '';
private duration: number = 0; // 0 is a sentinel value for "player not loaded yet"
private editor: JSONEditor | null = null;
private editorLoaded: boolean = false;
// used for remembering the time elapsed while the animation is playing.
private elapsedTime: number = 0;
private fps: number = 0;
private hash: string = '';
private height: number = 0;
private lottiePlayer: BodymovinPlayer | null = null;
private lottiePlayerRenderer: RendererType = 'svg';
private performanceChart: SkottiePerformanceSk | null = null;
private playing: boolean = true;
private playingOnStartOfScrub: boolean = false;
// The wasm animation computes how long it has been since the previous rendered time and
// uses arithmetic to figure out where to seek (i.e. which frame to draw).
private previousFrameTime: number = 0;
private scrubbing: boolean = false;
private showAudio: boolean = false;
private enableCompatibilityReport: boolean = false;
private showCompatibilityReport: boolean = false;
private showGifExporter: boolean = false;
private showJSONEditor: boolean = false;
private showLibrary: boolean = false;
private showLottie: boolean = false;
private showPerformanceChart: boolean = false;
private showTextEditor: boolean = false;
private showShaderEditor: boolean = false;
private showFileSettings: boolean = false;
private showBackgroundSettings: boolean = false;
private skottieLibrary: SkottieLibrarySk | null = null;
private skottiePlayer: SkottiePlayerSk | null = null;
private speed: number = 1; // this is a playback multiplier
private state: SkottieConfigState;
private stateChanged: () => void;
private ui: UIMode = 'idle';
private viewMode: ViewMode = 'default';
// This attribute will keep a reference to the tool that generated the last json change
// It will be used to prevent reloading the tool if it's the one affecting the animation
private changingTool: ToolType = 'none';
private width: number = 0;
private forceRedraw: boolean = false;
constructor() {
super(SkottieSk.template);
this.state = {
filename: '',
lottie: null,
assetsZip: '',
assetsFilename: '',
};
this.stateChanged = stateReflector(
/* getState */ () => ({
// provide empty values
l: this.showLottie,
e: this.showJSONEditor,
g: this.showGifExporter,
t: this.showTextEditor,
s: this.showShaderEditor,
p: this.showPerformanceChart,
ec: this.enableCompatibilityReport,
c: this.showCompatibilityReport,
i: this.showLibrary,
a: this.showAudio,
w: this.width,
h: this.height,
f: this.fps,
bg: this.backgroundColor,
mode: this.viewMode,
fs: this.showFileSettings,
b: this.showBackgroundSettings,
}),
/* setState */ (newState) => {
this.showLottie = !!newState.l;
this.showJSONEditor = !!newState.e;
this.showGifExporter = !!newState.g;
this.showTextEditor = !!newState.t;
this.showShaderEditor = !!newState.s;
this.showPerformanceChart = !!newState.p;
this.enableCompatibilityReport = !!newState.ec;
this.showCompatibilityReport = !!newState.c;
this.showLibrary = !!newState.i;
this.showAudio = !!newState.a;
this.width = +newState.w;
this.height = +newState.h;
this.fps = +newState.f;
this.showFileSettings = !!newState.fs;
this.showBackgroundSettings = !!newState.b;
this.viewMode = newState.mode === 'presentation' ? 'presentation' : 'default';
this.backgroundColor = String(newState.bg);
this.render();
}
);
}
connectedCallback(): void {
super.connectedCallback();
this.reflectFromURL();
window.addEventListener('popstate', this.reflectFromURL);
this.render();
// Start a continuous animation loop.
const drawFrame = () => {
window.requestAnimationFrame(drawFrame);
// Elsewhere, the _previousFrameTime 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.previousFrameTime && this.playing) {
this.previousFrameTime = Date.now();
this.elapsedTime = 0;
}
if (this.playing && this.duration > 0) {
const currentTime = Date.now();
this.elapsedTime += (currentTime - this.previousFrameTime) * this.speed;
this.previousFrameTime = currentTime;
const progress = this.elapsedTime % 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.
const normalizedProgress = progress / this.duration;
this.performanceChart?.start(progress, this.duration, this.state.lottie?.fr || 0);
this.skottiePlayer?.seek(normalizedProgress, this.forceRedraw);
this.performanceChart?.end();
this.skottieLibrary?.seek(normalizedProgress);
// lottie player takes the milliseconds from the beginning of the animation.
this.lottiePlayer?.goToAndStop(progress);
this.updateScrubber();
this.updateFrameLabel();
}
};
window.requestAnimationFrame(drawFrame);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('popstate', this.reflectFromURL);
}
attributeChangedCallback(): void {
this.render();
}
private updateAnimation(e: CustomEvent<LottieAnimation>): void {
this.state.lottie = e.detail;
this.state.filename = e.detail.metadata?.filename || this.state.filename;
this.changingTool = 'skottie-library';
this.ui = 'draft';
this.render();
}
private onTextChange(ev: CustomEvent<TextEditEventDetail>): void {
this.changingTool = 'text-edits';
this.onAnimationUpdated(ev);
this.loadAssetsAndRender();
}
private applyShaderEdits(e: CustomEvent<ShaderEditApplyEventDetail>): void {
const shaders = e.detail.shaders;
this.state.lottie = replaceShaders(shaders, this.state.lottie!);
// TODO(jmbetancourt): support skottieLibrary
// this.skottieLibrary?.replaceShaders(shaders);
this.changingTool = 'shader-edits';
this.ui = 'draft';
this.render();
}
private applyAudioSync(e: CustomEvent<AudioStartEventDetail>): void {
const detail = e.detail;
this.speed = detail.speed;
this.previousFrameTime = Date.now();
this.elapsedTime = 0;
if (!this.playing) {
this.playpause();
}
}
private onExportStart(): void {
if (this.playing) {
this.playpause();
}
}
private applyEdits(): void {
if (this.areChangesUploaded()) {
return;
}
this.upload();
}
private autoSize(): boolean {
let changed = false;
if (!this.width) {
this.width = this.state.lottie!.w;
changed = true;
}
if (!this.height) {
this.height = this.state.lottie!.h;
changed = true;
}
// By default, leave FPS at 0, instead of reading them from the lottie,
// because that will cause it to render as smoothly as possible,
// which looks better in most cases. If a user gives a negative value
// for fps (e.g. -1), then we use either what the lottie tells us or
// as fast as possible.
if (this.fps < 0) {
this.fps = this.state.lottie!.fr || 0;
}
return changed;
}
private skottieFileSelected(e: CustomEvent<SkottieConfigEventDetail>) {
this.state = e.detail.state;
this.width = e.detail.width;
this.height = e.detail.height;
this.fps = e.detail.fps;
this.backgroundColor = e.detail.backgroundColor;
this.autoSize();
this.stateChanged();
if (e.detail.fileChanged) {
this.upload();
} else {
this.ui = 'loaded';
this.render();
// Re-sync all players
this.rewind();
}
}
private skottieFileSettingsUpdated(e: CustomEvent<SkottieFileSettingsEventDetail>) {
this.width = e.detail.width;
this.height = e.detail.height;
this.fps = e.detail.fps;
this.stateChanged();
if (this.state.lottie) {
this.autoSize();
}
this.ui = 'loaded';
this.render();
}
private skottieFilesSelected(e: CustomEvent<SkottieFilesEventDetail>) {
const state = e.detail;
const width = state.lottie?.w || this.width;
const height = state.lottie?.h || this.height;
const fileSettings = $$<SkottieFileSettingsSk>('#file-settings', this);
if (fileSettings) {
fileSettings.width = width;
fileSettings.height = height;
}
this.width = width;
this.height = height;
this.state = state;
this.upload();
}
private skottieBackgroundUpdated(e: CustomEvent<SkottieBackgroundSettingsEventDetail>) {
const background = e.detail;
this.backgroundColor = background.color;
this.changingTool = 'background-color';
this.ui = 'draft';
this.stateChanged();
if (this.state.lottie) {
this.autoSize();
// Re-sync all players
this.rewind();
}
this.render();
}
private selectionCancelled() {
this.ui = 'loaded';
this.render();
}
private initializePlayer(): Promise<void> {
if (!this.isToolUnsynced('skottie-player')) {
return Promise.resolve();
}
return this.skottiePlayer!.initialize({
width: this.width,
height: this.height,
lottie: this.state.lottie!,
assets: this.state.assets,
soundMap: this.state.soundMap,
fps: this.fps,
}).then(() => {
this.performanceChart?.reset();
this.duration = this.skottiePlayer!.duration();
// If the user has specified a value for FPS, we want to lock the
// size of the scrubber so it is as discrete as the frame rate.
if (this.fps) {
const scrubber = $$<HTMLInputElement>('#scrub', this);
if (scrubber) {
// calculate a scaled version of ms per frame as the step size.
scrubber.step = String((1000 / this.fps) * (SCRUBBER_RANGE / this.duration));
}
}
const frameTotal = $$<HTMLInputElement>('#frameTotal', this);
if (frameTotal) {
if (this.state.lottie!.fr) {
frameTotal.textContent = `of ${String(
Math.round(this.duration * (this.state.lottie!.fr / 1000))
)}`;
}
}
this.renderSlotManager();
});
}
private fetchAdditionalAssets(): Promise<string[]> {
if (!this.hash) {
return Promise.resolve([]);
}
return fetch(`/_/r/${this.hash}`)
.then(jsonOrThrow)
.then((json) => {
const allFileNames: string[] = json.files;
const additionalAssets: string[] = [];
if (allFileNames) {
for (const fileName of allFileNames) {
const ext: string | undefined = fileName.split('.').pop();
if (ext && (ext === 'png' || ext === 'jpg')) {
additionalAssets.push(fileName);
}
}
}
return additionalAssets;
});
}
private loadAssetsAndRender(): Promise<void> {
// We always fetch additional asset list before loading assets
// While more readable, could be further optimized if startup time gets too bloated as a result
return this.fetchAdditionalAssets().then((additionalAssets: string[]) => {
const toLoad: Promise<LoadedAsset | null>[] = [];
const lottie = this.state.lottie!;
let fonts: FontAsset[] = [];
let assets: LottieAsset[] = [];
let loadAdditionalAssets: boolean = false;
if (lottie.fonts && lottie.fonts.list) {
fonts = lottie.fonts.list;
}
if (lottie.assets && lottie.assets.length) {
assets = lottie.assets;
// check for slot ids
for (const asset of assets) {
if (!isBinaryAsset(asset)) {
continue;
}
if (asset.sid) {
loadAdditionalAssets = true;
break;
}
}
}
toLoad.push(...this.loadFonts(fonts));
if (loadAdditionalAssets) {
toLoad.push(...this.loadAssets(assets, additionalAssets));
} else {
toLoad.push(...this.loadAssets(assets, []));
}
return Promise.all(toLoad)
.then((externalAssets: (LoadedAsset | null)[]) => {
const loadedAssets: Record<string, ArrayBuffer> = {};
const sounds = new SoundMap();
for (const asset of externalAssets) {
if (asset && asset.bytes) {
loadedAssets[asset.name] = asset.bytes;
} else if (asset && asset.player) {
sounds.setPlayer(asset.name, asset.player);
}
}
// check fonts
fonts.forEach((font: FontAsset) => {
if (!loadedAssets[font.fName]) {
console.error(`Could not load font '${font.fName}'.`);
}
});
this.state.assets = loadedAssets;
this.state.soundMap = sounds;
if (this.ui === 'synced') {
this.ui = 'loaded';
} else if (this.ui === 'unsynced') {
this.ui = 'draft';
}
this.renderSlotManager();
this.render();
})
.catch(() => {
this.render();
});
});
}
private loadFonts(fonts: FontAsset[]): Promise<LoadedAsset | null>[] {
const promises: Promise<LoadedAsset | null>[] = [];
for (const font of fonts) {
if (!font.fName) {
continue;
}
const fetchFont = (fontURL: string) => {
promises.push(
fetch(fontURL).then((resp: Response) => {
// fetch does not reject on 404
if (!resp.ok) {
return null;
}
return resp.arrayBuffer().then((buffer: ArrayBuffer) => ({
name: font.fName,
bytes: buffer,
}));
})
);
};
// We have a mirror of google web fonts with a flattened directory structure which
// makes them easier to find. Additionally, we can host the full .ttf
// font, instead of the .woff2 font which is served by Google due to
// it's smaller size by being a subset based on what glyphs are rendered.
// Since we don't know all the glyphs we need up front, it's easiest
// to just get the full font as a .ttf file.
fetchFont(`${GOOGLE_WEB_FONTS_HOST}/${font.fName}.ttf`);
// Also try using uploaded assets.
// We may end up with two different blobs for the same font name, in which case
// the user-provided one takes precedence.
if (this.hash) {
fetchFont(`${this.assetsPath}/${this.hash}/${font.fName}.ttf`);
}
}
return promises;
}
private loadAssets(
assets: LottieAsset[],
additionalAssets: string[]
): Promise<LoadedAsset | null>[] {
const alreadyPromised = new Set<string>();
const promises: Promise<LoadedAsset | null>[] = [];
for (const asset of assets) {
if (!isBinaryAsset(asset)) {
continue;
}
if (asset.id.startsWith('audio_')) {
// Howler handles our audio assets, they don't provide a promise when making a new Howl.
// We push the audio asset as is and hope that it loads before playback starts.
const inline = asset.p && asset.p.startsWith && asset.p.startsWith('data:');
if (inline) {
promises.push(
Promise.resolve({
name: asset.id,
player: new AudioPlayer(asset.p),
})
);
} else {
promises.push(
Promise.resolve({
name: asset.id,
player: new AudioPlayer(`${this.assetsPath}/${this.hash}/${asset.p}`),
})
);
}
} else {
// asset.p is the filename, if it's an image.
// Don't try to load inline/dataURI images.
const should_load = asset.p && asset.p.startsWith && !asset.p.startsWith('data:');
if (should_load) {
alreadyPromised.add(asset.p);
promises.push(
fetch(`${this.assetsPath}/${this.hash}/${asset.p}`).then((resp: Response) => {
// fetch does not reject on 404
if (!resp.ok) {
console.error(`Could not load ${asset.p}: status ${resp.status}`);
return null;
}
return resp.arrayBuffer().then((buffer) => ({
name: asset.p,
bytes: buffer,
}));
})
);
}
}
}
for (const assetName of additionalAssets) {
if (!alreadyPromised.has(assetName)) {
promises.push(
fetch(`${this.assetsPath}/${this.hash}/${assetName}`).then((resp: Response) => {
// fetch does not reject on 404
if (!resp.ok) {
console.error(`Could not load ${assetName}: status ${resp.status}`);
return null;
}
return resp.arrayBuffer().then((buffer) => ({
name: assetName,
bytes: buffer,
}));
})
);
}
}
return promises;
}
private playpause(): void {
const audioManager = $$<SkottieAudioSk>('skottie-audio-sk');
if (this.playing) {
this.lottiePlayer?.pause();
this.state.soundMap?.pause();
$$<HTMLElement>('#playpause-pause')!.style.display = 'none';
$$<HTMLElement>('#playpause-play')!.style.display = 'inherit';
audioManager?.pause();
} else {
this.lottiePlayer?.play();
this.previousFrameTime = Date.now();
// There is no need call a soundMap.play() function here.
// Skottie invokes the play by calling seek on the needed audio track.
$$<HTMLElement>('#playpause-pause')!.style.display = 'inherit';
$$<HTMLElement>('#playpause-play')!.style.display = 'none';
audioManager?.resume();
}
this.playing = !this.playing;
}
private recoverFromError(msg: string): void {
errorMessage(msg);
console.error(msg);
window.history.pushState(null, '', '/');
// For development we recover to the loaded state to see the animation
// even if the upload didn't work
this.ui = isDomain(SUPPORTED_DOMAINS.LOCALHOST) ? 'loaded' : 'idle';
this.render();
}
private reflectFromURL(): void {
// Check URL.
const match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/);
if (!match) {
this.hash = DEFAULT_LOTTIE_FILE;
} else {
this.hash = match[1];
}
this.ui = 'loading';
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) => {
// remove legacy fields from state, if they are there.
delete json.width;
delete json.height;
delete json.fps;
this.state = json;
if (this.autoSize()) {
this.stateChanged();
}
this.ui = 'loaded';
this.loadAssetsAndRender().then(() => {
this.dispatchEvent(new CustomEvent('initial-animation-loaded', { bubbles: true }));
});
})
.catch((msg) => this.recoverFromError(msg));
});
}
private render(): void {
if (this.downloadURL) {
URL.revokeObjectURL(this.downloadURL);
}
this.downloadURL = URL.createObjectURL(new Blob([JSON.stringify(this.state.lottie)]));
super._render();
this.skottiePlayer = $$<SkottiePlayerSk>('skottie-player-sk', this);
this.performanceChart = $$<SkottiePerformanceSk>('skottie-performance-sk', this);
this.skottieLibrary = $$<SkottieLibrarySk>('skottie-library-sk', this);
const skottieGifExporter = $$<SkottieGifExporterSk>('skottie-gif-exporter-sk', this);
if (skottieGifExporter && this.skottiePlayer) {
skottieGifExporter.player = this.skottiePlayer;
}
if (this.isPlayerView()) {
if (this.state.soundMap && this.state.soundMap.map.size > 0) {
this.hideVolumeSlider(false);
// Stop any audio assets that start playing on frame 0
// Pause the playback to force a user gesture to resume the AudioContext
if (this.playing) {
this.playpause();
this.rewind();
}
this.state.soundMap.stop();
} else {
this.hideVolumeSlider(true);
}
try {
this.initializePlayer().then(() => this.rewind());
this.renderLottieWeb();
this.renderJSONEditor();
this.renderTextEditor();
this.renderShaderEditor();
this.renderAudioManager();
} catch (e) {
console.warn('caught error while rendering third party code', e);
}
}
if (this.ui === 'draft') {
this.ui = 'unsynced';
} else if (this.ui === 'loaded') {
this.ui = 'synced';
}
this.changingTool = 'none';
}
private renderAudioManager(): void {
if (this.showAudio) {
const audioManager = $$<SkottieAudioSk>('skottie-audio-sk', this);
if (audioManager) {
audioManager.animation = this.state.lottie!;
}
}
}
private renderTextEditor(): void {
if (this.showTextEditor) {
const textEditor = $$<SkottieTextEditorSk>('skottie-text-editor-sk', this);
if (textEditor) {
textEditor.animation = this.state.lottie!;
}
}
}
private renderSlotManager(): void {
const slotManager = $$<SkottieSlotManagerSk>('skottie-slot-manager-sk', this);
if (slotManager) {
slotManager.player = this.skottiePlayer!;
if (this.state.assets) {
slotManager.resourceList = Object.keys(this.state.assets);
}
if (slotManager.hasSlots()) {
this.forceRedraw = true;
}
}
}
private renderShaderEditor(): void {
if (this.showShaderEditor) {
const shaderEditor = $$<ShaderEditorSk>('skottie-shader-editor-sk', this);
if (shaderEditor) {
shaderEditor.animation = this.state.lottie!;
}
}
}
private renderJSONEditor(): void {
if (!this.showJSONEditor) {
this.editorLoaded = false;
this.editor = null;
return;
}
const editorContainer = $$<HTMLDivElement>('#json_editor')!;
// See https://github.com/josdejong/svelte-jsoneditor/tree/main?tab=readme-ov-file#api
// for documentation on this editor.
const editorProps: JSONEditorPropsOptional = {
onChange: () => {
if (!this.editorLoaded) {
return;
}
this.changingTool = 'json-editor';
this.ui = 'draft';
const lottie = toJSONContent(this.editor!.get()).json;
this.state.lottie = lottie as LottieAnimation;
this.render();
},
};
if (this.enableCompatibilityReport) {
editorProps.validator = createAjvValidator({
// TODO(bwils) include feature schemas as well? More of UX problem
schema: lottieSchema,
onCreateAjv: () =>
// Override ajv instance to support json schema 2020-12
new Ajv({
allErrors: true,
verbose: true,
strict: false,
}),
});
}
const editorOptions = {
target: editorContainer,
props: editorProps,
};
// 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
// made changes.
if (!this.editor) {
this.editorLoaded = false;
editorContainer.innerHTML = '';
this.editor = new JSONEditor(editorOptions);
this.editor.set({ json: this.state.lottie });
} else if (this.isToolUnsynced('json-editor')) {
this.editorLoaded = false;
this.editor.set({ json: this.state.lottie });
}
// We are now pretty confident that the onChange events will only be
// when the user modifies the JSON.
this.editorLoaded = true;
}
private renderLottieWeb(): void {
if (!this.showLottie) {
if (this.lottiePlayer) {
this.lottiePlayer.destroy();
this.lottiePlayer = null;
}
return;
}
// Don't re-start the animation while the user edits.
if (this.isToolUnsynced('lottie-player') || !this.lottiePlayer) {
if (this.lottiePlayer) {
this.lottiePlayer.destroy();
}
$$<HTMLDivElement>('#container')!.innerHTML = '';
this.lottiePlayer = LottiePlayer.loadAnimation({
container: $$('#container')!,
renderer: this.lottiePlayerRenderer,
loop: true,
autoplay: this.playing,
assetsPath: `${this.assetsPath}/${this.hash}/`,
// Apparently the lottie player modifies the data as it runs?
animationData: JSON.parse(JSON.stringify(this.state.lottie)) as LottieAnimation,
rendererSettings: {
preserveAspectRatio: 'xMidYMid meet',
},
});
}
}
// This fires every time the user moves the scrub slider.
private onScrub(e: Event): void {
if (!this.scrubbing) {
// Pause the animation while dragging the slider.
this.playingOnStartOfScrub = this.playing;
if (this.playing) {
this.playpause();
}
this.scrubbing = true;
}
const scrubber = (e.target as HTMLInputElement)!;
const seek = +scrubber.value / SCRUBBER_RANGE;
this.seek(seek);
this.updateFrameLabel();
}
// This fires when the user releases the scrub slider.
private onScrubEnd(): void {
if (this.playingOnStartOfScrub) {
this.playpause();
}
this.scrubbing = false;
}
private onFrameFocus(): void {
if (this.playing) {
this.playpause();
}
}
private onFrameChange(): void {
if (this.playing) {
this.playpause();
}
const frameInput = $$<HTMLInputElement>('#frameInput', this);
if (frameInput) {
const frame = +frameInput.value;
this.seekFrame(frame);
}
}
private onChartClick(e: Event): void {
const chart = $$<SkottiePerformanceSk>('#chart', this);
const frame: number | undefined = chart?.getClickedFrame(e);
if (frame !== undefined && frame !== -1) {
if (this.playing) {
this.playpause();
}
const frameInput = $$<HTMLInputElement>('#frameInput', this);
if (frameInput) frameInput.value = String(frame);
this.seekFrame(frame);
}
}
private seekFrame(frame: number): void {
if (frame > 0 && frame < this.duration) {
let seek = 0;
if (this.state.lottie?.fr) {
seek = ((frame / this.state.lottie.fr) * 1000) / this.duration;
}
this.seek(seek);
this.updateScrubber();
}
}
private updateScrubber(): void {
const scrubber = $$<HTMLInputElement>('#scrub', this);
if (scrubber) {
// Scale from time to the arbitrary scrubber range.
const progress = this.elapsedTime % this.duration;
scrubber.value = String((SCRUBBER_RANGE * progress) / this.duration);
}
}
private updateFrameLabel(): void {
const frameLabel = $$<HTMLInputElement>('#frameInput', this);
if (frameLabel) {
const progress = this.elapsedTime % this.duration;
if (this.state.lottie!.fr) {
frameLabel.value = String(Math.round(progress * (this.state.lottie!.fr / 1000)));
}
}
}
private seek(t: number): void {
// catch case where t = 1
t = Math.min(t, 0.9999);
this.elapsedTime = t * this.duration;
this.lottiePlayer?.goToAndStop(t * this.duration);
this.skottiePlayer?.seek(t, this.forceRedraw);
this.skottieLibrary?.seek(t);
}
private onVolumeChange(e: Event): void {
const scrubber = (e.target as HTMLInputElement)!;
this.state.soundMap?.setVolume(+scrubber.value);
}
private rewind(): void {
// Handle rewinding when paused.
if (!this.playing) {
this.skottiePlayer!.seek(0, this.forceRedraw);
this.skottieLibrary?.seek(0);
this.previousFrameTime = 0;
this.lottiePlayer?.goToAndStop(0);
const scrubber = $$<HTMLInputElement>('#scrub', this);
if (scrubber) {
scrubber.value = '0';
}
} else {
this.lottiePlayer?.goToAndPlay(0);
this.previousFrameTime = 0;
const audioManager = $$<SkottieAudioSk>('skottie-audio-sk', this);
audioManager?.rewind();
}
}
private toggleEditor(e: Event): void {
// avoid double toggles
e.preventDefault();
const showJSONEditor = this.showJSONEditor;
this.closeAllDialogs();
this.showTextEditor = false;
this.showJSONEditor = !showJSONEditor;
this.stateChanged();
this.render();
}
private toggleGifExporter(e: Event): void {
// avoid double toggles
e.preventDefault();
this.showGifExporter = !this.showGifExporter;
this.stateChanged();
this.render();
}
private exportSelectHandler(e: CustomEvent<DropdownSelectEvent>): void {
if (!this.skottiePlayer) {
return;
}
const exportManager = $$<SkottieExporterSk>('skottie-exporter-sk');
exportManager?.export(e.detail.value as ExportType, this.skottiePlayer);
}
private closeAllDialogs() {
this.showPerformanceChart = false;
this.showJSONEditor = false;
this.showCompatibilityReport = false;
}
private togglePerformanceChart(e: Event): void {
// avoid double toggles
e.preventDefault();
const showPerformanceChart = this.showPerformanceChart;
this.closeAllDialogs();
this.showPerformanceChart = !showPerformanceChart;
this.stateChanged();
this.render();
}
private toggleCompatibilityReport(e: Event): void {
// avoid double toggles
e.preventDefault();
const showCompatibilityReport = this.showCompatibilityReport;
this.closeAllDialogs();
this.showCompatibilityReport = !showCompatibilityReport;
this.stateChanged();
this.render();
}
private toggleShaderEditor(open: boolean): void {
this.showJSONEditor = false;
this.showShaderEditor = open;
this.stateChanged();
this.render();
}
private toggleLibrary(open: boolean): void {
this.showLibrary = open;
this.stateChanged();
this.render();
}
private toggleAudio(open: boolean): void {
this.showAudio = open;
this.stateChanged();
this.render();
}
private toggleFileSettings(open: boolean): void {
this.showFileSettings = open;
this.stateChanged();
this.render();
}
private toggleBackgroundSettings(open: boolean): void {
this.showBackgroundSettings = open;
this.stateChanged();
this.render();
}
private toggleLottie(e: Event): void {
// avoid double toggles
e.preventDefault();
this.showLottie = !this.showLottie;
this.stateChanged();
this.render();
}
private hideVolumeSlider(v: boolean) {
const collapse = $$<CollapseSk>('#volume', this);
if (collapse) {
collapse.closed = v;
}
}
private upload(): void {
// POST the JSON to /_/upload
this.hash = '';
this.ui = 'draft';
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 and the lottie file
this.ui = 'loaded';
this.hash = json.hash;
this.state.lottie = json.lottie;
window.history.pushState(null, '', `/${this.hash}`);
this.stateChanged();
if (this.state.assetsZip) {
this.loadAssetsAndRender();
}
this.render();
})
.catch((msg) => this.recoverFromError(msg));
if (!this.state.assetsZip) {
this.ui = 'loaded';
// Start drawing right away, no need to wait for
// the JSON to make a round-trip to the server, since there
// are no assets that we need to unzip server-side.
// We still need to check for things like webfonts.
this.render();
this.loadAssetsAndRender();
} else {
// We have to wait for the server to process the zip file.
this.ui = 'loading';
this.render();
}
}
private onLottieRendererSelect(ev: CustomEvent<DropdownSelectEvent>) {
this.lottiePlayerRenderer = ev.detail.value as RendererType;
// Re-initialize Lottie.
this.lottiePlayer!.destroy();
this.lottiePlayer = null;
this.render();
}
private onColorManagerUpdated(ev: CustomEvent<SkottieTemplateEventDetail>) {
this.changingTool = 'color-manager';
this.onAnimationUpdated(ev);
}
private onSlotManagerUpdated(ev: CustomEvent<SkottieTemplateEventDetail>) {
this.changingTool = 'slot-manager';
this.onAnimationUpdated(ev);
}
private onAnimationUpdated(
ev: CustomEvent<SkottieTemplateEventDetail | TextEditEventDetail>
): void {
this.state.lottie = ev.detail.animation;
this.ui = 'draft';
this.render();
}
overrideAssetsPathForTesting(p: string): void {
this.assetsPath = p;
}
private isToolUnsynced(tool: ToolType): boolean {
return ['draft', 'loaded'].includes(this.ui) && this.changingTool !== tool;
}
private areChangesUploaded(): boolean {
return !['draft', 'unsynced'].includes(this.ui);
}
private isPlayerView(): boolean {
return ['draft', 'unsynced', 'synced', 'loaded'].includes(this.ui);
}
}
define('skottie-sk', SkottieSk);