animation.js (31822B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 /** 8 * Set of actors that expose the Web Animations API to devtools protocol 9 * clients. 10 * 11 * The |Animations| actor is the main entry point. It is used to discover 12 * animation players on given nodes. 13 * There should only be one instance per devtools server. 14 * 15 * The |AnimationPlayer| actor provides attributes and methods to inspect an 16 * animation as well as pause/resume/seek it. 17 * 18 * The Web Animation spec implementation is ongoing in Gecko, and so this set 19 * of actors should evolve when the implementation progresses. 20 * 21 * References: 22 * - WebAnimation spec: 23 * http://drafts.csswg.org/web-animations/ 24 * - WebAnimation WebIDL files: 25 * /dom/webidl/Animation*.webidl 26 */ 27 28 const { Actor } = require("resource://devtools/shared/protocol.js"); 29 const { 30 animationPlayerSpec, 31 animationsSpec, 32 } = require("resource://devtools/shared/specs/animation.js"); 33 34 const { 35 ANIMATION_TYPE_FOR_LONGHANDS, 36 } = require("resource://devtools/server/actors/animation-type-longhand.js"); 37 38 loader.lazyRequireGetter( 39 this, 40 "getNodeDisplayName", 41 "resource://devtools/server/actors/inspector/utils.js", 42 true 43 ); 44 45 // Types of animations. 46 const ANIMATION_TYPES = { 47 CSS_ANIMATION: "cssanimation", 48 CSS_TRANSITION: "csstransition", 49 SCRIPT_ANIMATION: "scriptanimation", 50 UNKNOWN: "unknown", 51 }; 52 exports.ANIMATION_TYPES = ANIMATION_TYPES; 53 54 function getAnimationTypeForLonghand(property) { 55 // If this is a custom property, return "custom" for now as it's not straightforward 56 // to retrieve the proper animation type. 57 // TODO: We could compute the animation type from the registered property syntax (Bug 1875435) 58 if (property.startsWith("--")) { 59 return "custom"; 60 } 61 62 for (const [type, props] of ANIMATION_TYPE_FOR_LONGHANDS) { 63 if (props.has(property)) { 64 return type; 65 } 66 } 67 throw new Error("Unknown longhand property name"); 68 } 69 exports.getAnimationTypeForLonghand = getAnimationTypeForLonghand; 70 71 /** 72 * The AnimationPlayerActor provides information about a given animation: its 73 * startTime, currentTime, current state, etc. 74 * 75 * Since the state of a player changes as the animation progresses it is often 76 * useful to call getCurrentState at regular intervals to get the current state. 77 * 78 * This actor also allows playing, pausing and seeking the animation. 79 */ 80 class AnimationPlayerActor extends Actor { 81 /** 82 * @param {AnimationsActor} The main AnimationsActor instance 83 * @param {AnimationPlayer} The player object returned by getAnimationPlayers 84 * @param {number} Time which animation created 85 */ 86 constructor(animationsActor, player, createdTime) { 87 super(animationsActor.conn, animationPlayerSpec); 88 89 this.onAnimationMutation = this.onAnimationMutation.bind(this); 90 91 this.animationsActor = animationsActor; 92 this.walker = animationsActor.walker; 93 this.player = player; 94 // getting the node might need to traverse the DOM, let's only do this once, when 95 // the Actor gets created 96 this.node = this.getNode(); 97 98 // Listen to animation mutations on the node to alert the front when the 99 // current animation changes. 100 this.observer = new this.window.MutationObserver(this.onAnimationMutation); 101 if (this.isPseudoElement) { 102 // If the node is a pseudo-element, then we listen on its binding element (which is 103 // this.player.effect.target here), with `subtree:true` (there's no risk of getting 104 // too many notifications in onAnimationTargetMutation since we filter out events 105 // that aren't for the current animation). 106 this.observer.observe(this.player.effect.target, { 107 animations: true, 108 subtree: true, 109 }); 110 } else { 111 this.observer.observe(this.node, { animations: true }); 112 } 113 114 this.createdTime = createdTime; 115 this.currentTimeAtCreated = player.currentTime; 116 } 117 118 destroy() { 119 // Only try to disconnect the observer if it's not already dead (i.e. if the 120 // container view hasn't navigated since). 121 if (this.observer && !Cu.isDeadWrapper(this.observer)) { 122 this.observer.disconnect(); 123 } 124 this.player = this.observer = this.walker = this.animationsActor = null; 125 126 super.destroy(); 127 } 128 129 get isPseudoElement() { 130 return !!this.player.effect.pseudoElement; 131 } 132 133 getNode() { 134 if (!this.isPseudoElement) { 135 return this.player.effect.target; 136 } 137 138 const originatingElem = this.player.effect.target; 139 const treeWalker = this.walker.getDocumentWalker(originatingElem); 140 141 // When the animated node is a pseudo-element, we need to walk the children 142 // of the target node and look for it. 143 for ( 144 let next = treeWalker.firstChild(); 145 next; 146 // Use `nextNode` (and not `nextSibling`) as we might need to traverse the whole 147 // children tree to find nested elements (e.g. `::view-transition-group(root)`). 148 next = treeWalker.nextNode() 149 ) { 150 if (!next.implementedPseudoElement) { 151 continue; 152 } 153 154 if (this.player.effect.pseudoElement === getNodeDisplayName(next)) { 155 return next; 156 } 157 } 158 159 console.warn( 160 `Pseudo element ${this.player.effect.pseudoElement} is not found` 161 ); 162 163 return null; 164 } 165 166 get document() { 167 return this.player.effect.target.ownerDocument; 168 } 169 170 get window() { 171 return this.document.defaultView; 172 } 173 174 /** 175 * Release the actor, when it isn't needed anymore. 176 * Protocol.js uses this release method to call the destroy method. 177 */ 178 release() {} 179 180 form() { 181 const data = this.getCurrentState(); 182 data.actor = this.actorID; 183 184 // If we know the WalkerActor, and if the animated node is known by it, then 185 // return its corresponding NodeActor ID too. 186 if (this.walker && this.walker.hasNode(this.node)) { 187 data.animationTargetNodeActorID = this.walker.getNode(this.node).actorID; 188 } 189 190 return data; 191 } 192 193 isCssAnimation(player = this.player) { 194 return this.window.CSSAnimation.isInstance(player); 195 } 196 197 isCssTransition(player = this.player) { 198 return this.window.CSSTransition.isInstance(player); 199 } 200 201 isScriptAnimation(player = this.player) { 202 return ( 203 this.window.Animation.isInstance(player) && 204 !( 205 this.window.CSSAnimation.isInstance(player) || 206 this.window.CSSTransition.isInstance(player) 207 ) 208 ); 209 } 210 211 getType() { 212 if (this.isCssAnimation()) { 213 return ANIMATION_TYPES.CSS_ANIMATION; 214 } else if (this.isCssTransition()) { 215 return ANIMATION_TYPES.CSS_TRANSITION; 216 } else if (this.isScriptAnimation()) { 217 return ANIMATION_TYPES.SCRIPT_ANIMATION; 218 } 219 220 return ANIMATION_TYPES.UNKNOWN; 221 } 222 223 /** 224 * Get the name of this animation. This can be either the animation.id 225 * property if it was set, or the keyframe rule name or the transition 226 * property. 227 * 228 * @return {string} 229 */ 230 getName() { 231 if (this.player.id) { 232 return this.player.id; 233 } else if (this.isCssAnimation()) { 234 return this.player.animationName; 235 } else if (this.isCssTransition()) { 236 return this.player.transitionProperty; 237 } 238 239 return ""; 240 } 241 242 /** 243 * Get the animation duration from this player, in milliseconds. 244 * 245 * @return {number} 246 */ 247 getDuration() { 248 return this.player.effect.getComputedTiming().duration; 249 } 250 251 /** 252 * Get the animation delay from this player, in milliseconds. 253 * 254 * @return {number} 255 */ 256 getDelay() { 257 return this.player.effect.getComputedTiming().delay; 258 } 259 260 /** 261 * Get the animation endDelay from this player, in milliseconds. 262 * 263 * @return {number} 264 */ 265 getEndDelay() { 266 return this.player.effect.getComputedTiming().endDelay; 267 } 268 269 /** 270 * Get the animation iteration count for this player. That is, how many times 271 * is the animation scheduled to run. 272 * 273 * @return {number} The number of iterations, or null if the animation repeats 274 * infinitely. 275 */ 276 getIterationCount() { 277 const iterations = this.player.effect.getComputedTiming().iterations; 278 return iterations === Infinity ? null : iterations; 279 } 280 281 /** 282 * Get the animation iterationStart from this player, in ratio. 283 * That is offset of starting position of the animation. 284 * 285 * @return {number} 286 */ 287 getIterationStart() { 288 return this.player.effect.getComputedTiming().iterationStart; 289 } 290 291 /** 292 * Get the animation easing from this player. 293 * 294 * @return {string} 295 */ 296 getEasing() { 297 return this.player.effect.getComputedTiming().easing; 298 } 299 300 /** 301 * Get the animation fill mode from this player. 302 * 303 * @return {string} 304 */ 305 getFill() { 306 return this.player.effect.getComputedTiming().fill; 307 } 308 309 /** 310 * Get the animation direction from this player. 311 * 312 * @return {string} 313 */ 314 getDirection() { 315 return this.player.effect.getComputedTiming().direction; 316 } 317 318 /** 319 * Get animation-timing-function from animated element if CSS Animations. 320 * 321 * @return {string} 322 */ 323 getAnimationTimingFunction() { 324 if (!this.isCssAnimation()) { 325 return null; 326 } 327 328 const { target, pseudoElement } = this.player.effect; 329 return this.window.getComputedStyle(target, pseudoElement) 330 .animationTimingFunction; 331 } 332 333 getPropertiesCompositorStatus() { 334 const properties = this.player.effect.getProperties(); 335 return properties.map(prop => { 336 return { 337 property: prop.property, 338 runningOnCompositor: prop.runningOnCompositor, 339 warning: prop.warning, 340 }; 341 }); 342 } 343 344 /** 345 * Return the current start of the Animation. 346 * 347 * @return {object} 348 */ 349 getState() { 350 const compositorStatus = this.getPropertiesCompositorStatus(); 351 // Note that if you add a new property to the state object, make sure you 352 // add the corresponding property in the AnimationPlayerFront' initialState 353 // getter. 354 return { 355 // Don't include the type if the animation was removed (e.g. it isn't handled by the 356 // AnimationsActor anymore). The client filters out animations without type as a 357 // result of its calls to AnimationPlayerFront#refreshState. 358 type: this.animationRemoved ? null : this.getType(), 359 // startTime is null whenever the animation is paused or waiting to start. 360 startTime: this.player.startTime, 361 currentTime: this.player.currentTime, 362 playState: this.player.playState, 363 playbackRate: this.player.playbackRate, 364 name: this.getName(), 365 duration: this.getDuration(), 366 delay: this.getDelay(), 367 endDelay: this.getEndDelay(), 368 iterationCount: this.getIterationCount(), 369 iterationStart: this.getIterationStart(), 370 fill: this.getFill(), 371 easing: this.getEasing(), 372 direction: this.getDirection(), 373 animationTimingFunction: this.getAnimationTimingFunction(), 374 // animation is hitting the fast path or not. Returns false whenever the 375 // animation is paused as it is taken off the compositor then. 376 isRunningOnCompositor: compositorStatus.some( 377 propState => propState.runningOnCompositor 378 ), 379 propertyState: compositorStatus, 380 // The document timeline's currentTime is being sent along too. This is 381 // not strictly related to the node's animationPlayer, but is useful to 382 // know the current time of the animation with respect to the document's. 383 documentCurrentTime: this.document.timeline.currentTime, 384 // The time which this animation created. 385 createdTime: this.createdTime, 386 // The time which an animation's current time when this animation has created. 387 currentTimeAtCreated: this.currentTimeAtCreated, 388 properties: this.getProperties(), 389 }; 390 } 391 392 /** 393 * Get the current state of the AnimationPlayer (currentTime, playState, ...). 394 * Note that the initial state is returned as the form of this actor when it 395 * is initialized. 396 * This protocol method only returns a trimed down version of this state in 397 * case some properties haven't changed since last time (since the front can 398 * reconstruct those). If you want the full state, use the getState method. 399 * 400 * @return {object} 401 */ 402 getCurrentState() { 403 const newState = this.getState(); 404 405 // If we've saved a state before, compare and only send what has changed. 406 // It's expected of the front to also save old states to re-construct the 407 // full state when an incomplete one is received. 408 // This is to minimize protocol traffic. 409 let sentState = {}; 410 if (this.currentState) { 411 for (const key in newState) { 412 if ( 413 typeof this.currentState[key] === "undefined" || 414 this.currentState[key] !== newState[key] 415 ) { 416 sentState[key] = newState[key]; 417 } 418 } 419 } else { 420 sentState = newState; 421 } 422 this.currentState = newState; 423 424 return sentState; 425 } 426 427 /** 428 * Executed when the current animation changes, used to emit the new state 429 * the the front. 430 */ 431 onAnimationMutation(mutations) { 432 const isCurrentAnimation = animation => animation === this.player; 433 const hasCurrentAnimation = animations => 434 animations.some(isCurrentAnimation); 435 let hasChanged = false; 436 437 for (const { removedAnimations, changedAnimations } of mutations) { 438 if (hasCurrentAnimation(removedAnimations)) { 439 // Reset the local copy of the state on removal, since the animation can 440 // be kept on the client and re-added, its state needs to be sent in 441 // full. 442 this.currentState = null; 443 } 444 445 if (hasCurrentAnimation(changedAnimations)) { 446 // Only consider the state has having changed if any of effect timing properties, 447 // animationTimingFunction or playbackRate has changed. 448 const newState = this.getState(); 449 const oldState = this.currentState; 450 hasChanged = 451 newState.delay !== oldState.delay || 452 newState.iterationCount !== oldState.iterationCount || 453 newState.iterationStart !== oldState.iterationStart || 454 newState.duration !== oldState.duration || 455 newState.endDelay !== oldState.endDelay || 456 newState.direction !== oldState.direction || 457 newState.easing !== oldState.easing || 458 newState.fill !== oldState.fill || 459 newState.animationTimingFunction !== 460 oldState.animationTimingFunction || 461 newState.playbackRate !== oldState.playbackRate; 462 break; 463 } 464 } 465 466 if (hasChanged) { 467 this.emit("changed", this.getCurrentState()); 468 } 469 } 470 471 onAnimationRemoved() { 472 this.animationRemoved = true; 473 } 474 475 /** 476 * Get data about the animated properties of this animation player. 477 * 478 * @return {Array} Returns a list of animated properties. 479 * Each property contains a list of values, their offsets and distances. 480 */ 481 getProperties() { 482 const properties = this.player.effect.getProperties().map(property => { 483 return { name: property.property, values: property.values }; 484 }); 485 486 // If the node isn't connected, the call to DOMWindowUtils.getUnanimatedComputedStyle 487 // below would throw. So early return from here, we'll miss the distance but that 488 // seems fine. 489 if (!this.node?.isConnected) { 490 return properties; 491 } 492 493 const DOMWindowUtils = this.window.windowUtils; 494 495 // Fill missing keyframe with computed value. 496 for (const property of properties) { 497 let underlyingValue = null; 498 // Check only 0% and 100% keyframes. 499 [0, property.values.length - 1].forEach(index => { 500 const values = property.values[index]; 501 if (values.value !== undefined) { 502 return; 503 } 504 if (!underlyingValue) { 505 const { target, pseudoElement } = this.player.effect; 506 const value = DOMWindowUtils.getUnanimatedComputedStyle( 507 target, 508 pseudoElement, 509 property.name, 510 DOMWindowUtils.FLUSH_NONE 511 ); 512 const animationType = getAnimationTypeForLonghand(property.name); 513 underlyingValue = 514 animationType === "float" ? parseFloat(value, 10) : value; 515 } 516 values.value = underlyingValue; 517 }); 518 } 519 520 // Calculate the distance. 521 for (const property of properties) { 522 const propertyName = property.name; 523 const maxObject = { distance: -1 }; 524 for (let i = 0; i < property.values.length - 1; i++) { 525 const value1 = property.values[i].value; 526 for (let j = i + 1; j < property.values.length; j++) { 527 const value2 = property.values[j].value; 528 const distance = this.getDistance( 529 this.node, 530 propertyName, 531 value1, 532 value2, 533 DOMWindowUtils 534 ); 535 if (maxObject.distance >= distance) { 536 continue; 537 } 538 maxObject.distance = distance; 539 maxObject.value1 = value1; 540 maxObject.value2 = value2; 541 } 542 } 543 if (maxObject.distance === 0) { 544 // Distance is zero means that no values change or can't calculate the distance. 545 // In this case, we use the keyframe offset as the distance. 546 property.values.reduce((previous, current) => { 547 // If the current value is same as previous value, use previous distance. 548 current.distance = 549 current.value === previous.value 550 ? previous.distance 551 : current.offset; 552 return current; 553 }, property.values[0]); 554 continue; 555 } 556 const baseValue = 557 maxObject.value1 < maxObject.value2 558 ? maxObject.value1 559 : maxObject.value2; 560 for (const values of property.values) { 561 const value = values.value; 562 const distance = this.getDistance( 563 this.node, 564 propertyName, 565 baseValue, 566 value, 567 DOMWindowUtils 568 ); 569 values.distance = distance / maxObject.distance; 570 } 571 } 572 return properties; 573 } 574 575 /** 576 * Get the animation types for a given list of CSS property names. 577 * 578 * @param {Array} propertyNames - CSS property names (e.g. background-color) 579 * @return {object} Returns animation types (e.g. {"background-color": "rgb(0, 0, 0)"}. 580 */ 581 getAnimationTypes(propertyNames) { 582 const animationTypes = {}; 583 for (const propertyName of propertyNames) { 584 animationTypes[propertyName] = getAnimationTypeForLonghand(propertyName); 585 } 586 return animationTypes; 587 } 588 589 /** 590 * Returns the distance of between value1, value2. 591 * 592 * @param {object} target - dom element 593 * @param {string} propertyName - e.g. transform 594 * @param {string} value1 - e.g. translate(0px) 595 * @param {string} value2 - e.g. translate(10px) 596 * @param {object} DOMWindowUtils 597 * @param {float} distance 598 */ 599 getDistance(target, propertyName, value1, value2, DOMWindowUtils) { 600 if (value1 === value2) { 601 return 0; 602 } 603 try { 604 const distance = DOMWindowUtils.computeAnimationDistance( 605 target, 606 propertyName, 607 value1, 608 value2 609 ); 610 return distance; 611 } catch (e) { 612 // We can't compute the distance such the 'discrete' animation, 613 // 'auto' keyword and so on. 614 return 0; 615 } 616 } 617 } 618 619 exports.AnimationPlayerActor = AnimationPlayerActor; 620 621 /** 622 * The Animations actor lists animation players for a given node. 623 */ 624 exports.AnimationsActor = class AnimationsActor extends Actor { 625 constructor(conn, targetActor) { 626 super(conn, animationsSpec); 627 this.targetActor = targetActor; 628 629 this.onWillNavigate = this.onWillNavigate.bind(this); 630 this.onNavigate = this.onNavigate.bind(this); 631 this.onAnimationMutation = this.onAnimationMutation.bind(this); 632 633 this.allAnimationsPaused = false; 634 this.targetActor.on("will-navigate", this.onWillNavigate); 635 this.targetActor.on("navigate", this.onNavigate); 636 } 637 638 destroy() { 639 super.destroy(); 640 this.targetActor.off("will-navigate", this.onWillNavigate); 641 this.targetActor.off("navigate", this.onNavigate); 642 643 this.stopAnimationPlayerUpdates(); 644 this.targetActor = this.observer = this.actors = this.walker = null; 645 } 646 647 /** 648 * Clients can optionally call this with a reference to their WalkerActor. 649 * If they do, then AnimationPlayerActor's forms are going to also include 650 * NodeActor IDs when the corresponding NodeActors do exist. 651 * This, in turns, is helpful for clients to avoid having to go back once more 652 * to the server to get a NodeActor for a particular animation. 653 * 654 * @param {WalkerActor} walker 655 */ 656 setWalkerActor(walker) { 657 this.walker = walker; 658 } 659 660 /** 661 * Retrieve the list of AnimationPlayerActor actors for currently running 662 * animations on a node and its descendants. 663 * Note that calling this method a second time will destroy all previously 664 * retrieved AnimationPlayerActors. Indeed, the lifecycle of these actors 665 * is managed here on the server and tied to getAnimationPlayersForNode 666 * being called. 667 * 668 * @param {NodeActor} nodeActor The NodeActor as defined in 669 * /devtools/server/actors/inspector 670 */ 671 getAnimationPlayersForNode(nodeActor) { 672 let { rawNode } = nodeActor; 673 674 // If the selected node is a ::view-transition child, we want to show all the view-transition 675 // animations so the user can't play only "parts" of the transition. 676 const viewTransitionNode = this.#closestViewTransitionNode(rawNode); 677 if (viewTransitionNode) { 678 rawNode = viewTransitionNode; 679 } 680 681 const animations = rawNode.getAnimations({ subtree: true }); 682 683 // Destroy previously stored actors 684 if (this.actors) { 685 for (const actor of this.actors) { 686 actor.destroy(); 687 } 688 } 689 690 this.actors = []; 691 692 for (const animation of animations) { 693 const createdTime = this.getCreatedTime(animation); 694 const actor = new AnimationPlayerActor(this, animation, createdTime); 695 this.actors.push(actor); 696 } 697 698 // When a front requests the list of players for a node, start listening 699 // for animation mutations on this node to send updates to the front, until 700 // either getAnimationPlayersForNode is called again or 701 // stopAnimationPlayerUpdates is called. 702 this.stopAnimationPlayerUpdates(); 703 // ownerGlobal doesn't exist in content privileged windows. 704 // eslint-disable-next-line mozilla/use-ownerGlobal 705 const win = rawNode.ownerDocument.defaultView; 706 this.observer = new win.MutationObserver(this.onAnimationMutation); 707 this.observer.observe(rawNode, { 708 animations: true, 709 subtree: true, 710 }); 711 712 return this.actors; 713 } 714 715 /** 716 * Returns the passed node closest ::view-transition node if it exists, null otherwise 717 * 718 * @param {Element} rawNode 719 * @returns {Element|null} 720 */ 721 #closestViewTransitionNode(rawNode) { 722 const { implementedPseudoElement } = rawNode; 723 if ( 724 !implementedPseudoElement || 725 !implementedPseudoElement?.startsWith("::view-transition") 726 ) { 727 return null; 728 } 729 // Look up for the root ::view-transition node 730 while ( 731 rawNode && 732 rawNode.implementedPseudoElement && 733 rawNode.implementedPseudoElement !== "::view-transition" 734 ) { 735 rawNode = rawNode.parentElement; 736 } 737 738 return rawNode; 739 } 740 741 onAnimationMutation(mutations) { 742 const eventData = []; 743 const readyPromises = []; 744 745 for (const { addedAnimations, removedAnimations } of mutations) { 746 for (const player of removedAnimations) { 747 // Note that animations are reported as removed either when they are 748 // actually removed from the node (e.g. css class removed) or when they 749 // are finished and don't have forwards animation-fill-mode. 750 // In the latter case, we don't send an event, because the corresponding 751 // animation can still be seeked/resumed, so we want the client to keep 752 // its reference to the AnimationPlayerActor. 753 if (player.playState !== "idle") { 754 continue; 755 } 756 757 const index = this.actors.findIndex(a => a.player === player); 758 if (index !== -1) { 759 eventData.push({ 760 type: "removed", 761 player: this.actors[index], 762 }); 763 this.actors[index].onAnimationRemoved(); 764 this.actors.splice(index, 1); 765 } 766 } 767 768 for (const player of addedAnimations) { 769 // If the added player already exists, it means we previously filtered 770 // it out when it was reported as removed. So filter it out here too. 771 if (this.actors.find(a => a.player === player)) { 772 continue; 773 } 774 775 // If the added player has the same name and target node as a player we 776 // already have, it means it's a transition that's re-starting. So send 777 // a "removed" event for the one we already have. 778 const index = this.actors.findIndex(a => { 779 const isSameType = a.player.constructor === player.constructor; 780 const isSameName = 781 (a.isCssAnimation() && 782 a.player.animationName === player.animationName) || 783 (a.isCssTransition() && 784 a.player.transitionProperty === player.transitionProperty); 785 const isSameNode = 786 a.player.effect.target === player.effect.target && 787 a.player.effect.pseudoElement === player.effect.pseudoElement; 788 789 return isSameType && isSameNode && isSameName; 790 }); 791 if (index !== -1) { 792 eventData.push({ 793 type: "removed", 794 player: this.actors[index], 795 }); 796 this.actors[index].onAnimationRemoved(); 797 this.actors.splice(index, 1); 798 } 799 800 const createdTime = this.getCreatedTime(player); 801 const actor = new AnimationPlayerActor(this, player, createdTime); 802 this.actors.push(actor); 803 eventData.push({ 804 type: "added", 805 player: actor, 806 }); 807 readyPromises.push(player.ready); 808 } 809 } 810 811 if (eventData.length) { 812 // Let's wait for all added animations to be ready before telling the 813 // front-end. 814 Promise.all(readyPromises).then(() => { 815 this.emit("mutations", eventData); 816 }); 817 } 818 } 819 820 /** 821 * After the client has called getAnimationPlayersForNode for a given DOM 822 * node, the actor starts sending animation mutations for this node. If the 823 * client doesn't want this to happen anymore, it should call this method. 824 */ 825 stopAnimationPlayerUpdates() { 826 if (this.observer && !Cu.isDeadWrapper(this.observer)) { 827 this.observer.disconnect(); 828 } 829 } 830 831 onWillNavigate({ isTopLevel }) { 832 if (isTopLevel) { 833 this.stopAnimationPlayerUpdates(); 834 } 835 } 836 837 onNavigate({ isTopLevel }) { 838 if (isTopLevel) { 839 this.allAnimationsPaused = false; 840 } 841 } 842 843 /** 844 * Pause given animations. 845 * 846 * @param {Array} actors A list of AnimationPlayerActor. 847 */ 848 pauseSome(actors) { 849 const handledActors = []; 850 for (const actor of actors) { 851 // The client could call this with actors that we no longer handle, as it might 852 // not have received the mutations event yet for removed animations. 853 // In such case, ignore the actor, as pausing the animation again might trigger a 854 // new mutation, which would cause problems here and on the client. 855 if (!this.actors.includes(actor)) { 856 continue; 857 } 858 this.pauseSync(actor.player); 859 handledActors.push(actor); 860 } 861 862 return this.waitForNextFrame(handledActors); 863 } 864 865 /** 866 * Play given animations. 867 * 868 * @param {Array} actors A list of AnimationPlayerActor. 869 */ 870 playSome(actors) { 871 const handledActors = []; 872 for (const actor of actors) { 873 // The client could call this with actors that we no longer handle, as it might 874 // not have received the mutations event yet for removed animations. 875 // In such case, ignore the actor, as playing the animation again might trigger a 876 // new mutation, which would cause problems here and on the client. 877 if (!this.actors.includes(actor)) { 878 continue; 879 } 880 this.playSync(actor.player); 881 handledActors.push(actor); 882 } 883 884 return this.waitForNextFrame(handledActors); 885 } 886 887 /** 888 * Set the current time of several animations at the same time. 889 * 890 * @param {Array} actors A list of AnimationPlayerActor. 891 * @param {number} time The new currentTime. 892 * @param {boolean} shouldPause Should the players be paused too. 893 */ 894 setCurrentTimes(actors, time, shouldPause) { 895 const handledActors = []; 896 for (const actor of actors) { 897 // The client could call this with actors that we no longer handle, as it might 898 // not have received the mutations event yet for removed animations. 899 // In such case, ignore the actor, as setting the time might trigger a 900 // new mutation, which would cause problems here and on the client. 901 if (!this.actors.includes(actor)) { 902 continue; 903 } 904 const player = actor.player; 905 906 if (shouldPause) { 907 player.startTime = null; 908 } 909 910 const currentTime = 911 player.playbackRate > 0 912 ? time - actor.createdTime 913 : actor.createdTime - time; 914 player.currentTime = currentTime * Math.abs(player.playbackRate); 915 handledActors.push(actor); 916 } 917 918 return this.waitForNextFrame(handledActors); 919 } 920 921 /** 922 * Set the playback rate of several animations at the same time. 923 * 924 * @param {Array} actors A list of AnimationPlayerActor. 925 * @param {number} rate The new rate. 926 */ 927 setPlaybackRates(actors, rate) { 928 const readyPromises = []; 929 for (const actor of actors) { 930 // The client could call this with actors that we no longer handle, as it might 931 // not have received the mutations event yet for removed animations. 932 // In such case, ignore the actor, as setting the playback rate might trigger a 933 // new mutation, which would cause problems here and on the client. 934 if (!this.actors.includes(actor)) { 935 continue; 936 } 937 actor.player.updatePlaybackRate(rate); 938 readyPromises.push(actor.player.ready); 939 } 940 return Promise.all(readyPromises); 941 } 942 943 /** 944 * Pause given player synchronously. 945 * 946 * @param {object} player 947 */ 948 pauseSync(player) { 949 player.startTime = null; 950 } 951 952 /** 953 * Play given player synchronously. 954 * 955 * @param {object} player 956 */ 957 playSync(player) { 958 if (!player.playbackRate) { 959 // We can not play with playbackRate zero. 960 return; 961 } 962 963 // Play animation in a synchronous fashion by setting the start time directly. 964 const currentTime = player.currentTime || 0; 965 player.startTime = 966 player.timeline.currentTime - currentTime / player.playbackRate; 967 } 968 969 /** 970 * Return created fime of given animaiton. 971 * 972 * @param {object} animation 973 */ 974 getCreatedTime(animation) { 975 return ( 976 animation.startTime || 977 animation.timeline.currentTime - 978 animation.currentTime / animation.playbackRate 979 ); 980 } 981 982 /** 983 * Wait for next animation frame. 984 * 985 * @param {Array} actors 986 * @return {Promise} which waits for next frame 987 */ 988 waitForNextFrame(actors) { 989 const promises = actors.map(actor => { 990 const doc = actor.document; 991 const win = actor.window; 992 const timeAtCurrent = doc.timeline.currentTime; 993 994 return new Promise(resolve => { 995 win.requestAnimationFrame(() => { 996 if (timeAtCurrent === doc.timeline.currentTime) { 997 win.requestAnimationFrame(resolve); 998 } else { 999 resolve(); 1000 } 1001 }); 1002 }); 1003 }); 1004 1005 return Promise.all(promises); 1006 } 1007 };