chromium/ui/webui/resources/cr_elements/cr_lottie/cr_lottie.ts

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview 'cr-lottie' is a wrapper around the player for lottie
 * animations. Since the player runs on a worker thread, 'cr-lottie' requires
 * the document CSP to be set to "worker-src blob: chrome://resources 'self';".
 *
 * For documents that have TrustedTypes CSP checks enabled, it also requires the
 * document CSP to be set to "trusted-types lottie-worker-script-loader;".
 *
 * Fires a 'cr-lottie-initialized' event when the animation was successfully
 * initialized.
 * Fires a 'cr-lottie-playing' event when the animation starts playing.
 * Fires a 'cr-lottie-paused' event when the animation has paused.
 * Fires a 'cr-lottie-stopped' event when animation has stopped.
 * Fires a 'cr-lottie-resized' event when the canvas the animation is being
 * drawn on is resized.
 */

import {assert, assertNotReached} from '//resources/js/assert.js';
import {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from '//resources/lit/v3_0/lit.rollup.js';

import {getCss} from './cr_lottie.css.js';
import {getHtml} from './cr_lottie.html.js';

let workerLoaderPolicy: TrustedTypePolicy|null = null;

function getLottieWorkerURL(): TrustedScriptURL {
  if (workerLoaderPolicy === null) {
    workerLoaderPolicy =
        window.trustedTypes!.createPolicy('lottie-worker-script-loader', {
          createScriptURL: (_ignore: string) => {
            const script =
                `import 'chrome://resources/lottie/lottie_worker.min.js';`;
            // CORS blocks loading worker script from a different origin, even
            // if chrome://resources/ is added in the 'worker-src' CSP header.
            // (see https://crbug.com/1385477). Loading scripts as blob and then
            // instantiating it as web worker is possible.
            const blob = new Blob([script], {type: 'text/javascript'});
            return URL.createObjectURL(blob);
          },
          createHTML: () => assertNotReached(),
          createScript: () => assertNotReached(),
        });
  }

  return workerLoaderPolicy.createScriptURL('');
}

interface MessageData {
  animationData: object|null|string;
  drawSize: {width: number, height: number};
  params: {loop: boolean, autoplay: boolean};
  canvas?: OffscreenCanvas;
}

interface CanvasElementWithOffscreen extends HTMLCanvasElement {
  transferControlToOffscreen: () => OffscreenCanvas;
}

export interface CrLottieElement {
  $: {
    canvas: CanvasElementWithOffscreen,
  };
}

export class CrLottieElement extends CrLitElement {
  static get is() {
    return 'cr-lottie';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      animationUrl: {type: String},
      autoplay: {type: Boolean},
      hidden: {type: Boolean},
      singleLoop: {type: Boolean},
    };
  }

  animationUrl: string = '';
  autoplay: boolean = false;
  override hidden: boolean = false;
  singleLoop: boolean = false;

  private canvasElement_: CanvasElementWithOffscreen|null = null;
  private isAnimationLoaded_: boolean = false;
  private offscreenCanvas_: OffscreenCanvas|null = null;

  /** Whether the canvas has been transferred to the worker thread. */
  private hasTransferredCanvas_: boolean = false;

  private resizeObserver_: ResizeObserver|null = null;

  /**
   * The last state that was explicitly set via setPlay.
   * In case setPlay() is invoked before the animation is initialized, the
   * state is stored in this variable. Once the animation initializes, the
   * state is sent to the worker.
   */
  private playState_: boolean = false;

  /**
   * Whether the Worker needs to receive new size
   * information about the canvas. This is necessary for the corner case
   * when the size information is received when the animation is still being
   * loaded into the worker.
   */
  private workerNeedsSizeUpdate_: boolean = false;

  /**
   * Whether the Worker needs to receive new control
   * information about its desired state. This is necessary for the corner
   * case when the control information is received when the animation is still
   * being loaded into the worker.
   */
  private workerNeedsPlayControlUpdate_: boolean = false;

  private worker_: Worker|null = null;

  /** The current in-flight request. */
  private xhr_: XMLHttpRequest|null = null;

  override connectedCallback() {
    super.connectedCallback();

    this.worker_ =
        new Worker(getLottieWorkerURL() as unknown as URL, {type: 'module'});
    this.worker_.onmessage = this.onMessage_.bind(this);
    this.initialize_();
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    if (this.resizeObserver_) {
      this.resizeObserver_.disconnect();
    }
    if (this.worker_) {
      this.worker_.terminate();
      this.worker_ = null;
    }
    if (this.xhr_) {
      this.xhr_.abort();
      this.xhr_ = null;
    }
  }

  /**
   * Updates the animation that is being displayed.
   */
  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);
    if (!changedProperties.has('animationUrl')) {
      return;
    }

    if (!this.worker_) {
      // The worker hasn't loaded yet. We will load the new animation once the
      // worker loads.
      return;
    }
    if (this.xhr_) {
      // There is an in-flight request to load the previous animation. Abort it
      // before loading a new image.
      this.xhr_.abort();
      this.xhr_ = null;
    }
    if (this.isAnimationLoaded_) {
      this.worker_.postMessage({control: {stop: true}});
      this.isAnimationLoaded_ = false;
    }
    this.sendXmlHttpRequest_(
        this.animationUrl, 'json', this.initAnimation_.bind(this));
  }

  /**
   * Controls the animation based on the value of |shouldPlay|. If the
   * animation is being loaded into the worker when this method is invoked,
   * the action will be postponed to when the animation is fully loaded.
   * @param shouldPlay True for play, false for pause.
   */
  setPlay(shouldPlay: boolean) {
    this.playState_ = shouldPlay;
    if (this.isAnimationLoaded_) {
      this.sendPlayControlInformationToWorker_();
    } else {
      this.workerNeedsPlayControlUpdate_ = true;
    }
  }

  /**
   * Sends control (play/pause) information to the worker.
   */
  private sendPlayControlInformationToWorker_() {
    assert(this.worker_);
    this.worker_.postMessage({control: {play: this.playState_}});
  }

  /**
   * Initializes all the members of this element.
   */
  private initialize_() {
    // Generate an offscreen canvas.
    this.canvasElement_ = this.$.canvas;
    this.offscreenCanvas_ = this.canvasElement_.transferControlToOffscreen();

    this.resizeObserver_ =
        new ResizeObserver(this.onCanvasElementResized_.bind(this));
    this.resizeObserver_.observe(this.canvasElement_);

    if (this.isAnimationLoaded_) {
      return;
    }

    // Open animation file and start playing the animation.
    this.sendXmlHttpRequest_(
        this.animationUrl, 'json', this.initAnimation_.bind(this));
  }

  /**
   * Computes the draw buffer size for the canvas. This ensures that the
   * rasterization is crisp and sharp rather than blurry.
   * @return Size of the canvas draw buffer
   */
  private getCanvasDrawBufferSize_(): {width: number, height: number} {
    const canvasElement = this.$.canvas;
    const devicePixelRatio = window.devicePixelRatio;
    const clientRect = canvasElement.getBoundingClientRect();
    const drawSize = {
      width: clientRect.width * devicePixelRatio,
      height: clientRect.height * devicePixelRatio,
    };
    return drawSize;
  }

  /**
   * Returns true if the |maybeValidUrl| provided is safe to use in an
   * XMLHTTPRequest.
   * @param maybeValidUrl The url string to check for validity.
   */
  private isValidUrl_(maybeValidUrl: string): boolean {
    const url = new URL(maybeValidUrl, document.location.href);
    return url.protocol === 'chrome:' ||
        (url.protocol === 'data:' &&
         url.pathname.startsWith('application/json;'));
  }

  /**
   * Sends an XMLHTTPRequest to load a resource and runs the callback on
   * getting a successful response.
   * @param url The URL to load the resource.
   * @param responseType The type of response the request would
   *     give on success.
   * @param successCallback The callback to run
   *     when a successful response is received.
   */
  private sendXmlHttpRequest_(
      url: string, responseType: XMLHttpRequestResponseType,
      successCallback: (p: object|null|Blob|MediaSource) => void) {
    assert(this.isValidUrl_(url), 'Invalid scheme or data url used.');
    assert(!this.xhr_);

    this.xhr_ = new XMLHttpRequest();
    this.xhr_!.open('GET', url, true);
    this.xhr_!.responseType = responseType;
    this.xhr_!.send();
    this.xhr_!.onreadystatechange = () => {
      assert(this.xhr_);
      if (this.xhr_.readyState === 4 && this.xhr_.status === 200) {
        // |successCallback| might trigger another xhr, so we set to null before
        // calling it.
        const response = this.xhr_.response;
        this.xhr_ = null;
        successCallback(response);
      }
    };
  }

  /**
   * Handles the canvas element resize event. If the animation isn't fully
   * loaded, the canvas size is sent later, once the loading is done.
   */
  private onCanvasElementResized_() {
    if (this.isAnimationLoaded_) {
      this.sendCanvasSizeToWorker_();
    } else {
      // Mark a size update as necessary once the animation is loaded.
      this.workerNeedsSizeUpdate_ = true;
    }
  }

  /**
   * This informs the offscreen canvas worker of the current canvas size.
   */
  private sendCanvasSizeToWorker_() {
    assert(this.worker_);
    this.worker_.postMessage({drawSize: this.getCanvasDrawBufferSize_()});
  }

  /**
   * Initializes the the animation on the web worker with the data provided.
   * @param animationData The animation that will be played.
   */
  private initAnimation_(animationData: object|null|string) {
    const message: MessageData = {
      animationData,
      drawSize: this.getCanvasDrawBufferSize_(),
      params: {loop: !this.singleLoop, autoplay: this.autoplay},
    };
    assert(this.worker_);
    if (!this.hasTransferredCanvas_) {
      message.canvas = this.offscreenCanvas_!;
      this.hasTransferredCanvas_ = true;
      this.worker_.postMessage(
          message, [this.offscreenCanvas_! as unknown as Transferable]);
    } else {
      this.worker_.postMessage(message);
    }
  }

  /**
   * Handles the messages sent from the web worker to its parent thread.
   * @param event Event sent by the web worker.
   */
  private onMessage_(event: MessageEvent) {
    if (event.data.name === 'initialized' && event.data.success) {
      this.isAnimationLoaded_ = true;
      this.sendPendingInfo_();
      this.fire('cr-lottie-initialized');
    } else if (event.data.name === 'playing') {
      this.fire('cr-lottie-playing');
    } else if (event.data.name === 'paused') {
      this.fire('cr-lottie-paused');
    } else if (event.data.name === 'stopped') {
      this.fire('cr-lottie-stopped');
    } else if (event.data.name === 'resized') {
      this.fire('cr-lottie-resized', event.data.size);
    }
  }

  /**
   * Called once the animation is fully loaded into the worker. Sends any
   * size or control information that may have arrived while the animation
   * was not yet fully loaded.
   */
  private sendPendingInfo_() {
    if (this.workerNeedsSizeUpdate_) {
      this.workerNeedsSizeUpdate_ = false;
      this.sendCanvasSizeToWorker_();
    }
    if (this.workerNeedsPlayControlUpdate_) {
      this.workerNeedsPlayControlUpdate_ = false;
      this.sendPlayControlInformationToWorker_();
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cr-lottie': CrLottieElement;
  }
}

customElements.define(CrLottieElement.is, CrLottieElement);