Bug 1129454 - 2 - Adds a global play/pause button to the animation panel; r=vp
authorPatrick Brosset <pbrosset@mozilla.com>
Thu, 12 Feb 2015 16:28:42 +0100
changeset 256005 ecd8e5e79346d2feaa22de8259beff4d4a5e8b86
parent 256004 b04a0da319b6101555ed58555fb10c1c52a54fc9
child 256006 d74068ffe2edfdce5a810058ae02b32c64b5381f
push id4610
push userjlund@mozilla.com
push dateMon, 30 Mar 2015 18:32:55 +0000
treeherdermozilla-beta@4df54044d9ef [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersvp
bugs1129454
milestone38.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 1129454 - 2 - Adds a global play/pause button to the animation panel; r=vp
browser/devtools/animationinspector/animation-controller.js
browser/devtools/animationinspector/animation-inspector.xhtml
browser/devtools/animationinspector/animation-panel.js
browser/devtools/animationinspector/test/browser.ini
browser/devtools/animationinspector/test/browser_animation_toggle_button_resets_on_navigate.js
browser/devtools/animationinspector/test/browser_animation_toggle_button_toggles_animations.js
browser/devtools/animationinspector/test/browser_animation_toggle_button_updates_playerWidgets.js
browser/devtools/animationinspector/test/browser_animation_toolbar_exists.js
browser/devtools/animationinspector/test/doc_frame_script.js
browser/devtools/animationinspector/test/head.js
browser/locales/en-US/chrome/browser/devtools/animationinspector.dtd
browser/themes/shared/devtools/animationinspector.css
--- a/browser/devtools/animationinspector/animation-controller.js
+++ b/browser/devtools/animationinspector/animation-controller.js
@@ -35,44 +35,42 @@ let startup = Task.async(function*(inspe
   gInspector = inspector;
   gToolbox = inspector.toolbox;
 
   // Don't assume that AnimationsPanel is defined here, it's in another file.
   if (!typeof AnimationsPanel === "undefined") {
     throw new Error("AnimationsPanel was not loaded in the animationinspector window");
   }
 
-  yield promise.all([
-    AnimationsController.initialize(),
-    AnimationsPanel.initialize()
-  ]).then(null, Cu.reportError);
+  // Startup first initalizes the controller and then the panel, in sequence.
+  // If you want to know when everything's ready, do:
+  // AnimationsPanel.once(AnimationsPanel.PANEL_INITIALIZED)
+  yield AnimationsController.initialize();
+  yield AnimationsPanel.initialize();
 });
 
 /**
  * Shutdown the animationinspector controller and view, called by the sidebar
  * widget when loading/unloading the iframe into the tab.
  */
 let shutdown = Task.async(function*() {
-  yield promise.all([
-    AnimationsController.destroy(),
-    // Don't assume that AnimationsPanel is defined here, it's in another file.
-    typeof AnimationsPanel !== "undefined"
-      ? AnimationsPanel.destroy()
-      : promise.resolve()
-  ]).then(() => {
-    gToolbox = gInspector = null;
-  }, Cu.reportError);
+  yield AnimationsController.destroy();
+  // Don't assume that AnimationsPanel is defined here, it's in another file.
+  if (typeof AnimationsPanel !== "undefined") {
+    yield AnimationsPanel.destroy()
+  }
+  gToolbox = gInspector = null;
 });
 
 // This is what makes the sidebar widget able to load/unload the panel.
 function setPanel(panel) {
-  return startup(panel);
+  return startup(panel).catch(Cu.reportError);
 }
 function destroy() {
-  return shutdown();
+  return shutdown().catch(Cu.reportError);
 }
 
 /**
  * The animationinspector controller's job is to retrieve AnimationPlayerFronts
  * from the server. It is also responsible for keeping the list of players up to
  * date when the node selection changes in the inspector, as well as making sure
  * no updates are done when the animationinspector sidebar panel is not visible.
  *
@@ -96,16 +94,18 @@ let AnimationsController = {
   initialize: Task.async(function*() {
     if (this.initialized) {
       return this.initialized.promise;
     }
     this.initialized = promise.defer();
 
     let target = gToolbox.target;
     this.animationsFront = new AnimationsFront(target.client, target.form);
+    // Not all server versions provide a way to pause all animations at once.
+    this.hasToggleAll = yield target.actorHasMethod("animations", "toggleAll");
 
     this.onPanelVisibilityChange = this.onPanelVisibilityChange.bind(this);
     this.onNewNodeFront = this.onNewNodeFront.bind(this);
 
     this.startListeners();
 
     yield this.onNewNodeFront();
 
@@ -181,16 +181,27 @@ let AnimationsController = {
 
     this.nodeFront = gInspector.selection.nodeFront;
     yield this.refreshAnimationPlayers(this.nodeFront);
     this.emit(this.PLAYERS_UPDATED_EVENT, this.animationPlayers);
 
     done();
   }),
 
+  /**
+   * Toggle (pause/play) all animations in the current target.
+   */
+  toggleAll: function() {
+    if (!this.hasToggleAll) {
+      return promis.resolve();
+    }
+
+    return this.animationsFront.toggleAll().catch(Cu.reportError);
+  },
+
   // AnimationPlayerFront objects are managed by this controller. They are
   // retrieved when refreshAnimationPlayers is called, stored in the
   // animationPlayers array, and destroyed when refreshAnimationPlayers is
   // called again.
   animationPlayers: [],
 
   refreshAnimationPlayers: Task.async(function*(nodeFront) {
     yield this.destroyAnimationPlayers();
--- a/browser/devtools/animationinspector/animation-inspector.xhtml
+++ b/browser/devtools/animationinspector/animation-inspector.xhtml
@@ -10,16 +10,20 @@
   <head>
     <title>&title;</title>
     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
     <link rel="stylesheet" href="chrome://browser/skin/devtools/common.css" type="text/css"/>
     <link rel="stylesheet" href="chrome://browser/skin/devtools/animationinspector.css" type="text/css"/>
     <script type="application/javascript;version=1.8" src="chrome://browser/content/devtools/theme-switching.js"/>
   </head>
   <body class="theme-sidebar devtools-monospace" role="application">
+    <div id="toolbar" class="theme-toolbar">
+      <span class="label">&allAnimations;</span>
+      <button id="toggle-all" standalone="true" class="devtools-button"></button>
+    </div>
     <div id="players" class="theme-toolbar"></div>
     <div id="error-message">
       <p>&invalidElement;</p>
       <p>&selectElement;</p>
       <button id="element-picker" standalone="true" class="devtools-button"></button>
     </div>
     <script type="application/javascript;version=1.8" src="animation-controller.js"></script>
     <script type="application/javascript;version=1.8" src="animation-panel.js"></script>
--- a/browser/devtools/animationinspector/animation-panel.js
+++ b/browser/devtools/animationinspector/animation-panel.js
@@ -6,88 +6,134 @@
 
 "use strict";
 
 /**
  * The main animations panel UI.
  */
 let AnimationsPanel = {
   UI_UPDATED_EVENT: "ui-updated",
+  PANEL_INITIALIZED: "panel-initialized",
 
   initialize: Task.async(function*() {
     if (this.initialized) {
       return this.initialized.promise;
     }
     this.initialized = promise.defer();
 
     this.playersEl = document.querySelector("#players");
     this.errorMessageEl = document.querySelector("#error-message");
     this.pickerButtonEl = document.querySelector("#element-picker");
+    this.toggleAllButtonEl = document.querySelector("#toggle-all");
+
+    // If the server doesn't support toggling all animations at once, hide the
+    // whole bottom toolbar.
+    if (!AnimationsController.hasToggleAll) {
+      document.querySelector("#toolbar").style.display = "none";
+    }
 
     let hUtils = gToolbox.highlighterUtils;
     this.togglePicker = hUtils.togglePicker.bind(hUtils);
     this.onPickerStarted = this.onPickerStarted.bind(this);
     this.onPickerStopped = this.onPickerStopped.bind(this);
     this.createPlayerWidgets = this.createPlayerWidgets.bind(this);
+    this.toggleAll = this.toggleAll.bind(this);
+    this.onTabNavigated = this.onTabNavigated.bind(this);
 
     this.startListeners();
 
     this.initialized.resolve();
+
+    this.emit(this.PANEL_INITIALIZED);
   }),
 
   destroy: Task.async(function*() {
     if (!this.initialized) {
       return;
     }
 
     if (this.destroyed) {
       return this.destroyed.promise;
     }
     this.destroyed = promise.defer();
 
     this.stopListeners();
     yield this.destroyPlayerWidgets();
 
     this.playersEl = this.errorMessageEl = null;
+    this.toggleAllButtonEl = this.pickerButtonEl = null;
 
     this.destroyed.resolve();
   }),
 
   startListeners: function() {
     AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT,
       this.createPlayerWidgets);
+
     this.pickerButtonEl.addEventListener("click", this.togglePicker, false);
     gToolbox.on("picker-started", this.onPickerStarted);
     gToolbox.on("picker-stopped", this.onPickerStopped);
+
+    this.toggleAllButtonEl.addEventListener("click", this.toggleAll, false);
+    gToolbox.target.on("navigate", this.onTabNavigated);
   },
 
   stopListeners: function() {
     AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT,
       this.createPlayerWidgets);
+
     this.pickerButtonEl.removeEventListener("click", this.togglePicker, false);
     gToolbox.off("picker-started", this.onPickerStarted);
     gToolbox.off("picker-stopped", this.onPickerStopped);
+
+    this.toggleAllButtonEl.removeEventListener("click", this.toggleAll, false);
+    gToolbox.target.off("navigate", this.onTabNavigated);
   },
 
   displayErrorMessage: function() {
     this.errorMessageEl.style.display = "block";
+    this.playersEl.style.display = "none";
   },
 
   hideErrorMessage: function() {
     this.errorMessageEl.style.display = "none";
+    this.playersEl.style.display = "block";
   },
 
   onPickerStarted: function() {
     this.pickerButtonEl.setAttribute("checked", "true");
   },
 
   onPickerStopped: function() {
     this.pickerButtonEl.removeAttribute("checked");
   },
 
+  toggleAll: Task.async(function*() {
+    let btnClass = this.toggleAllButtonEl.classList;
+
+    // Toggling all animations is async and it may be some time before each of
+    // the current players get their states updated, so toggle locally too, to
+    // avoid the timelines from jumping back and forth.
+    if (this.playerWidgets) {
+      let currentWidgetStateChange = [];
+      for (let widget of this.playerWidgets) {
+        currentWidgetStateChange.push(btnClass.contains("paused")
+          ? widget.play() : widget.pause());
+      }
+      yield promise.all(currentWidgetStateChange).catch(Cu.reportError);
+    }
+
+    btnClass.toggle("paused");
+    yield AnimationsController.toggleAll();
+  }),
+
+  onTabNavigated: function() {
+    this.toggleAllButtonEl.classList.remove("paused");
+  },
+
   createPlayerWidgets: Task.async(function*() {
     let done = gInspector.updating("animationspanel");
 
     // Empty the whole panel first.
     this.hideErrorMessage();
     yield this.destroyPlayerWidgets();
 
     // If there are no players to show, show the error message instead and return.
@@ -302,30 +348,37 @@ PlayerWidget.prototype = {
   },
 
   /**
    * Pause the animation player via this widget.
    * @return {Promise} Resolves when the player is paused, the button is
    * switched to the right state, and the timeline animation is stopped.
    */
   pause: function() {
+    if (this.player.state.playState === "finished") {
+      return;
+    }
+
     // Switch to the right className on the element right away to avoid waiting
     // for the next state update to change the playPause icon.
     this.updateWidgetState({playState: "paused"});
-    return this.player.pause().then(() => {
-      this.stopTimelineAnimation();
-    });
+    this.stopTimelineAnimation();
+    return this.player.pause();
   },
 
   /**
    * Play the animation player via this widget.
    * @return {Promise} Resolves when the player is playing, the button is
    * switched to the right state, and the timeline animation is started.
    */
   play: function() {
+    if (this.player.state.playState === "finished") {
+      return;
+    }
+
     // Switch to the right className on the element right away to avoid waiting
     // for the next state update to change the playPause icon.
     this.updateWidgetState({playState: "running"});
     this.startTimelineAnimation();
     return this.player.play();
   },
 
   updateWidgetState: function({playState}) {
--- a/browser/devtools/animationinspector/test/browser.ini
+++ b/browser/devtools/animationinspector/test/browser.ini
@@ -16,10 +16,14 @@ support-files =
 [browser_animation_playerWidgets_dont_show_time_after_duration.js]
 [browser_animation_playerWidgets_meta_data.js]
 [browser_animation_playerWidgets_state_after_pause.js]
 [browser_animation_refresh_when_active.js]
 [browser_animation_same_nb_of_playerWidgets_and_playerFronts.js]
 [browser_animation_shows_player_on_valid_node.js]
 [browser_animation_timeline_animates.js]
 [browser_animation_timeline_waits_for_delay.js]
+[browser_animation_toggle_button_resets_on_navigate.js]
+[browser_animation_toggle_button_toggles_animations.js]
+[browser_animation_toggle_button_updates_playerWidgets.js]
+[browser_animation_toolbar_exists.js]
 [browser_animation_ui_updates_when_animation_changes.js]
 [browser_animation_ui_updates_when_animation_data_changes.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_toggle_button_resets_on_navigate.js
@@ -0,0 +1,29 @@
+/* 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";
+
+// Test that a page navigation resets the state of the global toggle button.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
+
+  ok(!panel.toggleAllButtonEl.classList.contains("paused"),
+    "The toggle button is in its running state by default");
+
+  info("Toggle all animations, so that they pause");
+  yield panel.toggleAll();
+  ok(panel.toggleAllButtonEl.classList.contains("paused"),
+    "The toggle button now is in its paused state");
+
+  info("Reloading the page");
+  let onNewRoot = inspector.once("new-root");
+  yield reloadTab();
+  yield onNewRoot;
+  yield inspector.once("inspector-updated");
+
+  ok(!panel.toggleAllButtonEl.classList.contains("paused"),
+    "The toggle button is back in its running state");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_toggle_button_toggles_animations.js
@@ -0,0 +1,30 @@
+/* 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";
+
+// Test that the main toggle button actually toggles animations.
+// This test doesn't need to be extra careful about checking that *all*
+// animations have been paused (including inside iframes) because there's an
+// actor test in /toolkit/devtools/server/tests/browser/ that does this.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
+
+  info("Click the toggle button");
+  yield panel.toggleAll();
+  yield checkState("paused");
+
+  info("Click again the toggle button");
+  yield panel.toggleAll();
+  yield checkState("running");
+});
+
+function* checkState(state) {
+  for (let selector of [".animated", ".multi", ".long"]) {
+    let playState = yield getAnimationPlayerState(selector);
+    is(playState, state, "The animation on node " + selector + " is " + state);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_toggle_button_updates_playerWidgets.js
@@ -0,0 +1,35 @@
+/* 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";
+
+// Test that pressing the main toggle button also updates the displayed
+// player widgets.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
+
+  info("Select an animated node");
+  yield selectNode(".animated", inspector);
+  let widget = panel.playerWidgets[0];
+
+  info("Click the toggle button to pause all animations");
+  let onRefresh = widget.player.once(widget.player.AUTO_REFRESH_EVENT);
+  yield panel.toggleAll();
+  yield onRefresh;
+
+  info("Checking the selected node's animation player widget's state");
+  is(widget.player.state.playState, "paused", "The player front's state is paused");
+  ok(widget.el.classList.contains("paused"), "The widget's UI is in paused state");
+
+  info("Click the toggle button to play all animations");
+  onRefresh = widget.player.once(widget.player.AUTO_REFRESH_EVENT);
+  yield panel.toggleAll();
+  yield onRefresh;
+
+  info("Checking the selected node's animation player widget's state again");
+  is(widget.player.state.playState, "running", "The player front's state is running");
+  ok(widget.el.classList.contains("running"), "The widget's UI is in running state");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_toolbar_exists.js
@@ -0,0 +1,26 @@
+/* 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";
+
+// Test that the animation panel has a top toolbar that contains the play/pause
+// button and that is displayed at all times.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel, window} = yield openAnimationInspector();
+  let doc = window.document;
+
+  let toolbar = doc.querySelector("#toolbar");
+  ok(toolbar, "The panel contains the toolbar element");
+  ok(toolbar.querySelector("#toggle-all"), "The toolbar contains the toggle button");
+  ok(isNodeVisible(toolbar), "The toolbar is visible");
+
+  info("Select an animated node");
+  yield selectNode(".animated", inspector);
+
+  toolbar = doc.querySelector("#toolbar");
+  ok(toolbar, "The panel still contains the toolbar element");
+  ok(isNodeVisible(toolbar), "The toolbar is still visible");
+});
--- a/browser/devtools/animationinspector/test/doc_frame_script.js
+++ b/browser/devtools/animationinspector/test/doc_frame_script.js
@@ -39,8 +39,25 @@ addMessageListener("Test:ToggleAnimation
 addMessageListener("Test:SetNodeStyle", function(msg) {
   let {propertyName, propertyValue} = msg.data;
   let {node} = msg.objects;
 
   node.style[propertyName] = propertyValue;
 
   sendAsyncMessage("Test:SetNodeStyle");
 });
+
+/**
+ * Get the current playState of an animation player on a given node.
+ * @param {Object} data
+ * - {Number} animationIndex The index of the node's animationPlayers to check
+ * @param {Object} objects
+ * - {DOMNode} node The node to check
+ */
+addMessageListener("Test:GetAnimationPlayerState", function(msg) {
+  let {animationIndex} = msg.data;
+  let {node} = msg.objects;
+
+  let player = node.getAnimationPlayers()[animationIndex];
+  player.ready.then(() => {
+    sendAsyncMessage("Test:GetAnimationPlayerState", player.playState);
+  });
+});
--- a/browser/devtools/animationinspector/test/head.js
+++ b/browser/devtools/animationinspector/test/head.js
@@ -13,16 +13,17 @@ const {console} = Components.utils.impor
 const {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
 
 // All tests are asynchronous
 waitForExplicitFinish();
 
 const TEST_URL_ROOT = "http://example.com/browser/browser/devtools/animationinspector/test/";
 const ROOT_TEST_DIR = getRootDirectory(gTestPath);
 const FRAME_SCRIPT_URL = ROOT_TEST_DIR + "doc_frame_script.js";
+const COMMON_FRAME_SCRIPT_URL = "chrome://browser/content/devtools/frame-script-utils.js";
 
 // Auto clean-up when a test ends
 registerCleanupFunction(function*() {
   let target = TargetFactory.forTab(gBrowser.selectedTab);
   yield gDevTools.closeToolbox(target);
 
   while (gBrowser.tabs.length > 1) {
     gBrowser.removeCurrentTab();
@@ -58,27 +59,37 @@ function addTab(url) {
   window.focus();
 
   let tab = window.gBrowser.selectedTab = window.gBrowser.addTab(url);
   let browser = tab.linkedBrowser;
 
   info("Loading the helper frame script " + FRAME_SCRIPT_URL);
   browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false);
 
+  info("Loading the helper frame script " + COMMON_FRAME_SCRIPT_URL);
+  browser.messageManager.loadFrameScript(COMMON_FRAME_SCRIPT_URL, false);
+
   browser.addEventListener("load", function onload() {
     browser.removeEventListener("load", onload, true);
     info("URL '" + url + "' loading complete");
 
     def.resolve(tab);
   }, true);
 
   return def.promise;
 }
 
 /**
+ * Reload the current tab location.
+ */
+function reloadTab() {
+  return executeInContent("devtools:test:reload", {}, {}, false);
+}
+
+/**
  * Simple DOM node accesor function that takes either a node or a string css
  * selector as argument and returns the corresponding node
  * @param {String|DOMNode} nodeOrSelector
  * @return {DOMNode|CPOW} Note that in e10s mode a CPOW object is returned which
  * doesn't implement *all* of the DOMNode's properties
  */
 function getNode(nodeOrSelector) {
   info("Getting the node for '" + nodeOrSelector + "'");
@@ -143,20 +154,17 @@ let openAnimationInspector = Task.async(
   inspector.sidebar.select("animationinspector");
 
   info("Waiting for the inspector and sidebar to be ready");
   yield promise.all(initPromises);
 
   let win = inspector.sidebar.getWindowForTab("animationinspector");
   let {AnimationsController, AnimationsPanel} = win;
 
-  yield promise.all([
-    AnimationsController.initialized,
-    AnimationsPanel.initialized
-  ]);
+  yield AnimationsPanel.once(AnimationsPanel.PANEL_INITIALIZED);
 
   return {
     toolbox: toolbox,
     inspector: inspector,
     controller: AnimationsController,
     panel: AnimationsPanel,
     window: win
   };
@@ -279,15 +287,25 @@ let togglePlayPauseButton = Task.async(f
 
   yield onClicked;
 
   // Wait for the next sate change event to make sure the state is updated
   yield widget.player.once(widget.player.AUTO_REFRESH_EVENT);
 });
 
 /**
+ * Get the current playState of an animation player on a given node.
+ */
+let getAnimationPlayerState = Task.async(function*(selector, animationIndex=0) {
+  let playState = yield executeInContent("Test:GetAnimationPlayerState",
+                                         {animationIndex},
+                                         {node: getNode(selector)});
+  return playState;
+});
+
+/**
  * Is the given node visible in the page (rendered in the frame tree).
  * @param {DOMNode}
  * @return {Boolean}
  */
 function isNodeVisible(node) {
   return !!node.getClientRects().length;
 }
--- a/browser/locales/en-US/chrome/browser/devtools/animationinspector.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/animationinspector.dtd
@@ -1,24 +1,29 @@
 <!-- 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/. -->
+     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/. -->
 
 <!-- LOCALIZATION NOTE : FILE This file contains the Animations panel strings.
-  - The Animations panel is part of the Inspector sidebar -->
+     The Animations panel is part of the Inspector sidebar -->
 
 <!-- LOCALIZATION NOTE : FILE The correct localization of this file might be to
-  - keep it in English, or another language commonly spoken among web developers.
-  - You want to make that choice consistent across the developer tools.
-  - A good criteria is the language in which you'd find the best
-  - documentation on web development on the web. -->
+     keep it in English, or another language commonly spoken among web
+     developers. You want to make that choice consistent across the developer
+     tools. A good criteria is the language in which you'd find the best
+     documentation on web development on the web. -->
 
 <!-- LOCALIZATION NOTE (title): This is the label shown in the sidebar tab -->
-<!ENTITY title                  "Animations">
+<!ENTITY title "Animations">
 
 <!-- LOCALIZATION NOTE (invalidElement): This is the label shown in the panel
-  - when an invalid node is currently selected in the inspector. -->
-<!ENTITY invalidElement         "No animations were found for the current element.">
+     when an invalid node is currently selected in the inspector. -->
+<!ENTITY invalidElement "No animations were found for the current element.">
 
 <!-- LOCALIZATION NOTE (selectElement): This is the label shown in the panel
-  - when an invalid node is currently selected in the inspector, to invite the
-  - user to select a new node by clicking on the element-picker icon. -->
-<!ENTITY selectElement         "Pick another element from the page.">
\ No newline at end of file
+     when an invalid node is currently selected in the inspector, to invite the
+     user to select a new node by clicking on the element-picker icon. -->
+<!ENTITY selectElement "Pick another element from the page.">
+
+<!-- LOCALIZATION NOTE (allAnimations): This is the label shown at the bottom of
+     the panel, in a toolbar, to let the user know the toolbar applies to all
+     animations, not just the ones applying to the current element. -->
+<!ENTITY allAnimations "All animations">
--- a/browser/themes/shared/devtools/animationinspector.css
+++ b/browser/themes/shared/devtools/animationinspector.css
@@ -1,65 +1,121 @@
+html {
+  height: 100%;
+}
+
 body {
   margin: 0;
   padding: 0;
+  display : flex;
+  flex-direction: column;
+  height: 100%;
+  overflow: hidden;
+  color: var(--theme-content-color3);
+}
+
+/* The top toolbar, containing the toggle-all button */
+
+#toolbar {
+  border-bottom: 1px solid var(--theme-splitter-color);
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
+  height: 20px;
+}
+
+#toolbar .label {
+  padding: 1px 4px;
+}
+
+#toggle-all {
+  border-width: 0px 1px;
+  min-height: 20px;
 }
 
 /* The error message, shown when an invalid/unanimated element is selected */
 
 #error-message {
-  margin-top: 10%;
+  padding-top: 10%;
   text-align: center;
+  flex: 1;
+  overflow: auto;
 
   /* The error message is hidden by default */
   display: none;
 }
 
-/* Element picker button */
+/* The animation players container */
 
-#element-picker {
+#players {
+  flex: 1;
+  overflow: auto;
+}
+
+/* Element picker and toggle-all buttons */
+
+#element-picker,
+#toggle-all {
   position: relative;
 }
 
-#element-picker::before {
+#element-picker::before,
+#toggle-all::before {
   content: "";
   display: block;
   width: 16px;
   height: 16px;
   position: absolute;
   left: 50%;
   top: 50%;
   margin: -8px 0 0 -8px;
   background-image: url("chrome://browser/skin/devtools/command-pick.png");
 }
 
+#toggle-all::before {
+  background-image: url("debugger-pause.png");
+}
+
 #element-picker[checked]::before {
   background-position: -48px 0;
   filter: none; /* Icon is blue when checked, don't invert for light theme */
 }
 
+#toggle-all.paused::before {
+  background-image: url("debugger-play.png");
+}
+
 @media (min-resolution: 2dppx) {
-  #element-picker::before {
+  #element-picker::before,
+  #toggle-all::before {
     background-image: url("chrome://browser/skin/devtools/command-pick@2x.png");
     background-size: 64px;
   }
+
+  #toggle-all::before {
+    background-image: url("debugger-pause@2x.png");
+  }
+
+  #toggle-all.paused::before {
+    background-image: url("debugger-play@2x.png");
+  }
 }
 
 /* Disabled playerWidget when the animation has ended */
 
 .finished {
   pointer-events: none;
   opacity: .5;
 }
 
 /* Animation title gutter, contains the name, duration, iteration */
 
 .animation-title {
   background-color: var(--theme-toolbar-background);
-  color: var(--theme-content-color3);
   border-bottom: 1px solid var(--theme-splitter-color);
   padding: 1px 4px;
   word-wrap: break-word;
   overflow: auto;
 }
 
 .animation-title .meta-data {
   float: right;