/* -*- 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 // 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; } } };