summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/animation.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/animation.js')
-rw-r--r--devtools/server/actors/animation.js906
1 files changed, 906 insertions, 0 deletions
diff --git a/devtools/server/actors/animation.js b/devtools/server/actors/animation.js
new file mode 100644
index 0000000000..d8de85fd73
--- /dev/null
+++ b/devtools/server/actors/animation.js
@@ -0,0 +1,906 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Set of actors that expose the Web Animations API to devtools protocol
+ * clients.
+ *
+ * The |Animations| actor is the main entry point. It is used to discover
+ * animation players on given nodes.
+ * There should only be one instance per devtools server.
+ *
+ * The |AnimationPlayer| actor provides attributes and methods to inspect an
+ * animation as well as pause/resume/seek it.
+ *
+ * The Web Animation spec implementation is ongoing in Gecko, and so this set
+ * of actors should evolve when the implementation progresses.
+ *
+ * References:
+ * - WebAnimation spec:
+ * http://drafts.csswg.org/web-animations/
+ * - WebAnimation WebIDL files:
+ * /dom/webidl/Animation*.webidl
+ */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ animationPlayerSpec,
+ animationsSpec,
+} = require("resource://devtools/shared/specs/animation.js");
+
+const {
+ ANIMATION_TYPE_FOR_LONGHANDS,
+} = require("resource://devtools/server/actors/animation-type-longhand.js");
+
+// Types of animations.
+const ANIMATION_TYPES = {
+ CSS_ANIMATION: "cssanimation",
+ CSS_TRANSITION: "csstransition",
+ SCRIPT_ANIMATION: "scriptanimation",
+ UNKNOWN: "unknown",
+};
+exports.ANIMATION_TYPES = ANIMATION_TYPES;
+
+function getAnimationTypeForLonghand(property) {
+ // If this is a custom property, return "custom" for now as it's not straightforward
+ // to retrieve the proper animation type.
+ // TODO: We could compute the animation type from the registered property syntax (Bug 1875435)
+ if (property.startsWith("--")) {
+ return "custom";
+ }
+
+ for (const [type, props] of ANIMATION_TYPE_FOR_LONGHANDS) {
+ if (props.has(property)) {
+ return type;
+ }
+ }
+ throw new Error("Unknown longhand property name");
+}
+exports.getAnimationTypeForLonghand = getAnimationTypeForLonghand;
+
+/**
+ * The AnimationPlayerActor provides information about a given animation: its
+ * startTime, currentTime, current state, etc.
+ *
+ * Since the state of a player changes as the animation progresses it is often
+ * useful to call getCurrentState at regular intervals to get the current state.
+ *
+ * This actor also allows playing, pausing and seeking the animation.
+ */
+class AnimationPlayerActor extends Actor {
+ /**
+ * @param {AnimationsActor} The main AnimationsActor instance
+ * @param {AnimationPlayer} The player object returned by getAnimationPlayers
+ * @param {Number} Time which animation created
+ */
+ constructor(animationsActor, player, createdTime) {
+ super(animationsActor.conn, animationPlayerSpec);
+
+ this.onAnimationMutation = this.onAnimationMutation.bind(this);
+
+ this.walker = animationsActor.walker;
+ this.player = player;
+
+ // Listen to animation mutations on the node to alert the front when the
+ // current animation changes.
+ // If the node is a pseudo-element, then we listen on its parent with
+ // subtree:true (there's no risk of getting too many notifications in
+ // onAnimationMutation since we filter out events that aren't for the
+ // current animation).
+ this.observer = new this.window.MutationObserver(this.onAnimationMutation);
+ if (this.isPseudoElement) {
+ this.observer.observe(this.node.parentElement, {
+ animations: true,
+ subtree: true,
+ });
+ } else {
+ this.observer.observe(this.node, { animations: true });
+ }
+
+ this.createdTime = createdTime;
+ this.currentTimeAtCreated = player.currentTime;
+ }
+
+ destroy() {
+ // Only try to disconnect the observer if it's not already dead (i.e. if the
+ // container view hasn't navigated since).
+ if (this.observer && !Cu.isDeadWrapper(this.observer)) {
+ this.observer.disconnect();
+ }
+ this.player = this.observer = this.walker = null;
+
+ super.destroy();
+ }
+
+ get isPseudoElement() {
+ return !!this.player.effect.pseudoElement;
+ }
+
+ get pseudoElemenName() {
+ if (!this.isPseudoElement) {
+ return null;
+ }
+
+ return `_moz_generated_content_${this.player.effect.pseudoElement.replace(
+ /^::/,
+ ""
+ )}`;
+ }
+
+ get node() {
+ if (!this.isPseudoElement) {
+ return this.player.effect.target;
+ }
+
+ const pseudoElementName = this.pseudoElemenName;
+ const originatingElem = this.player.effect.target;
+ const treeWalker = this.walker.getDocumentWalker(originatingElem);
+
+ // When the animated node is a pseudo-element, we need to walk the children
+ // of the target node and look for it.
+ for (
+ let next = treeWalker.firstChild();
+ next;
+ next = treeWalker.nextSibling()
+ ) {
+ if (next.nodeName === pseudoElementName) {
+ return next;
+ }
+ }
+
+ console.warn(
+ `Pseudo element ${this.player.effect.pseudoElement} is not found`
+ );
+ return originatingElem;
+ }
+
+ get document() {
+ return this.node.ownerDocument;
+ }
+
+ get window() {
+ return this.document.defaultView;
+ }
+
+ /**
+ * Release the actor, when it isn't needed anymore.
+ * Protocol.js uses this release method to call the destroy method.
+ */
+ release() {}
+
+ form(detail) {
+ const data = this.getCurrentState();
+ data.actor = this.actorID;
+
+ // If we know the WalkerActor, and if the animated node is known by it, then
+ // return its corresponding NodeActor ID too.
+ if (this.walker && this.walker.hasNode(this.node)) {
+ data.animationTargetNodeActorID = this.walker.getNode(this.node).actorID;
+ }
+
+ return data;
+ }
+
+ isCssAnimation(player = this.player) {
+ return player instanceof this.window.CSSAnimation;
+ }
+
+ isCssTransition(player = this.player) {
+ return player instanceof this.window.CSSTransition;
+ }
+
+ isScriptAnimation(player = this.player) {
+ return (
+ player instanceof this.window.Animation &&
+ !(
+ player instanceof this.window.CSSAnimation ||
+ player instanceof this.window.CSSTransition
+ )
+ );
+ }
+
+ getType() {
+ if (this.isCssAnimation()) {
+ return ANIMATION_TYPES.CSS_ANIMATION;
+ } else if (this.isCssTransition()) {
+ return ANIMATION_TYPES.CSS_TRANSITION;
+ } else if (this.isScriptAnimation()) {
+ return ANIMATION_TYPES.SCRIPT_ANIMATION;
+ }
+
+ return ANIMATION_TYPES.UNKNOWN;
+ }
+
+ /**
+ * Get the name of this animation. This can be either the animation.id
+ * property if it was set, or the keyframe rule name or the transition
+ * property.
+ * @return {String}
+ */
+ getName() {
+ if (this.player.id) {
+ return this.player.id;
+ } else if (this.isCssAnimation()) {
+ return this.player.animationName;
+ } else if (this.isCssTransition()) {
+ return this.player.transitionProperty;
+ }
+
+ return "";
+ }
+
+ /**
+ * Get the animation duration from this player, in milliseconds.
+ * @return {Number}
+ */
+ getDuration() {
+ return this.player.effect.getComputedTiming().duration;
+ }
+
+ /**
+ * Get the animation delay from this player, in milliseconds.
+ * @return {Number}
+ */
+ getDelay() {
+ return this.player.effect.getComputedTiming().delay;
+ }
+
+ /**
+ * Get the animation endDelay from this player, in milliseconds.
+ * @return {Number}
+ */
+ getEndDelay() {
+ return this.player.effect.getComputedTiming().endDelay;
+ }
+
+ /**
+ * Get the animation iteration count for this player. That is, how many times
+ * is the animation scheduled to run.
+ * @return {Number} The number of iterations, or null if the animation repeats
+ * infinitely.
+ */
+ getIterationCount() {
+ const iterations = this.player.effect.getComputedTiming().iterations;
+ return iterations === Infinity ? null : iterations;
+ }
+
+ /**
+ * Get the animation iterationStart from this player, in ratio.
+ * That is offset of starting position of the animation.
+ * @return {Number}
+ */
+ getIterationStart() {
+ return this.player.effect.getComputedTiming().iterationStart;
+ }
+
+ /**
+ * Get the animation easing from this player.
+ * @return {String}
+ */
+ getEasing() {
+ return this.player.effect.getComputedTiming().easing;
+ }
+
+ /**
+ * Get the animation fill mode from this player.
+ * @return {String}
+ */
+ getFill() {
+ return this.player.effect.getComputedTiming().fill;
+ }
+
+ /**
+ * Get the animation direction from this player.
+ * @return {String}
+ */
+ getDirection() {
+ return this.player.effect.getComputedTiming().direction;
+ }
+
+ /**
+ * Get animation-timing-function from animated element if CSS Animations.
+ * @return {String}
+ */
+ getAnimationTimingFunction() {
+ if (!this.isCssAnimation()) {
+ return null;
+ }
+
+ let pseudo = null;
+ let target = this.player.effect.target;
+ if (target.type) {
+ // Animated element is a pseudo element.
+ pseudo = target.type;
+ target = target.element;
+ }
+ return this.window.getComputedStyle(target, pseudo).animationTimingFunction;
+ }
+
+ getPropertiesCompositorStatus() {
+ const properties = this.player.effect.getProperties();
+ return properties.map(prop => {
+ return {
+ property: prop.property,
+ runningOnCompositor: prop.runningOnCompositor,
+ warning: prop.warning,
+ };
+ });
+ }
+
+ /**
+ * Return the current start of the Animation.
+ * @return {Object}
+ */
+ getState() {
+ const compositorStatus = this.getPropertiesCompositorStatus();
+ // Note that if you add a new property to the state object, make sure you
+ // add the corresponding property in the AnimationPlayerFront' initialState
+ // getter.
+ return {
+ type: this.getType(),
+ // startTime is null whenever the animation is paused or waiting to start.
+ startTime: this.player.startTime,
+ currentTime: this.player.currentTime,
+ playState: this.player.playState,
+ playbackRate: this.player.playbackRate,
+ name: this.getName(),
+ duration: this.getDuration(),
+ delay: this.getDelay(),
+ endDelay: this.getEndDelay(),
+ iterationCount: this.getIterationCount(),
+ iterationStart: this.getIterationStart(),
+ fill: this.getFill(),
+ easing: this.getEasing(),
+ direction: this.getDirection(),
+ animationTimingFunction: this.getAnimationTimingFunction(),
+ // animation is hitting the fast path or not. Returns false whenever the
+ // animation is paused as it is taken off the compositor then.
+ isRunningOnCompositor: compositorStatus.some(
+ propState => propState.runningOnCompositor
+ ),
+ propertyState: compositorStatus,
+ // The document timeline's currentTime is being sent along too. This is
+ // not strictly related to the node's animationPlayer, but is useful to
+ // know the current time of the animation with respect to the document's.
+ documentCurrentTime: this.node.ownerDocument.timeline.currentTime,
+ // The time which this animation created.
+ createdTime: this.createdTime,
+ // The time which an animation's current time when this animation has created.
+ currentTimeAtCreated: this.currentTimeAtCreated,
+ properties: this.getProperties(),
+ };
+ }
+
+ /**
+ * Get the current state of the AnimationPlayer (currentTime, playState, ...).
+ * Note that the initial state is returned as the form of this actor when it
+ * is initialized.
+ * This protocol method only returns a trimed down version of this state in
+ * case some properties haven't changed since last time (since the front can
+ * reconstruct those). If you want the full state, use the getState method.
+ * @return {Object}
+ */
+ getCurrentState() {
+ const newState = this.getState();
+
+ // If we've saved a state before, compare and only send what has changed.
+ // It's expected of the front to also save old states to re-construct the
+ // full state when an incomplete one is received.
+ // This is to minimize protocol traffic.
+ let sentState = {};
+ if (this.currentState) {
+ for (const key in newState) {
+ if (
+ typeof this.currentState[key] === "undefined" ||
+ this.currentState[key] !== newState[key]
+ ) {
+ sentState[key] = newState[key];
+ }
+ }
+ } else {
+ sentState = newState;
+ }
+ this.currentState = newState;
+
+ return sentState;
+ }
+
+ /**
+ * Executed when the current animation changes, used to emit the new state
+ * the the front.
+ */
+ onAnimationMutation(mutations) {
+ const isCurrentAnimation = animation => animation === this.player;
+ const hasCurrentAnimation = animations =>
+ animations.some(isCurrentAnimation);
+ let hasChanged = false;
+
+ for (const { removedAnimations, changedAnimations } of mutations) {
+ if (hasCurrentAnimation(removedAnimations)) {
+ // Reset the local copy of the state on removal, since the animation can
+ // be kept on the client and re-added, its state needs to be sent in
+ // full.
+ this.currentState = null;
+ }
+
+ if (hasCurrentAnimation(changedAnimations)) {
+ // Only consider the state has having changed if any of effect timing properties,
+ // animationTimingFunction or playbackRate has changed.
+ const newState = this.getState();
+ const oldState = this.currentState;
+ hasChanged =
+ newState.delay !== oldState.delay ||
+ newState.iterationCount !== oldState.iterationCount ||
+ newState.iterationStart !== oldState.iterationStart ||
+ newState.duration !== oldState.duration ||
+ newState.endDelay !== oldState.endDelay ||
+ newState.direction !== oldState.direction ||
+ newState.easing !== oldState.easing ||
+ newState.fill !== oldState.fill ||
+ newState.animationTimingFunction !==
+ oldState.animationTimingFunction ||
+ newState.playbackRate !== oldState.playbackRate;
+ break;
+ }
+ }
+
+ if (hasChanged) {
+ this.emit("changed", this.getCurrentState());
+ }
+ }
+
+ /**
+ * Get data about the animated properties of this animation player.
+ * @return {Array} Returns a list of animated properties.
+ * Each property contains a list of values, their offsets and distances.
+ */
+ getProperties() {
+ const properties = this.player.effect.getProperties().map(property => {
+ return { name: property.property, values: property.values };
+ });
+
+ const DOMWindowUtils = this.window.windowUtils;
+
+ // Fill missing keyframe with computed value.
+ for (const property of properties) {
+ let underlyingValue = null;
+ // Check only 0% and 100% keyframes.
+ [0, property.values.length - 1].forEach(index => {
+ const values = property.values[index];
+ if (values.value !== undefined) {
+ return;
+ }
+ if (!underlyingValue) {
+ let pseudo = null;
+ let target = this.player.effect.target;
+ if (target.type) {
+ // This target is a pseudo element.
+ pseudo = target.type;
+ target = target.element;
+ }
+ const value = DOMWindowUtils.getUnanimatedComputedStyle(
+ target,
+ pseudo,
+ property.name,
+ DOMWindowUtils.FLUSH_NONE
+ );
+ const animationType = getAnimationTypeForLonghand(property.name);
+ underlyingValue =
+ animationType === "float" ? parseFloat(value, 10) : value;
+ }
+ values.value = underlyingValue;
+ });
+ }
+
+ // Calculate the distance.
+ for (const property of properties) {
+ const propertyName = property.name;
+ const maxObject = { distance: -1 };
+ for (let i = 0; i < property.values.length - 1; i++) {
+ const value1 = property.values[i].value;
+ for (let j = i + 1; j < property.values.length; j++) {
+ const value2 = property.values[j].value;
+ const distance = this.getDistance(
+ this.node,
+ propertyName,
+ value1,
+ value2,
+ DOMWindowUtils
+ );
+ if (maxObject.distance >= distance) {
+ continue;
+ }
+ maxObject.distance = distance;
+ maxObject.value1 = value1;
+ maxObject.value2 = value2;
+ }
+ }
+ if (maxObject.distance === 0) {
+ // Distance is zero means that no values change or can't calculate the distance.
+ // In this case, we use the keyframe offset as the distance.
+ property.values.reduce((previous, current) => {
+ // If the current value is same as previous value, use previous distance.
+ current.distance =
+ current.value === previous.value
+ ? previous.distance
+ : current.offset;
+ return current;
+ }, property.values[0]);
+ continue;
+ }
+ const baseValue =
+ maxObject.value1 < maxObject.value2
+ ? maxObject.value1
+ : maxObject.value2;
+ for (const values of property.values) {
+ const value = values.value;
+ const distance = this.getDistance(
+ this.node,
+ propertyName,
+ baseValue,
+ value,
+ DOMWindowUtils
+ );
+ values.distance = distance / maxObject.distance;
+ }
+ }
+ return properties;
+ }
+
+ /**
+ * Get the animation types for a given list of CSS property names.
+ * @param {Array} propertyNames - CSS property names (e.g. background-color)
+ * @return {Object} Returns animation types (e.g. {"background-color": "rgb(0, 0, 0)"}.
+ */
+ getAnimationTypes(propertyNames) {
+ const animationTypes = {};
+ for (const propertyName of propertyNames) {
+ animationTypes[propertyName] = getAnimationTypeForLonghand(propertyName);
+ }
+ return animationTypes;
+ }
+
+ /**
+ * Returns the distance of between value1, value2.
+ * @param {Object} target - dom element
+ * @param {String} propertyName - e.g. transform
+ * @param {String} value1 - e.g. translate(0px)
+ * @param {String} value2 - e.g. translate(10px)
+ * @param {Object} DOMWindowUtils
+ * @param {float} distance
+ */
+ getDistance(target, propertyName, value1, value2, DOMWindowUtils) {
+ if (value1 === value2) {
+ return 0;
+ }
+ try {
+ const distance = DOMWindowUtils.computeAnimationDistance(
+ target,
+ propertyName,
+ value1,
+ value2
+ );
+ return distance;
+ } catch (e) {
+ // We can't compute the distance such the 'discrete' animation,
+ // 'auto' keyword and so on.
+ return 0;
+ }
+ }
+}
+
+exports.AnimationPlayerActor = AnimationPlayerActor;
+
+/**
+ * The Animations actor lists animation players for a given node.
+ */
+exports.AnimationsActor = class AnimationsActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, animationsSpec);
+ this.targetActor = targetActor;
+
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+ this.onNavigate = this.onNavigate.bind(this);
+ this.onAnimationMutation = this.onAnimationMutation.bind(this);
+
+ this.allAnimationsPaused = false;
+ this.targetActor.on("will-navigate", this.onWillNavigate);
+ this.targetActor.on("navigate", this.onNavigate);
+ }
+
+ destroy() {
+ super.destroy();
+ this.targetActor.off("will-navigate", this.onWillNavigate);
+ this.targetActor.off("navigate", this.onNavigate);
+
+ this.stopAnimationPlayerUpdates();
+ this.targetActor = this.observer = this.actors = this.walker = null;
+ }
+
+ /**
+ * Clients can optionally call this with a reference to their WalkerActor.
+ * If they do, then AnimationPlayerActor's forms are going to also include
+ * NodeActor IDs when the corresponding NodeActors do exist.
+ * This, in turns, is helpful for clients to avoid having to go back once more
+ * to the server to get a NodeActor for a particular animation.
+ * @param {WalkerActor} walker
+ */
+ setWalkerActor(walker) {
+ this.walker = walker;
+ }
+
+ /**
+ * Retrieve the list of AnimationPlayerActor actors for currently running
+ * animations on a node and its descendants.
+ * Note that calling this method a second time will destroy all previously
+ * retrieved AnimationPlayerActors. Indeed, the lifecycle of these actors
+ * is managed here on the server and tied to getAnimationPlayersForNode
+ * being called.
+ * @param {NodeActor} nodeActor The NodeActor as defined in
+ * /devtools/server/actors/inspector
+ */
+ getAnimationPlayersForNode(nodeActor) {
+ const animations = nodeActor.rawNode.getAnimations({ subtree: true });
+
+ // Destroy previously stored actors
+ if (this.actors) {
+ for (const actor of this.actors) {
+ actor.destroy();
+ }
+ }
+
+ this.actors = [];
+
+ for (const animation of animations) {
+ const createdTime = this.getCreatedTime(animation);
+ const actor = new AnimationPlayerActor(this, animation, createdTime);
+ this.actors.push(actor);
+ }
+
+ // When a front requests the list of players for a node, start listening
+ // for animation mutations on this node to send updates to the front, until
+ // either getAnimationPlayersForNode is called again or
+ // stopAnimationPlayerUpdates is called.
+ this.stopAnimationPlayerUpdates();
+ // ownerGlobal doesn't exist in content privileged windows.
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ const win = nodeActor.rawNode.ownerDocument.defaultView;
+ this.observer = new win.MutationObserver(this.onAnimationMutation);
+ this.observer.observe(nodeActor.rawNode, {
+ animations: true,
+ subtree: true,
+ });
+
+ return this.actors;
+ }
+
+ onAnimationMutation(mutations) {
+ const eventData = [];
+ const readyPromises = [];
+
+ for (const { addedAnimations, removedAnimations } of mutations) {
+ for (const player of removedAnimations) {
+ // Note that animations are reported as removed either when they are
+ // actually removed from the node (e.g. css class removed) or when they
+ // are finished and don't have forwards animation-fill-mode.
+ // In the latter case, we don't send an event, because the corresponding
+ // animation can still be seeked/resumed, so we want the client to keep
+ // its reference to the AnimationPlayerActor.
+ if (player.playState !== "idle") {
+ continue;
+ }
+
+ const index = this.actors.findIndex(a => a.player === player);
+ if (index !== -1) {
+ eventData.push({
+ type: "removed",
+ player: this.actors[index],
+ });
+ this.actors.splice(index, 1);
+ }
+ }
+
+ for (const player of addedAnimations) {
+ // If the added player already exists, it means we previously filtered
+ // it out when it was reported as removed. So filter it out here too.
+ if (this.actors.find(a => a.player === player)) {
+ continue;
+ }
+
+ // If the added player has the same name and target node as a player we
+ // already have, it means it's a transition that's re-starting. So send
+ // a "removed" event for the one we already have.
+ const index = this.actors.findIndex(a => {
+ const isSameType = a.player.constructor === player.constructor;
+ const isSameName =
+ (a.isCssAnimation() &&
+ a.player.animationName === player.animationName) ||
+ (a.isCssTransition() &&
+ a.player.transitionProperty === player.transitionProperty);
+ const isSameNode = a.player.effect.target === player.effect.target;
+
+ return isSameType && isSameNode && isSameName;
+ });
+ if (index !== -1) {
+ eventData.push({
+ type: "removed",
+ player: this.actors[index],
+ });
+ this.actors.splice(index, 1);
+ }
+
+ const createdTime = this.getCreatedTime(player);
+ const actor = new AnimationPlayerActor(this, player, createdTime);
+ this.actors.push(actor);
+ eventData.push({
+ type: "added",
+ player: actor,
+ });
+ readyPromises.push(player.ready);
+ }
+ }
+
+ if (eventData.length) {
+ // Let's wait for all added animations to be ready before telling the
+ // front-end.
+ Promise.all(readyPromises).then(() => {
+ this.emit("mutations", eventData);
+ });
+ }
+ }
+
+ /**
+ * After the client has called getAnimationPlayersForNode for a given DOM
+ * node, the actor starts sending animation mutations for this node. If the
+ * client doesn't want this to happen anymore, it should call this method.
+ */
+ stopAnimationPlayerUpdates() {
+ if (this.observer && !Cu.isDeadWrapper(this.observer)) {
+ this.observer.disconnect();
+ }
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.stopAnimationPlayerUpdates();
+ }
+ }
+
+ onNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.allAnimationsPaused = false;
+ }
+ }
+
+ /**
+ * Pause given animations.
+ *
+ * @param {Array} actors A list of AnimationPlayerActor.
+ */
+ pauseSome(actors) {
+ for (const { player } of actors) {
+ this.pauseSync(player);
+ }
+
+ return this.waitForNextFrame(actors);
+ }
+
+ /**
+ * Play given animations.
+ *
+ * @param {Array} actors A list of AnimationPlayerActor.
+ */
+ playSome(actors) {
+ for (const { player } of actors) {
+ this.playSync(player);
+ }
+
+ return this.waitForNextFrame(actors);
+ }
+
+ /**
+ * Set the current time of several animations at the same time.
+ * @param {Array} players A list of AnimationPlayerActor.
+ * @param {Number} time The new currentTime.
+ * @param {Boolean} shouldPause Should the players be paused too.
+ */
+ setCurrentTimes(players, time, shouldPause) {
+ for (const actor of players) {
+ const player = actor.player;
+
+ if (shouldPause) {
+ player.startTime = null;
+ }
+
+ const currentTime =
+ player.playbackRate > 0
+ ? time - actor.createdTime
+ : actor.createdTime - time;
+ player.currentTime = currentTime * Math.abs(player.playbackRate);
+ }
+
+ return this.waitForNextFrame(players);
+ }
+
+ /**
+ * Set the playback rate of several animations at the same time.
+ * @param {Array} actors A list of AnimationPlayerActor.
+ * @param {Number} rate The new rate.
+ */
+ setPlaybackRates(players, rate) {
+ return Promise.all(
+ players.map(({ player }) => {
+ player.updatePlaybackRate(rate);
+ return player.ready;
+ })
+ );
+ }
+
+ /**
+ * Pause given player synchronously.
+ *
+ * @param {Object} player
+ */
+ pauseSync(player) {
+ player.startTime = null;
+ }
+
+ /**
+ * Play given player synchronously.
+ *
+ * @param {Object} player
+ */
+ playSync(player) {
+ if (!player.playbackRate) {
+ // We can not play with playbackRate zero.
+ return;
+ }
+
+ // Play animation in a synchronous fashion by setting the start time directly.
+ const currentTime = player.currentTime || 0;
+ player.startTime =
+ player.timeline.currentTime - currentTime / player.playbackRate;
+ }
+
+ /**
+ * Return created fime of given animaiton.
+ *
+ * @param {Object} animation
+ */
+ getCreatedTime(animation) {
+ return (
+ animation.startTime ||
+ animation.timeline.currentTime -
+ animation.currentTime / animation.playbackRate
+ );
+ }
+
+ /**
+ * Wait for next animation frame.
+ *
+ * @param {Array} actors
+ * @return {Promise} which waits for next frame
+ */
+ waitForNextFrame(actors) {
+ const promises = actors.map(actor => {
+ const doc = actor.document;
+ const win = actor.window;
+ const timeAtCurrent = doc.timeline.currentTime;
+
+ return new Promise(resolve => {
+ win.requestAnimationFrame(() => {
+ if (timeAtCurrent === doc.timeline.currentTime) {
+ win.requestAnimationFrame(resolve);
+ } else {
+ resolve();
+ }
+ });
+ });
+ });
+
+ return Promise.all(promises);
+ }
+};