Bug 1155661 - 6 - Implement the behavior behind the timeline play/pause button; r=miker
authorPatrick Brosset <pbrosset@mozilla.com>
Fri, 18 Sep 2015 09:28:14 +0200
changeset 295851 bb283bb54d56b03d16805823fc351ddd2ce26157
parent 295850 fb954e0271ecdd382d7737064acf6101ce1c2eb1
child 295852 9df83a75732217b4a48b0e54f7283e35741441e2
push id5245
push userraliiev@mozilla.com
push dateThu, 29 Oct 2015 11:30:51 +0000
treeherdermozilla-beta@dac831dc1bd0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmiker
bugs1155661
milestone43.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1155661 - 6 - Implement the behavior behind the timeline play/pause button; r=miker
browser/devtools/animationinspector/animation-panel.js
browser/devtools/animationinspector/components.js
browser/devtools/animationinspector/test/browser.ini
browser/devtools/animationinspector/test/browser_animation_timeline_pause_button.js
browser/devtools/animationinspector/test/unit/test_timeScale.js
toolkit/devtools/server/actors/animation.js
--- a/browser/devtools/animationinspector/animation-panel.js
+++ b/browser/devtools/animationinspector/animation-panel.js
@@ -45,17 +45,17 @@ var AnimationsPanel = {
     // whole global toolbar.
     if (!AnimationsController.traits.hasToggleAll) {
       document.querySelector("#global-toolbar").style.display = "none";
     }
 
     // Binding functions that need to be called in scope.
     for (let functionName of ["onPickerStarted", "onPickerStopped",
       "refreshAnimations", "toggleAll", "onTabNavigated",
-      "onTimelineDataChanged"]) {
+      "onTimelineDataChanged", "playPauseTimeline"]) {
       this[functionName] = this[functionName].bind(this);
     }
     let hUtils = gToolbox.highlighterUtils;
     this.togglePicker = hUtils.togglePicker.bind(hUtils);
 
     if (AnimationsController.traits.isNewUI) {
       this.animationsTimelineComponent = new AnimationsTimeline(gInspector);
       this.animationsTimelineComponent.init(this.playersEl);
@@ -95,38 +95,40 @@ var AnimationsPanel = {
 
     this.destroyed.resolve();
   }),
 
   startListeners: function() {
     AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT,
       this.refreshAnimations);
 
-    this.pickerButtonEl.addEventListener("click", this.togglePicker, false);
+    this.pickerButtonEl.addEventListener("click", this.togglePicker);
     gToolbox.on("picker-started", this.onPickerStarted);
     gToolbox.on("picker-stopped", this.onPickerStopped);
 
-    this.toggleAllButtonEl.addEventListener("click", this.toggleAll, false);
+    this.toggleAllButtonEl.addEventListener("click", this.toggleAll);
+    this.playTimelineButtonEl.addEventListener("click", this.playPauseTimeline);
     gToolbox.target.on("navigate", this.onTabNavigated);
 
     if (this.animationsTimelineComponent) {
       this.animationsTimelineComponent.on("timeline-data-changed",
         this.onTimelineDataChanged);
     }
   },
 
   stopListeners: function() {
     AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT,
       this.refreshAnimations);
 
-    this.pickerButtonEl.removeEventListener("click", this.togglePicker, false);
+    this.pickerButtonEl.removeEventListener("click", this.togglePicker);
     gToolbox.off("picker-started", this.onPickerStarted);
     gToolbox.off("picker-stopped", this.onPickerStopped);
 
-    this.toggleAllButtonEl.removeEventListener("click", this.toggleAll, false);
+    this.toggleAllButtonEl.removeEventListener("click", this.toggleAll);
+    this.playTimelineButtonEl.removeEventListener("click", this.playPauseTimeline);
     gToolbox.target.off("navigate", this.onTabNavigated);
 
     if (this.animationsTimelineComponent) {
       this.animationsTimelineComponent.off("timeline-data-changed",
         this.onTimelineDataChanged);
     }
   },
 
@@ -167,16 +169,34 @@ var AnimationsPanel = {
                      .catch(error => console.error(error));
       }
     }
 
     btnClass.toggle("paused");
     yield AnimationsController.toggleAll();
   }),
 
