Bug 1211801 - Add a playback rate selector to the animation panel. r=miker

This commit is contained in:
Patrick Brosset 2015-11-02 12:54:07 +01:00
parent 6898bfcc30
commit d29ab74560
11 changed files with 370 additions and 30 deletions

View File

@ -31,6 +31,11 @@ player.animationDurationLabel=Duration:
# displayed before the animation delay.
player.animationDelayLabel=Delay:
# LOCALIZATION NOTE (player.animationRateLabel):
# This string is displayed in each animation player widget. It is the label
# displayed before the animation playback rate.
player.animationRateLabel=Playback rate:
# LOCALIZATION NOTE (player.animationIterationCountLabel):
# This string is displayed in each animation player widget. It is the label
# displayed before the number of times the animation is set to repeat.

View File

@ -86,6 +86,8 @@ var getServerTraits = Task.async(function*(target) {
method: "stopAnimationPlayerUpdates" },
{ name: "hasSetPlaybackRate", actor: "animationplayer",
method: "setPlaybackRate" },
{ name: "hasSetPlaybackRates", actor: "animations",
method: "setPlaybackRates" },
{ name: "hasTargetNode", actor: "domwalker",
method: "getNodeFromActor" },
{ name: "hasSetCurrentTimes", actor: "animations",
@ -284,6 +286,23 @@ var AnimationsController = {
}
}),
/**
* Set all known animations' playback rates to the provided rate.
* @param {Number} rate.
* @return {Promise} Resolves when the rate has been set.
*/
setPlaybackRateAll: Task.async(function*(rate) {
if (this.traits.hasSetPlaybackRates) {
// If the backend can set all playback rates at the same time, use that.
yield this.animationsFront.setPlaybackRates(this.animationPlayers, rate);
} else if (this.traits.hasSetPlaybackRate) {
// Otherwise, fall back to setting each rate individually.
for (let animation of this.animationPlayers) {
yield animation.setPlaybackRate(rate);
}
}
}),
// AnimationPlayerFront objects are managed by this controller. They are
// retrieved when refreshAnimationPlayers is called, stored in the
// animationPlayers array, and destroyed when refreshAnimationPlayers is

View File

@ -22,6 +22,7 @@
<div id="timeline-toolbar" class="theme-toolbar">
<button id="rewind-timeline" standalone="true" class="devtools-button"></button>
<button id="pause-resume-timeline" standalone="true" class="devtools-button pause-button paused"></button>
<span id="timeline-rate"></span>
<span id="timeline-current-time" class="label"></span>
</div>
<div id="players"></div>

View File

