This is the first step towards the animation-inspector UI v3 (bug 1153271). The new UI is still hidden behind a pref, and this change doesn't implement everything that is in the current v2 UI. This introduces a new Timeline graph to represent all currently animated nodes below the currently selected node. v2 used to show them as independent player widgets. With this patch, we now show them as synchronized time blocks on a common time scale. Each animation has a preview of the animated node in the left sidebar, and a time block on the right, the width of which represents its duration. The animation name is also displayed. There's also a time graduations header and background that gives the user information about how long do the animations last. This change does *not* provide a way to know what's the currentTime nor a way to set it yet. This also makes the existing animationinspector tests that still make sense with the new timeline-based UI run with the new UI pref on.
619 lines
18 KiB
JavaScript
619 lines
18 KiB
JavaScript
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
|
|
/* 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 */
|
|
|
|
"use strict";
|
|
|
|
const {createNode} = require("devtools/animationinspector/utils");
|
|
const {
|
|
PlayerMetaDataHeader,
|
|
PlaybackRateSelector,
|
|
AnimationTargetNode,
|
|
AnimationsTimeline
|
|
} = require("devtools/animationinspector/components");
|
|
|
|
/**
|
|
* The main animations panel UI.
|
|
*/
|
|
let AnimationsPanel = {
|
|
UI_UPDATED_EVENT: "ui-updated",
|
|
PANEL_INITIALIZED: "panel-initialized",
|
|
|
|
initialize: Task.async(function*() {
|
|
if (AnimationsController.destroyed) {
|
|
console.warn("Could not initialize the animation-panel, controller " +
|
|
"was destroyed");
|
|
return;
|
|
}
|
|
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.refreshAnimations = this.refreshAnimations.bind(this);
|
|
this.toggleAll = this.toggleAll.bind(this);
|
|
this.onTabNavigated = this.onTabNavigated.bind(this);
|
|
|
|
this.startListeners();
|
|
|
|
if (AnimationsController.isNewUI) {
|
|
this.animationsTimelineComponent = new AnimationsTimeline(gInspector);
|
|
this.animationsTimelineComponent.init(this.playersEl);
|
|
}
|
|
|
|
yield this.refreshAnimations();
|
|
|
|
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();
|
|
|
|
if (this.animationsTimelineComponent) {
|
|
this.animationsTimelineComponent.destroy();
|
|
this.animationsTimelineComponent = null;
|
|
}
|
|
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.refreshAnimations);
|
|
|
|
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.refreshAnimations);
|
|
|
|
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;
|
|
|
|
if (!AnimationsController.isNewUI) {
|
|
// 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");
|
|
},
|
|
|
|
refreshAnimations: Task.async(function*() {
|
|
let done = gInspector.updating("animationspanel");
|
|
|
|
// Empty the whole panel first.
|
|
this.hideErrorMessage();
|
|
yield this.destroyPlayerWidgets();
|
|
|
|
// Re-render the timeline component.
|
|
if (this.animationsTimelineComponent) {
|
|
this.animationsTimelineComponent.render(
|
|
AnimationsController.animationPlayers);
|
|
}
|
|
|
|
// If there are no players to show, show the error message instead and
|
|
// return.
|
|
if (!AnimationsController.animationPlayers.length) {
|
|
this.displayErrorMessage();
|
|
this.emit(this.UI_UPDATED_EVENT);
|
|
done();
|
|
return;
|
|
}
|
|
|
|
// Otherwise, create player widgets (only when isNewUI is false, the
|
|
// timeline has already been re-rendered).
|
|
if (!AnimationsController.isNewUI) {
|
|
this.playerWidgets = [];
|
|
let initPromises = [];
|
|
|
|
for (let player of AnimationsController.animationPlayers) {
|
|
let widget = new PlayerWidget(player, this.playersEl);
|
|
initPromises.push(widget.initialize());
|
|
this.playerWidgets.push(widget);
|
|
}
|
|
|
|
yield initPromises;
|
|
}
|
|
|
|
this.emit(this.UI_UPDATED_EVENT);
|
|
done();
|
|
}),
|
|
|
|
destroyPlayerWidgets: Task.async(function*() {
|
|
if (!this.playerWidgets) {
|
|
return;
|
|
}
|
|
|
|
let destroyers = this.playerWidgets.map(widget => widget.destroy());
|
|
yield promise.all(destroyers);
|
|
this.playerWidgets = null;
|
|
this.playersEl.innerHTML = "";
|
|
})
|
|
};
|
|
|
|
EventEmitter.decorate(AnimationsPanel);
|
|
|
|
/**
|
|
* An AnimationPlayer UI widget
|
|
*/
|
|
function PlayerWidget(player, containerEl) {
|
|
EventEmitter.decorate(this);
|
|
|
|
this.player = player;
|
|
this.containerEl = containerEl;
|
|
|
|
this.onStateChanged = this.onStateChanged.bind(this);
|
|
this.onPlayPauseBtnClick = this.onPlayPauseBtnClick.bind(this);
|
|
this.onRewindBtnClick = this.onRewindBtnClick.bind(this);
|
|
this.onFastForwardBtnClick = this.onFastForwardBtnClick.bind(this);
|
|
this.onCurrentTimeChanged = this.onCurrentTimeChanged.bind(this);
|
|
this.onPlaybackRateChanged = this.onPlaybackRateChanged.bind(this);
|
|
|
|
this.metaDataComponent = new PlayerMetaDataHeader();
|
|
if (AnimationsController.hasSetPlaybackRate) {
|
|
this.rateComponent = new PlaybackRateSelector();
|
|
}
|
|
if (AnimationsController.hasTargetNode) {
|
|
this.targetNodeComponent = new AnimationTargetNode(gInspector);
|
|
}
|
|
}
|
|
|
|
PlayerWidget.prototype = {
|
|
initialize: Task.async(function*() {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
this.initialized = true;
|
|
|
|
this.createMarkup();
|
|
this.startListeners();
|
|
}),
|
|
|
|
destroy: Task.async(function*() {
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
this.destroyed = true;
|
|
|
|
this.stopTimelineAnimation();
|
|
this.stopListeners();
|
|
this.metaDataComponent.destroy();
|
|
if (this.rateComponent) {
|
|
this.rateComponent.destroy();
|
|
}
|
|
if (this.targetNodeComponent) {
|
|
this.targetNodeComponent.destroy();
|
|
}
|
|
|
|
this.el.remove();
|
|
this.playPauseBtnEl = this.rewindBtnEl = this.fastForwardBtnEl = null;
|
|
this.currentTimeEl = this.timeDisplayEl = null;
|
|
this.containerEl = this.el = this.player = null;
|
|
}),
|
|
|
|
startListeners: function() {
|
|
this.player.on(this.player.AUTO_REFRESH_EVENT, this.onStateChanged);
|
|
this.playPauseBtnEl.addEventListener("click", this.onPlayPauseBtnClick);
|
|
if (AnimationsController.hasSetCurrentTime) {
|
|
this.rewindBtnEl.addEventListener("click", this.onRewindBtnClick);
|
|
this.fastForwardBtnEl.addEventListener("click", this.onFastForwardBtnClick);
|
|
this.currentTimeEl.addEventListener("input", this.onCurrentTimeChanged);
|
|
}
|
|
if (this.rateComponent) {
|
|
this.rateComponent.on("rate-changed", this.onPlaybackRateChanged);
|
|
}
|
|
},
|
|
|
|
stopListeners: function() {
|
|
this.player.off(this.player.AUTO_REFRESH_EVENT, this.onStateChanged);
|
|
this.playPauseBtnEl.removeEventListener("click", this.onPlayPauseBtnClick);
|
|
if (AnimationsController.hasSetCurrentTime) {
|
|
this.rewindBtnEl.removeEventListener("click", this.onRewindBtnClick);
|
|
this.fastForwardBtnEl.removeEventListener("click", this.onFastForwardBtnClick);
|
|
this.currentTimeEl.removeEventListener("input", this.onCurrentTimeChanged);
|
|
}
|
|
if (this.rateComponent) {
|
|
this.rateComponent.off("rate-changed", this.onPlaybackRateChanged);
|
|
}
|
|
},
|
|
|
|
createMarkup: function() {
|
|
let state = this.player.state;
|
|
|
|
this.el = createNode({
|
|
parent: this.containerEl,
|
|
attributes: {
|
|
"class": "player-widget " + state.playState
|
|
}
|
|
});
|
|
|
|
if (this.targetNodeComponent) {
|
|
this.targetNodeComponent.init(this.el);
|
|
this.targetNodeComponent.render(this.player);
|
|
}
|
|
|
|
this.metaDataComponent.init(this.el);
|
|
this.metaDataComponent.render(state);
|
|
|
|
// Timeline widget.
|
|
let timelineEl = createNode({
|
|
parent: this.el,
|
|
attributes: {
|
|
"class": "timeline"
|
|
}
|
|
});
|
|
|
|
// Playback control buttons container.
|
|
let playbackControlsEl = createNode({
|
|
parent: timelineEl,
|
|
attributes: {
|
|
"class": "playback-controls"
|
|
}
|
|
});
|
|
|
|
// Control buttons.
|
|
this.playPauseBtnEl = createNode({
|
|
parent: playbackControlsEl,
|
|
nodeType: "button",
|
|
attributes: {
|
|
"class": "toggle devtools-button"
|
|
}
|
|
});
|
|
|
|
if (AnimationsController.hasSetCurrentTime) {
|
|
this.rewindBtnEl = createNode({
|
|
parent: playbackControlsEl,
|
|
nodeType: "button",
|
|
attributes: {
|
|
"class": "rw devtools-button"
|
|
}
|
|
});
|
|
|
|
this.fastForwardBtnEl = createNode({
|
|
parent: playbackControlsEl,
|
|
nodeType: "button",
|
|
attributes: {
|
|
"class": "ff devtools-button"
|
|
}
|
|
});
|
|
}
|
|
|
|
if (this.rateComponent) {
|
|
this.rateComponent.init(playbackControlsEl);
|
|
this.rateComponent.render(state);
|
|
}
|
|
|
|
// Sliders container.
|
|
let slidersContainerEl = createNode({
|
|
parent: timelineEl,
|
|
attributes: {
|
|
"class": "sliders-container",
|
|
}
|
|
});
|
|
|
|
let max = state.duration;
|
|
if (state.iterationCount) {
|
|
// If there's a finite nb of iterations.
|
|
max = state.iterationCount * state.duration;
|
|
}
|
|
|
|
// For now, keyframes aren't exposed by the actor. So the only range <input>
|
|
// displayed in the container is the currentTime. When keyframes are
|
|
// available, one input per keyframe can be added here.
|
|
this.currentTimeEl = createNode({
|
|
nodeType: "input",
|
|
parent: slidersContainerEl,
|
|
attributes: {
|
|
"type": "range",
|
|
"class": "current-time",
|
|
"min": "0",
|
|
"max": max,
|
|
"step": "10",
|
|
"value": "0"
|
|
}
|
|
});
|
|
|
|
if (!AnimationsController.hasSetCurrentTime) {
|
|
this.currentTimeEl.setAttribute("disabled", "true");
|
|
}
|
|
|
|
// Time display
|
|
this.timeDisplayEl = createNode({
|
|
parent: timelineEl,
|
|
attributes: {
|
|
"class": "time-display"
|
|
}
|
|
});
|
|
|
|
// Show the initial time.
|
|
this.displayTime(state.currentTime);
|
|
},
|
|
|
|
/**
|
|
* Executed when the playPause button is clicked.
|
|
* Note that tests may want to call this callback directly rather than
|
|
* simulating a click on the button since it returns the promise returned by
|
|
* play and paused.
|
|
* @return {Promise}
|
|
*/
|
|
onPlayPauseBtnClick: function() {
|
|
if (this.player.state.playState === "running") {
|
|
return this.pause();
|
|
}
|
|
return this.play();
|
|
},
|
|
|
|
onRewindBtnClick: function() {
|
|
this.setCurrentTime(0, true);
|
|
},
|
|
|
|
onFastForwardBtnClick: function() {
|
|
let state = this.player.state;
|
|
|
|
let time = state.duration;
|
|
if (state.iterationCount) {
|
|
time = state.iterationCount * state.duration;
|
|
}
|
|
this.setCurrentTime(time, true);
|
|
},
|
|
|
|
/**
|
|
* Executed when the current-time range input is changed.
|
|
*/
|
|
onCurrentTimeChanged: function(e) {
|
|
let time = e.target.value;
|
|
this.setCurrentTime(parseFloat(time), true);
|
|
},
|
|
|
|
/**
|
|
* Executed when the playback rate dropdown value changes in the playbackrate
|
|
* component.
|
|
*/
|
|
onPlaybackRateChanged: function(e, rate) {
|
|
this.setPlaybackRate(rate);
|
|
},
|
|
|
|
/**
|
|
* Whenever a player state update is received.
|
|
*/
|
|
onStateChanged: function() {
|
|
let state = this.player.state;
|
|
|
|
this.updateWidgetState(state);
|
|
this.metaDataComponent.render(state);
|
|
if (this.rateComponent) {
|
|
this.rateComponent.render(state);
|
|
}
|
|
|
|
switch (state.playState) {
|
|
case "finished":
|
|
this.stopTimelineAnimation();
|
|
this.displayTime(this.player.state.currentTime);
|
|
break;
|
|
case "running":
|
|
this.startTimelineAnimation();
|
|
break;
|
|
case "paused":
|
|
this.stopTimelineAnimation();
|
|
this.displayTime(this.player.state.currentTime);
|
|
break;
|
|
case "idle":
|
|
this.stopTimelineAnimation();
|
|
this.displayTime(0);
|
|
break;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set the current time of the animation.
|
|
* @param {Number} time.
|
|
* @param {Boolean} shouldPause Should the player be paused too.
|
|
* @return {Promise} Resolves when the current time has been set.
|
|
*/
|
|
setCurrentTime: Task.async(function*(time, shouldPause) {
|
|
if (!AnimationsController.hasSetCurrentTime) {
|
|
throw new Error("This server version doesn't support setting " +
|
|
"animations' currentTime");
|
|
}
|
|
|
|
if (shouldPause) {
|
|
this.stopTimelineAnimation();
|
|
yield this.pause();
|
|
}
|
|
|
|
if (this.player.state.delay) {
|
|
time += this.player.state.delay;
|
|
}
|
|
|
|
// Set the time locally first so it feels instant, even if the request to
|
|
// actually set the time is async.
|
|
this.displayTime(time);
|
|
|
|
yield this.player.setCurrentTime(time);
|
|
}),
|
|
|
|
/**
|
|
* Set the playback rate of the animation.
|
|
* @param {Number} rate.
|
|
* @return {Promise} Resolves when the rate has been set.
|
|
*/
|
|
setPlaybackRate: function(rate) {
|
|
if (!AnimationsController.hasSetPlaybackRate) {
|
|
throw new Error("This server version doesn't support setting " +
|
|
"animations' playbackRate");
|
|
}
|
|
|
|
return this.player.setPlaybackRate(rate);
|
|
},
|
|
|
|
/**
|
|
* 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() {
|
|
// 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"});
|
|
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() {
|
|
// 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}) {
|
|
this.el.className = "player-widget " + playState;
|
|
},
|
|
|
|
/**
|
|
* Make the timeline progress smoothly, even though the currentTime is only
|
|
* updated at some intervals. This uses a local animation loop.
|
|
*/
|
|
startTimelineAnimation: function() {
|
|
this.stopTimelineAnimation();
|
|
|
|
let state = this.player.state;
|
|
|
|
let start = performance.now();
|
|
let loop = () => {
|
|
this.rafID = requestAnimationFrame(loop);
|
|
let delta = (performance.now() - start) * state.playbackRate;
|
|
let now = state.currentTime + delta;
|
|
this.displayTime(now);
|
|
};
|
|
|
|
loop();
|
|
},
|
|
|
|
/**
|
|
* Display the time in the timeDisplayEl and in the currentTimeEl slider.
|
|
*/
|
|
displayTime: function(time) {
|
|
let state = this.player.state;
|
|
|
|
// If the animation is delayed, don't start displaying the time until the
|
|
// delay has passed.
|
|
if (state.delay) {
|
|
time = Math.max(0, time - state.delay);
|
|
}
|
|
|
|
// For finite animations, make sure the displayed time does not go beyond
|
|
// the animation total duration (this may happen due to the local
|
|
// requestAnimationFrame loop).
|
|
if (state.iterationCount) {
|
|
time = Math.min(time, state.iterationCount * state.duration);
|
|
}
|
|
|
|
// Set the time label value.
|
|
this.timeDisplayEl.textContent = L10N.getFormatStr("player.timeLabel",
|
|
L10N.numberWithDecimals(time / 1000, 2));
|
|
|
|
// Set the timeline slider value.
|
|
if (!state.iterationCount && time !== state.duration) {
|
|
time = time % state.duration;
|
|
}
|
|
this.currentTimeEl.value = time;
|
|
},
|
|
|
|
/**
|
|
* Stop the animation loop that makes the timeline progress.
|
|
*/
|
|
stopTimelineAnimation: function() {
|
|
if (this.rafID) {
|
|
cancelAnimationFrame(this.rafID);
|
|
this.rafID = null;
|
|
}
|
|
}
|
|
};
|