+  /**
+   * Depending on the state of the timeline either pause or play the animations
+   * displayed in it.
+   * If the animations are finished, this will play them from the start again.
+   * If the animations are playing, this will pause them.
+   * If the animations are paused, this will resume them.
+   */
+  playPauseTimeline: Task.async(function*() {
+    yield AnimationsController.toggleCurrentAnimations(this.timelineData.isMoving);
+
+    // Now that the playState have been changed make sure the player (the
+    // fronts) are up to date, and then refresh the UI.
+    for (let player of AnimationsController.animationPlayers) {
+      yield player.refreshState(true);
+    }
+    yield this.refreshAnimations();
+  }),
+
   onTabNavigated: function() {
     this.toggleAllButtonEl.classList.remove("paused");
   },
 
   onTimelineDataChanged: function(e, data) {
     this.timelineData = data;
     let {isPaused, isMoving, time} = data;
 
--- a/browser/devtools/animationinspector/components.js
+++ b/browser/devtools/animationinspector/components.js
@@ -560,28 +560,31 @@ var TimeScale = {
   minStartTime: Infinity,
   maxEndTime: 0,
 
   /**
    * Add a new animation to time scale.
    * @param {Object} state A PlayerFront.state object.
    */
   addAnimation: function(state) {
-    let {startTime, delay, duration, iterationCount, playbackRate} = state;
+    let {previousStartTime, delay, duration,
+         iterationCount, playbackRate} = state;
 
     // Negative-delayed animations have their startTimes set such that we would
     // be displaying the delay outside the time window if we didn't take it into
     // account here.
     let relevantDelay = delay < 0 ? delay / playbackRate : 0;
+    previousStartTime = previousStartTime || 0;
 
-    this.minStartTime = Math.min(this.minStartTime, startTime + relevantDelay);
+    this.minStartTime = Math.min(this.minStartTime,
+                                 previousStartTime + relevantDelay);
     let length = (delay / playbackRate) +
                  ((duration / playbackRate) *
                   (!iterationCount ? 1 : iterationCount));
-    this.maxEndTime = Math.max(this.maxEndTime, startTime + length);
+    this.maxEndTime = Math.max(this.maxEndTime, previousStartTime + length);
   },
 
   /**
    * Reset the current time scale.
    */
   reset: function() {
     this.minStartTime = Infinity;
     this.maxEndTime = 0;
@@ -862,22 +865,27 @@ AnimationsTimeline.prototype = {
     if (!documentCurrentTime) {
       this.scrubberEl.style.display = "none";
     } else {
       this.scrubberEl.style.display = "block";
       this.startAnimatingScrubber(documentCurrentTime);
     }
   },
 
+  isAtLeastOneAnimationPlaying: function() {
+    return this.animations.some(({state}) => state.playState === "running");
+  },
+
   startAnimatingScrubber: function(time) {
     let x = TimeScale.startTimeToDistance(time, this.timeHeaderEl.offsetWidth);
     this.scrubberEl.style.left = x + "px";
 
     if (time < TimeScale.minStartTime ||
-        time > TimeScale.maxEndTime) {
+        time > TimeScale.maxEndTime ||
+        !this.isAtLeastOneAnimationPlaying()) {
       this.stopAnimatingScrubber();
       this.emit("timeline-data-changed", {
         isPaused: false,
         isMoving: false,
         time: TimeScale.distanceToRelativeTime(x, this.timeHeaderEl.offsetWidth)
       });
       return;
     }
@@ -952,17 +960,17 @@ AnimationsTimeline.prototype = {
   },
 
   drawTimeBlock: function({state}, el) {
     let width = el.offsetWidth;
 
     // Create a container element to hold the delay and iterations.
     // It is positioned according to its delay (divided by the playbackrate),
     // and its width is according to its duration (divided by the playbackrate).
-    let start = state.startTime;
+    let start = state.previousStartTime || 0;
     let duration = state.duration;
     let rate = state.playbackRate;
     let count = state.iterationCount;
     let delay = state.delay || 0;
 
     let x = TimeScale.startTimeToDistance(start + (delay / rate), width);
     let w = TimeScale.durationToDistance(duration / rate, width);
 
--- a/browser/devtools/animationinspector/test/browser.ini
+++ b/browser/devtools/animationinspector/test/browser.ini
@@ -35,16 +35,17 @@ support-files =
 [browser_animation_refresh_when_active.js]
 [browser_animation_same_nb_of_playerWidgets_and_playerFronts.js]
 [browser_animation_setting_currentTime_works_and_pauses.js]
 [browser_animation_setting_playbackRate_works.js]
 [browser_animation_shows_player_on_valid_node.js]
 [browser_animation_target_highlight_select.js]
 [browser_animation_timeline_displays_with_pref.js]
 [browser_animation_timeline_header.js]
+[browser_animation_timeline_pause_button.js]
 [browser_animation_timeline_scrubber_exists.js]
 [browser_animation_timeline_scrubber_movable.js]
 [browser_animation_timeline_scrubber_moves.js]
 [browser_animation_timeline_shows_delay.js]
 [browser_animation_timeline_shows_iterations.js]
 [browser_animation_timeline_shows_time_info.js]
 [browser_animation_timeline_takes_rate_into_account.js]
 [browser_animation_timeline_ui.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_timeline_pause_button.js
@@ -0,0 +1,67 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the timeline toolbar contains a pause button and that this pause
+// button can be clicked. Check that when it is, the current animations
+// displayed in the timeline get their playstates changed accordingly, and check
+// that the scrubber resumes/stops moving.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+
+  let {panel} = yield openAnimationInspectorNewUI();
+  let btn = panel.playTimelineButtonEl;
+
+  ok(btn, "The play/pause button exists");
+  ok(!btn.classList.contains("paused"),
+     "The play/pause button is in its playing state");
+
+  info("Click on the button to pause all timeline animations");
+  yield clickPlayPauseButton(panel);
+
+  ok(btn.classList.contains("paused"),
+     "The play/pause button is in its paused state");
+  yield checkIfScrubberMoving(panel, false);
+
+  info("Click again on the button to play all timeline animations");
+  yield clickPlayPauseButton(panel);
+
+  ok(!btn.classList.contains("paused"),
+     "The play/pause button is in its playing state again");
+  yield checkIfScrubberMoving(panel, true);
+});
+
+function* clickPlayPauseButton(panel) {
+  let onUiUpdated = panel.once(panel.UI_UPDATED_EVENT);
+
+  let btn = panel.playTimelineButtonEl;
+  let win = btn.ownerDocument.defaultView;
+  EventUtils.sendMouseEvent({type: "click"}, btn, win);
+
+  yield onUiUpdated;
+  yield waitForAllAnimationTargets(panel);
+}
+
+function* checkIfScrubberMoving(panel, isMoving) {
+  let timeline = panel.animationsTimelineComponent;
+  let scrubberEl = timeline.scrubberEl;
+
+  if (isMoving) {
+    // If we expect the scrubber to move, just wait for a couple of
+    // timeline-data-changed events and compare times.
+    let {time: time1} = yield timeline.once("timeline-data-changed");
+    let {time: time2} = yield timeline.once("timeline-data-changed");
+    ok(time2 > time1, "The scrubber is moving");
+  } else {
+    // If instead we expect the scrubber to remain at its position, just wait
+    // for some time. A relatively long timeout is used because the test page
+    // has long running animations, so the scrubber doesn't move that quickly.
+    let startOffset = scrubberEl.offsetLeft;
+    yield new Promise(r => setTimeout(r, 2000));
+    let endOffset = scrubberEl.offsetLeft;
+    is(startOffset, endOffset, "The scrubber is not moving");
+  }
+}
--- a/browser/devtools/animationinspector/test/unit/test_timeScale.js
+++ b/browser/devtools/animationinspector/test/unit/test_timeScale.js
@@ -7,51 +7,51 @@
 
 const Cu = Components.utils;
 const {require} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 const {TimeScale} = require("devtools/animationinspector/components");
 
 const TEST_ANIMATIONS = [{
   desc: "Testing a few standard animations",
   animations: [{
-    startTime: 500,
+    previousStartTime: 500,
     delay: 0,
     duration: 1000,
     iterationCount: 1,
     playbackRate: 1
   }, {
-    startTime: 400,
+    previousStartTime: 400,
     delay: 100,
     duration: 10,
     iterationCount: 100,
     playbackRate: 1
   }, {
-    startTime: 50,
+    previousStartTime: 50,
     delay: 1000,
     duration: 100,
     iterationCount: 20,
     playbackRate: 1
   }],
   expectedMinStart: 50,
   expectedMaxEnd: 3050
 }, {
   desc: "Testing a single negative-delay animation",
   animations: [{
-    startTime: 100,
+    previousStartTime: 100,
     delay: -100,
     duration: 100,
     iterationCount: 1,
     playbackRate: 1
   }],
   expectedMinStart: 0,
   expectedMaxEnd: 100
 }, {
   desc: "Testing a single negative-delay animation with a different rate",
   animations: [{
-    startTime: 3500,
+    previousStartTime: 3500,
     delay: -1000,
     duration: 10000,
     iterationCount: 2,
     playbackRate: 2
   }],
   expectedMinStart: 3000,
   expectedMaxEnd: 13000
 }];
--- a/toolkit/devtools/server/actors/animation.js
+++ b/toolkit/devtools/server/actors/animation.js
@@ -262,23 +262,33 @@ var AnimationPlayerActor = ActorClass({
 
   /**
    * 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.
    * @return {Object}
    */
   getCurrentState: method(function() {
+    // Remember the startTime each time getCurrentState is called, it may be
+    // useful when animations get paused. As in, when an animation gets paused,
+    // it's startTime goes back to null, but the front-end might still be
+    // interested in knowing what the previous startTime was. So everytime it
+    // is set, remember it and send it along with the newState.
+    if (this.player.startTime) {
+      this.previousStartTime = this.player.startTime;
+    }
+
     // Note that if you add a new property to the state object, make sure you
     // add the corresponding property in the AnimationPlayerFront' initialState
     // getter.
     let newState = {
       type: this.getType(),
       // startTime is null whenever the animation is paused or waiting to start.
       startTime: this.player.startTime,
+      previousStartTime: this.previousStartTime,
       currentTime: this.player.currentTime,
       playState: this.player.playState,
       playbackRate: this.player.playbackRate,
       name: this.getName(),
       duration: this.getDuration(),
       delay: this.getDelay(),
       iterationCount: this.getIterationCount(),
       // isRunningOnCompositor is important for developers to know if their
@@ -438,16 +448,17 @@ var AnimationPlayerFront = FrontClass(An
   /**
    * Getter for the initial state of the player. Up to date states can be
    * retrieved by calling the getCurrentState method.
    */
   get initialState() {
     return {
       type: this._form.type,
       startTime: this._form.startTime,
+      previousStartTime: this._form.previousStartTime,
       currentTime: this._form.currentTime,
       playState: this._form.playState,
       playbackRate: this._form.playbackRate,
       name: this._form.name,
       duration: this._form.duration,
       delay: this._form.delay,
       iterationCount: this._form.iterationCount,
       isRunningOnCompositor: this._form.isRunningOnCompositor,
@@ -513,22 +524,25 @@ var AnimationPlayerFront = FrontClass(An
 
     clearInterval(this.autoRefreshTimer);
     this.autoRefreshTimer = null;
   },
 
   /**
    * Called automatically when auto-refresh is on. Doesn't return anything, but
    * emits the "updated-state" event.
+   * @param {Boolean} forceRefresh This function is normally called by the
+   * auto-refresh loop. If you need to call it but are not using this mechanism,
+   * then set this to true.
    */
-  refreshState: Task.async(function*() {
+  refreshState: Task.async(function*(forceRefresh) {
     let data = yield this.getCurrentState();
 
     // By the time the new state is received, auto-refresh might be stopped.
-    if (!this.autoRefreshTimer) {
+    if (!this.autoRefreshTimer && !forceRefresh) {
       return;
     }
 
     if (this.currentStateHasChanged) {
       this.state = data;
       events.emit(this, this.AUTO_REFRESH_EVENT, this.state);
     }
   }),