@ -3,12 +3,14 @@
/* 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/. */
/* globals AnimationsController, document, performance, promise,
gToolbox, gInspector, requestAnimationFrame, cancelAnimationFrame, L10N */
/* globals AnimationsController, document, promise, gToolbox, gInspector */
"use strict";
const {AnimationsTimeline} = require("devtools/client/animationinspector/components");
const {
AnimationsTimeline,
RateSelector
} = require("devtools/client/animationinspector/components");
const {formatStopwatchTime} = require("devtools/client/animationinspector/utils");
var $ = (selector, target = document) => target.querySelector(selector);
@ -39,6 +41,7 @@ var AnimationsPanel = {
this.playTimelineButtonEl = $("#pause-resume-timeline");
this.rewindTimelineButtonEl = $("#rewind-timeline");
this.timelineCurrentTimeEl = $("#timeline-current-time");
this.rateSelectorEl = $("#timeline-rate");
// If the server doesn't support toggling all animations at once, hide the
// whole global toolbar.
@ -49,7 +52,8 @@ var AnimationsPanel = {
// Binding functions that need to be called in scope.
for (let functionName of ["onPickerStarted", "onPickerStopped",
"refreshAnimationsUI", "toggleAll", "onTabNavigated",
"onTimelineDataChanged", "playPauseTimeline", "rewindTimeline"]) {
"onTimelineDataChanged", "playPauseTimeline", "rewindTimeline",
"onRateChanged"]) {
this[functionName] = this[functionName].bind(this);
}
let hUtils = gToolbox.highlighterUtils;
@ -58,12 +62,16 @@ var AnimationsPanel = {
this.animationsTimelineComponent = new AnimationsTimeline(gInspector);
this.animationsTimelineComponent.init(this.playersEl);
if (AnimationsController.traits.hasSetPlaybackRate) {
this.rateSelectorComponent = new RateSelector();
this.rateSelectorComponent.init(this.rateSelectorEl);
}
this.startListeners();
yield this.refreshAnimationsUI();
this.initialized.resolve();
this.emit(this.PANEL_INITIALIZED);
}),
@ -83,10 +91,15 @@ var AnimationsPanel = {
this.animationsTimelineComponent.destroy();
this.animationsTimelineComponent = null;
if (this.rateSelectorComponent) {
this.rateSelectorComponent.destroy();
this.rateSelectorComponent = null;
}
this.playersEl = this.errorMessageEl = null;
this.toggleAllButtonEl = this.pickerButtonEl = null;
this.playTimelineButtonEl = this.rewindTimelineButtonEl = null;
this.timelineCurrentTimeEl = null;
this.timelineCurrentTimeEl = this.rateSelectorEl = null;
this.destroyed.resolve();
}),
@ -107,6 +120,10 @@ var AnimationsPanel = {
this.animationsTimelineComponent.on("timeline-data-changed",
this.onTimelineDataChanged);
if (this.rateSelectorComponent) {
this.rateSelectorComponent.on("rate-changed", this.onRateChanged);
}
},
stopListeners: function() {
@ -125,6 +142,10 @@ var AnimationsPanel = {
this.animationsTimelineComponent.off("timeline-data-changed",
this.onTimelineDataChanged);
if (this.rateSelectorComponent) {
this.rateSelectorComponent.off("rate-changed", this.onRateChanged);
}
},
togglePlayers: function(isVisible) {
@ -173,13 +194,23 @@ var AnimationsPanel = {
.catch(e => console.error(e));
},
/**
* Set the playback rate of all current animations shown in the timeline to
* the value of this.rateSelectorEl.
*/
onRateChanged: function(e, rate) {
AnimationsController.setPlaybackRateAll(rate)
.then(() => this.refreshAnimationsStateAndUI())
.catch(e => console.error(e));
},
onTabNavigated: function() {
this.toggleAllButtonEl.classList.remove("paused");
},
onTimelineDataChanged: function(e, data) {
this.timelineData = data;
let {isMoving, isPaused, isUserDrag, time} = data;
let {isMoving, isUserDrag, time} = data;
this.playTimelineButtonEl.classList.toggle("paused", !isMoving);
@ -232,6 +263,11 @@ var AnimationsPanel = {
AnimationsController.animationPlayers,
AnimationsController.documentCurrentTime);
// Re-render the rate selector component.
if (this.rateSelectorComponent) {
this.rateSelectorComponent.render(AnimationsController.animationPlayers);
}
// If there are no players to show, show the error message instead and
// return.
if (!AnimationsController.animationPlayers.length) {

View File

@ -34,6 +34,8 @@ const L10N = new ViewHelpers.L10N(STRINGS_URI);
const MILLIS_TIME_FORMAT_MAX_DURATION = 4000;
// The minimum spacing between 2 time graduation headers in the timeline (px).
const TIME_GRADUATION_MIN_SPACING = 40;
// List of playback rate presets displayed in the timeline toolbar.
const PLAYBACK_RATES = [.1, .25, .5, 1, 2, 5, 10];
// The size of the fast-track icon (for compositor-running animations), this is
// used to position the icon correctly.
const FAST_TRACK_ICON_SIZE = 20;
@ -187,6 +189,10 @@ AnimationTargetNode.prototype = {
this.previewEl.appendChild(document.createTextNode(">"));
}
this.startListeners();
},
startListeners: function() {
// Init events for highlighting and selecting the node.
this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver);
this.previewEl.addEventListener("mouseout", this.onPreviewMouseOut);
@ -200,15 +206,19 @@ AnimationTargetNode.prototype = {
TargetNodeHighlighter.on("highlighted", this.onTargetHighlighterLocked);
},
destroy: function() {
TargetNodeHighlighter.unhighlight().catch(e => console.error(e));
stopListeners: function() {
TargetNodeHighlighter.off("highlighted", this.onTargetHighlighterLocked);
this.inspector.off("markupmutation", this.onMarkupMutations);
this.previewEl.removeEventListener("mouseover", this.onPreviewMouseOver);
this.previewEl.removeEventListener("mouseout", this.onPreviewMouseOut);
this.previewEl.removeEventListener("click", this.onSelectNodeClick);
this.highlightNodeEl.removeEventListener("click", this.onHighlightNodeClick);
},
destroy: function() {
TargetNodeHighlighter.unhighlight().catch(e => console.error(e));
this.stopListeners();
this.el.remove();
this.el = this.tagNameEl = this.idEl = this.classEl = null;
@ -217,21 +227,26 @@ AnimationTargetNode.prototype = {
},
get highlighterUtils() {
return this.inspector.toolbox.highlighterUtils;
if (this.inspector && this.inspector.toolbox) {
return this.inspector.toolbox.highlighterUtils;
}
return null;
},
onPreviewMouseOver: function() {
if (!this.nodeFront) {
if (!this.nodeFront || !this.highlighterUtils) {
return;
}
this.highlighterUtils.highlightNodeFront(this.nodeFront);
this.highlighterUtils.highlightNodeFront(this.nodeFront)
.catch(e => console.error(e));
},
onPreviewMouseOut: function() {
if (!this.nodeFront) {
if (!this.nodeFront || !this.highlighterUtils) {
return;
}
this.highlighterUtils.unhighlight();
this.highlighterUtils.unhighlight()
.catch(e => console.error(e));
},
onSelectNodeClick: function() {
@ -331,6 +346,90 @@ AnimationTargetNode.prototype = {
})
};
/**
* UI component responsible for displaying a playback rate selector UI.
* The rendering logic is such that a predefined list of rates is generated.
* If *all* animations passed to render share the same rate, then that rate is
* selected in the <select> element, otherwise, the empty value is selected.
* If the rate that all animations share isn't part of the list of predefined
* rates, than that rate is added to the list.
*/
function RateSelector() {
this.onRateChanged = this.onRateChanged.bind(this);
EventEmitter.decorate(this);
}
exports.RateSelector = RateSelector;
RateSelector.prototype = {
init: function(containerEl) {
this.selectEl = createNode({
parent: containerEl,
nodeType: "select",
attributes: {"class": "devtools-button"}
});
this.selectEl.addEventListener("change", this.onRateChanged);
},
destroy: function() {
this.selectEl.removeEventListener("change", this.onRateChanged);
this.selectEl.remove();
this.selectEl = null;
},
getAnimationsRates: function(animations) {
return sortedUnique(animations.map(a => a.state.playbackRate));
},
getAllRates: function(animations) {
let animationsRates = this.getAnimationsRates(animations);
if (animationsRates.length > 1) {
return PLAYBACK_RATES;
}
return sortedUnique(PLAYBACK_RATES.concat(animationsRates));
},
render: function(animations) {
let allRates = this.getAnimationsRates(animations);
let hasOneRate = allRates.length === 1;
this.selectEl.innerHTML = "";
if (!hasOneRate) {
// When the animations displayed have mixed playback rates, we can't
// select any of the predefined ones, instead, insert an empty rate.
createNode({
parent: this.selectEl,
nodeType: "option",
attributes: {value: "", selector: "true"},
textContent: "-"
});
}
for (let rate of this.getAllRates(animations)) {
let option = createNode({
parent: this.selectEl,
nodeType: "option",
attributes: {value: rate},
textContent: L10N.getFormatStr("player.playbackRateLabel", rate)
});
// If there's only one rate and this is the option for it, select it.
if (hasOneRate && rate === allRates[0]) {
option.setAttribute("selected", "true");
}
}
},
onRateChanged: function() {
let rate = parseFloat(this.selectEl.value);
if (!isNaN(rate)) {
this.emit("rate-changed", rate);
}
}
};
/**
* The TimeScale helper object is used to know which size should something be
* displayed with in the animation panel, depending on the animations that are
@ -858,21 +957,49 @@ AnimationTimeBlock.prototype = {
getTooltipText: function(state) {
let getTime = time => L10N.getFormatStr("player.timeLabel",
L10N.numberWithDecimals(time / 1000, 2));
// The type isn't always available, older servers don't send it.
let title =
let text = "";
// Adding the name (the type isn't always available, older servers don't
// send it).
text +=
state.type
? L10N.getFormatStr("timeline." + state.type + ".nameLabel", state.name)
: state.name;
let delay = L10N.getStr("player.animationDelayLabel") + " " +
getTime(state.delay);
let duration = L10N.getStr("player.animationDurationLabel") + " " +
getTime(state.duration);
let iterations = L10N.getStr("player.animationIterationCountLabel") + " " +
(state.iterationCount ||
L10N.getStr("player.infiniteIterationCountText"));
let compositor = state.isRunningOnCompositor
? L10N.getStr("player.runningOnCompositorTooltip")
: "";
return [title, duration, iterations, delay, compositor].join("\n");
text += "\n";
// Adding the delay.
text += L10N.getStr("player.animationDelayLabel") + " ";
text += getTime(state.delay);
text += "\n";
// Adding the duration.
text += L10N.getStr("player.animationDurationLabel") + " ";
text += getTime(state.duration);
text += "\n";
// Adding the iteration count (the infinite symbol, or an integer).
// XXX: see bug 1219608 to remove this if the count is 1.
text += L10N.getStr("player.animationIterationCountLabel") + " ";
text += state.iterationCount ||
L10N.getStr("player.infiniteIterationCountText");
text += "\n";
// Adding the playback rate if it's different than 1.
if (state.playbackRate !== 1) {
text += L10N.getStr("player.animationRateLabel") + " ";
text += state.playbackRate;
text += "\n";
}
// Adding a note that the animation is running on the compositor thread if
// needed.
if (state.isRunningOnCompositor) {
text += L10N.getStr("player.runningOnCompositorTooltip");
}
return text;
}
};
let sortedUnique = arr => [...new Set(arr)].sort((a, b) => a > b);

View File

@ -28,6 +28,7 @@ support-files =
[browser_animation_timeline_currentTime.js]
[browser_animation_timeline_header.js]
[browser_animation_timeline_pause_button.js]
[browser_animation_timeline_rate_selector.js]
[browser_animation_timeline_rewind_button.js]
[browser_animation_timeline_scrubber_exists.js]
[browser_animation_timeline_scrubber_movable.js]

View File

@ -0,0 +1,54 @@
/* 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 playback rate selector UI and that
// it can be used to change the playback rate of animations in the timeline.
// Also check that it displays the rate of the current animations in case they
// all have the same rate, or that it displays the empty value in case they
// have mixed rates.
add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {panel, controller, inspector, toolbox} = yield openAnimationInspector();
// In this test, we disable the highlighter on purpose because of the way
// events are simulated to select an option in the playbackRate <select>.
// Indeed, this may cause mousemove events to be triggered on the nodes that
// are underneath the <select>, and these are AnimationTargetNode instances.
// Simulating mouse events on them will cause the highlighter to emit requests
// and this might cause the test to fail if they happen after it has ended.
disableHighlighter(toolbox);
let select = panel.rateSelectorEl.firstChild;
ok(select, "The rate selector exists");
info("Change all of the current animations' rates to 0.5");
yield changeTimelinePlaybackRate(panel, .5);
checkAllAnimationsRatesChanged(controller, select, .5);
info("Select just one animated node and change its rate only");
yield selectNode(".animated", inspector);
yield changeTimelinePlaybackRate(panel, 2);
checkAllAnimationsRatesChanged(controller, select, 2);
info("Select the <body> again, it should now have mixed-rates animations");
yield selectNode("body", inspector);
is(select.value, "", "The selected rate is empty");
info("Change the rate for these mixed-rate animations");
yield changeTimelinePlaybackRate(panel, 1);
checkAllAnimationsRatesChanged(controller, select, 1);
});
function checkAllAnimationsRatesChanged({animationPlayers}, select, rate) {
ok(animationPlayers.every(({state}) => state.playbackRate === rate),
"All animations' rates have been set to " + rate);
is(select.value, rate, "The right value is displayed in the select");
}

View File

@ -480,6 +480,11 @@ function* assertScrubberMoving(panel, isMoving) {
}
}
/**
* Click the play/pause button in the timeline toolbar and wait for animations
* to update.
* @param {AnimationsPanel} panel
*/
function* clickTimelinePlayPauseButton(panel) {
let onUiUpdated = panel.once(panel.UI_UPDATED_EVENT);
@ -491,6 +496,11 @@ function* clickTimelinePlayPauseButton(panel) {
yield waitForAllAnimationTargets(panel);
}
/**
* Click the rewind button in the timeline toolbar and wait for animations to
* update.
* @param {AnimationsPanel} panel
*/
function* clickTimelineRewindButton(panel) {
let onUiUpdated = panel.once(panel.UI_UPDATED_EVENT);
@ -501,3 +511,52 @@ function* clickTimelineRewindButton(panel) {
yield onUiUpdated;
yield waitForAllAnimationTargets(panel);
}
/**
* Select a rate inside the playback rate selector in the timeline toolbar and
* wait for animations to update.
* @param {AnimationsPanel} panel
* @param {Number} rate The new rate value to be selected
*/
function* changeTimelinePlaybackRate(panel, rate) {
let onUiUpdated = panel.once(panel.UI_UPDATED_EVENT);
let select = panel.rateSelectorEl.firstChild;
let win = select.ownerDocument.defaultView;
// Get the right option.
let option = [...select.options].filter(o => o.value === rate + "")[0];
if (!option) {
ok(false,
"Could not find an option for rate " + rate + " in the rate selector. " +
"Values are: " + [...select.options].map(o => o.value));
return;
}
// Simulate the right events to select the option in the drop-down.
EventUtils.synthesizeMouseAtCenter(select, {type: "mousedown"}, win);
EventUtils.synthesizeMouseAtCenter(option, {type: "mouseup"}, win);
yield onUiUpdated;
yield waitForAllAnimationTargets(panel);
// Simulate a mousemove outside of the rate selector area to avoid subsequent
// tests from failing because of unwanted mouseover events.
EventUtils.synthesizeMouseAtCenter(win.document.querySelector("#timeline-toolbar"),
{type: "mousemove"}, win);
}
/**
* Prevent the toolbox common highlighter from making backend requests.
* @param {Toolbox} toolbox
*/
function disableHighlighter(toolbox) {
toolbox._highlighter = {
showBoxModel: () => new Promise(r => r()),
hideBoxModel: () => new Promise(r => r()),
pick: () => new Promise(r => r()),
cancelPick: () => new Promise(r => r()),
destroy: () => {},
traits: {}
};
}

View File

@ -161,6 +161,13 @@ body {
}
}
#timeline-rate select {
-moz-appearance: none;
text-align: center;
color: inherit;
font-family: inherit;
}
/* Animation timeline component */
.animation-timeline {

View File

@ -410,7 +410,7 @@ var AnimationPlayerActor = ActorClass({
* Set the current time of the animation player.
*/
setCurrentTime: method(function(currentTime) {
this.player.currentTime = currentTime;
this.player.currentTime = currentTime * this.player.playbackRate;
}, {
request: {
currentTime: Arg(0, "number")
@ -849,6 +849,23 @@ var AnimationsActor = exports.AnimationsActor = ActorClass({
shouldPause: Arg(2, "boolean")
},
response: {}
}),
/**
* Set the playback rate of several animations at the same time.
* @param {Array} players A list of AnimationPlayerActor.
* @param {Number} rate The new rate.
*/
setPlaybackRates: method(function(players, rate) {
for (let player of players) {
player.setPlaybackRate(rate);
}
}, {
request: {
players: Arg(0, "array:animationplayer"),
rate: Arg(1, "number")
},
response: {}
})
});

View File

@ -4,7 +4,8 @@
"use strict";
// Check that a player's playbackRate can be changed.
// Check that a player's playbackRate can be changed, and that multiple players
// can have their rates changed at the same time.
add_task(function*() {
let {client, walker, animations} =
@ -32,6 +33,19 @@ add_task(function*() {
state = yield player.getCurrentState();
is(state.playbackRate, 1, "The playbackRate was changed back");
info("Retrieve several animation players and set their rates");
node = yield walker.querySelector(walker.rootNode, "body");
let players = yield animations.getAnimationPlayersForNode(node);
info("Change all animations in <body> to .5 rate");
yield animations.setPlaybackRates(players, .5);
info("Query their states and check they are correct");
for (let player of players) {
let state = yield player.getCurrentState();
is(state.playbackRate, .5, "The playbackRate was updated");
}
yield closeDebuggerClient(client);
gBrowser.removeCurrentTab();
});