mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-11 06:04:55 +08:00
Add a live-updating timeline view to the Media component in devtools
I had to add a small helper to the FlameChart.js file to be able to add a scroll wheel listener for zooming in and out. Right now I've got the play/pause/ended/destroyed events hooked up, as well as the audio/video buffering delays. I want to next hook up some of the new work the team has been doing on smoothness and cadence of frame presentation. Example usage: https://files.tedm.io/dtss/timeline/example_usage1.mp4 Example with throttled internet: https://files.tedm.io/dtss/timeline/example_usage2.mp4 Change-Id: I515acb76196d127059a958388168f0794e190274 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2232202 Commit-Queue: Ted Meyer <tmathmeyer@chromium.org> Reviewed-by: Mathias Bynens <mathias@chromium.org> Reviewed-by: Alex Rudenko <alexrudenko@chromium.org> Reviewed-by: Tim van der Lippe <tvanderlippe@chromium.org>
This commit is contained in:
@@ -1225,12 +1225,14 @@ all_devtools_modules = [
|
||||
"main/MainImpl.js",
|
||||
"main/SimpleApp.js",
|
||||
"media/EventDisplayTable.js",
|
||||
"media/EventTimelineView.js",
|
||||
"media/MainView.js",
|
||||
"media/MediaModel.js",
|
||||
"media/PlayerDetailView.js",
|
||||
"media/PlayerListView.js",
|
||||
"media/PlayerMessagesView.js",
|
||||
"media/PlayerPropertiesView.js",
|
||||
"media/TickingFlameChart.js",
|
||||
"mobile_throttling/MobileThrottlingSelector.js",
|
||||
"mobile_throttling/NetworkPanelIndicator.js",
|
||||
"mobile_throttling/NetworkThrottlingSelector.js",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright 2019 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import {PlayerEvent} from './MediaModel.js'; // eslint-disable-line no-unused-vars
|
||||
import {ColdColorScheme, HotColorScheme, TickingFlameChart} from './TickingFlameChart.js';
|
||||
|
||||
// Has to be a double, see https://v8.dev/blog/react-cliff
|
||||
const NO_NORMALIZED_TIMESTAMP = -1.5;
|
||||
|
||||
/**
|
||||
* @unrestricted
|
||||
*/
|
||||
export class PlayerEventsTimeline extends TickingFlameChart {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._normalizedTimestamp = NO_NORMALIZED_TIMESTAMP;
|
||||
|
||||
this.addGroup('Playback Status', 2);
|
||||
this.addGroup('Buffering Status', 2); // video on top, audio on bottom
|
||||
|
||||
this._playbackStatusLastEvent = null;
|
||||
this._audioBufferingStateEvent = null;
|
||||
this._videoBufferingStateEvent = null;
|
||||
}
|
||||
|
||||
_ensureNoPreviousPlaybackEvent(normalizedTime) {
|
||||
if (this._playbackStatusLastEvent !== null) {
|
||||
this._playbackStatusLastEvent.endTime = normalizedTime;
|
||||
this._playbackStatusLastEvent = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback events are {kPlay, kPause, kSuspended, kEnded, and kWebMediaPlayerDestroyed}
|
||||
* once destroyed, a player cannot recieve more events of any kind.
|
||||
*/
|
||||
_onPlaybackEvent(event, normalizedTime) {
|
||||
switch (event.event) {
|
||||
case 'kPlay':
|
||||
this.canTick = true;
|
||||
this._ensureNoPreviousPlaybackEvent(normalizedTime);
|
||||
|
||||
this._playbackStatusLastEvent = this.startEvent({level: 0, startTime: normalizedTime, name: 'Play'});
|
||||
break;
|
||||
|
||||
case 'kPause':
|
||||
// Don't change ticking state - the player is still active even during
|
||||
// video pause. It may recieve buffering events, seeks, etc.
|
||||
this._ensureNoPreviousPlaybackEvent(normalizedTime);
|
||||
this._playbackStatusLastEvent =
|
||||
this.startEvent({level: 0, startTime: normalizedTime, name: 'Pause', color: HotColorScheme[1]});
|
||||
break;
|
||||
|
||||
case 'kWebMediaPlayerDestroyed':
|
||||
this.canTick = false;
|
||||
this._ensureNoPreviousPlaybackEvent(normalizedTime);
|
||||
this.addMarker({level: 1, startTime: normalizedTime, name: 'Destroyed', color: HotColorScheme[4]});
|
||||
break;
|
||||
|
||||
case 'kSuspended':
|
||||
// Other event's can't happen during suspension or while the player is
|
||||
// destroyed, so stop the ticking.
|
||||
this.canTick = false;
|
||||
this._ensureNoPreviousPlaybackEvent(normalizedTime);
|
||||
this._playbackStatusLastEvent =
|
||||
this.startEvent({level: 1, startTime: normalizedTime, name: 'Suspended', color: HotColorScheme[3]});
|
||||
break;
|
||||
|
||||
case 'kEnded':
|
||||
// Player ending can still have seeks & other events.
|
||||
this._ensureNoPreviousPlaybackEvent(normalizedTime);
|
||||
this._playbackStatusLastEvent =
|
||||
this.startEvent({level: 1, startTime: normalizedTime, name: 'Ended', color: HotColorScheme[2]});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw `_onPlaybackEvent cant handle ${event.event}`;
|
||||
}
|
||||
}
|
||||
|
||||
_bufferedEnough(state) {
|
||||
return state['state'] === 'BUFFERING_HAVE_ENOUGH';
|
||||
}
|
||||
|
||||
_onBufferingStatus(event, normalizedTime) {
|
||||
// No declarations inside the case labels.
|
||||
let audioState = null;
|
||||
let videoState = null;
|
||||
|
||||
switch (event.event) {
|
||||
case 'kBufferingStateChanged':
|
||||
// There are three allowed entries, audio, video, and pipeline.
|
||||
// We only want the buffering for audio and video to be displayed.
|
||||
// One event may have changes for a single type, or for both audio/video
|
||||
// simultaneously.
|
||||
audioState = event.value['audio_buffering_state'];
|
||||
videoState = event.value['video_buffering_state'];
|
||||
|
||||
if (audioState) {
|
||||
if (this._audioBufferingStateEvent !== null) {
|
||||
this._audioBufferingStateEvent.endTime = normalizedTime;
|
||||
this._audioBufferingStateEvent = null;
|
||||
}
|
||||
if (!this._bufferedEnough(audioState)) {
|
||||
this._audioBufferingStateEvent = this.startEvent({
|
||||
level: 3,
|
||||
startTime: normalizedTime,
|
||||
name: 'Audio Buffering',
|
||||
color: ColdColorScheme[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (videoState) {
|
||||
if (this._videoBufferingStateEvent !== null) {
|
||||
this._videoBufferingStateEvent.endTime = normalizedTime;
|
||||
this._videoBufferingStateEvent = null;
|
||||
}
|
||||
if (!this._bufferedEnough(videoState)) {
|
||||
this._videoBufferingStateEvent = this.startEvent({
|
||||
level: 2,
|
||||
startTime: normalizedTime,
|
||||
name: 'Video Buffering',
|
||||
color: ColdColorScheme[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw `_onPlaybackEvent cant handle ${event.event}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!PlayerEvent} event
|
||||
*/
|
||||
onEvent(event) {
|
||||
if (this._normalizedTimestamp === NO_NORMALIZED_TIMESTAMP) {
|
||||
this._normalizedTimestamp = event.timestamp;
|
||||
}
|
||||
const inMilliseconds = (event.timestamp - this._normalizedTimestamp) * 1000;
|
||||
|
||||
switch (event.event) {
|
||||
case 'kPlay':
|
||||
case 'kPause':
|
||||
case 'kWebMediaPlayerDestroyed':
|
||||
case 'kSuspended':
|
||||
case 'kEnded':
|
||||
return this._onPlaybackEvent(event, inMilliseconds);
|
||||
|
||||
case 'kBufferingStateChanged':
|
||||
return this._onBufferingStatus(event, inMilliseconds);
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as Common from '../common/common.js';
|
||||
import * as UI from '../ui/ui.js';
|
||||
|
||||
import {PlayerEventsView} from './EventDisplayTable.js';
|
||||
import {PlayerEventsTimeline} from './EventTimelineView.js';
|
||||
import {TriggerHandler} from './MainView.js'; // eslint-disable-line no-unused-vars
|
||||
import {PlayerEvent} from './MediaModel.js'; // eslint-disable-line no-unused-vars
|
||||
import {PlayerMessagesView} from './PlayerMessagesView.js';
|
||||
@@ -17,7 +18,8 @@ import {PlayerPropertiesView} from './PlayerPropertiesView.js';
|
||||
export const PlayerDetailViewTabs = {
|
||||
Events: 'events',
|
||||
Properties: 'properties',
|
||||
Messages: 'messages'
|
||||
Messages: 'messages',
|
||||
Timeline: 'timeline'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -31,6 +33,7 @@ export class PlayerDetailView extends UI.TabbedPane.TabbedPane {
|
||||
this._eventView = new PlayerEventsView();
|
||||
this._propertyView = new PlayerPropertiesView();
|
||||
this._messageView = new PlayerMessagesView();
|
||||
this._timelineView = new PlayerEventsTimeline();
|
||||
|
||||
this.appendTab(
|
||||
PlayerDetailViewTabs.Properties, Common.UIString.UIString('Properties'), this._propertyView,
|
||||
@@ -43,6 +46,10 @@ export class PlayerDetailView extends UI.TabbedPane.TabbedPane {
|
||||
this.appendTab(
|
||||
PlayerDetailViewTabs.Messages, Common.UIString.UIString('Messages'), this._messageView,
|
||||
Common.UIString.UIString('Player messages'));
|
||||
|
||||
this.appendTab(
|
||||
PlayerDetailViewTabs.Timeline, Common.UIString.UIString('Timeline'), this._timelineView,
|
||||
Common.UIString.UIString('Player timeline'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,5 +81,6 @@ export class PlayerDetailView extends UI.TabbedPane.TabbedPane {
|
||||
*/
|
||||
onEvent(event) {
|
||||
this._eventView.onEvent(event);
|
||||
this._timelineView.onEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
// Copyright 2020 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import * as Common from '../common/common.js';
|
||||
import * as Host from '../host/host.js';
|
||||
import * as PerfUI from '../perf_ui/perf_ui.js';
|
||||
import * as UI from '../ui/ui.js';
|
||||
|
||||
import {PlayerEvent} from './MediaModel.js'; // eslint-disable-line no-unused-vars
|
||||
|
||||
const defaultFont = '11px ' + Host.Platform.fontFamily();
|
||||
const defaultColor = '#444';
|
||||
|
||||
const DefaultStyle = {
|
||||
height: 20,
|
||||
padding: 2,
|
||||
collapsible: false,
|
||||
font: defaultFont,
|
||||
color: defaultColor,
|
||||
backgroundColor: 'rgba(100 0 0 / 10%)',
|
||||
nestingLevel: 0,
|
||||
itemsHeight: 20,
|
||||
shareHeaderLine: false,
|
||||
useFirstLineForOverview: false,
|
||||
useDecoratorsForOverview: false
|
||||
};
|
||||
|
||||
export const HotColorScheme = ['#ffba08', '#faa307', '#f48c06', '#e85d04', '#dc2f02', '#d00000', '#9d0208'];
|
||||
export const ColdColorScheme = ['#7400b8', '#6930c3', '#5e60ce', '#5390d9', '#4ea8de', '#48bfe3', '#56cfe1', '#64dfdf'];
|
||||
|
||||
|
||||
/**
|
||||
* @param {number} ms
|
||||
* @param {number} decimalPlaces
|
||||
* @return {string}
|
||||
*/
|
||||
function formatMillisecondsToSeconds(ms, decimalPlaces) {
|
||||
const roundPower = Math.pow(10, 3 - decimalPlaces);
|
||||
const denominatorPower = Math.pow(10, Math.max(0, decimalPlaces));
|
||||
return `${Math.round(ms / roundPower) / denominatorPower} s`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} backgroundColor
|
||||
* @return {string}
|
||||
*/
|
||||
function calculateFontColor(backgroundColor) {
|
||||
// Dark background needs a light font.
|
||||
if (Common.Color.Color.parse(backgroundColor).hsla()[2] < 0.5) {
|
||||
return '#eee';
|
||||
}
|
||||
return '#444';
|
||||
}
|
||||
|
||||
|
||||
class Bounds {
|
||||
constructor(initialLow, initialHigh, maxRange, minRange) {
|
||||
this._min = initialLow;
|
||||
this._max = initialHigh;
|
||||
this._low = this._min;
|
||||
this._high = this._max;
|
||||
this._maxRange = maxRange;
|
||||
this._minRange = minRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get low() {
|
||||
return this._low;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get high() {
|
||||
return this._high;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get min() {
|
||||
return this._min;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get max() {
|
||||
return this._max;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get range() {
|
||||
return this._high - this._low;
|
||||
}
|
||||
|
||||
_reassertBounds() {
|
||||
let needsAdjustment = true;
|
||||
while (needsAdjustment) {
|
||||
needsAdjustment = false;
|
||||
if (this.range < this._minRange) {
|
||||
needsAdjustment = true;
|
||||
const delta = (this._minRange - this.range) / 2;
|
||||
this._high += delta;
|
||||
this._low -= delta;
|
||||
}
|
||||
|
||||
if (this._low < this._min) {
|
||||
needsAdjustment = true;
|
||||
this._low = this._min;
|
||||
}
|
||||
|
||||
if (this._high > this._max) {
|
||||
needsAdjustment = true;
|
||||
this._high = this._max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* zoom out |amount| ticks at position [0, 1] along the current range of the timeline.
|
||||
* @param {number} amount
|
||||
* @param {number} position
|
||||
*/
|
||||
zoomOut(amount, position) {
|
||||
const range = this._high - this._low;
|
||||
const growSize = range * Math.pow(1.1, amount) - range;
|
||||
const lowEnd = growSize * position;
|
||||
const highEnd = growSize - lowEnd;
|
||||
this._low -= lowEnd;
|
||||
this._high += highEnd;
|
||||
this._reassertBounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* zoom in |amount| ticks at position [0, 1] along the current range of the timeline.
|
||||
* @param {number} amount
|
||||
* @param {number} position
|
||||
*/
|
||||
zoomIn(amount, position) {
|
||||
const range = this._high - this._low;
|
||||
if (this.range <= this._minRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shrinkSize = range - range / Math.pow(1.1, amount);
|
||||
const lowEnd = shrinkSize * position;
|
||||
const highEnd = shrinkSize - lowEnd;
|
||||
this._low += lowEnd;
|
||||
this._high -= highEnd;
|
||||
this._reassertBounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Xms to the max value, and scroll the timeline forward if the end is in sight.
|
||||
* @param {number} amount
|
||||
*/
|
||||
addMax(amount) {
|
||||
const range = this._high - this._low;
|
||||
const isAtHighEnd = this._high === this._max;
|
||||
const isZoomedOut = this._low === this._min || range >= this._maxRange;
|
||||
|
||||
this._max += amount;
|
||||
if (isAtHighEnd && isZoomedOut) {
|
||||
this._high = this._max;
|
||||
}
|
||||
this._reassertBounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to push the maximum time up to |time| ms.
|
||||
* @param {number} time
|
||||
*/
|
||||
pushMaxAtLeastTo(time) {
|
||||
if (this._max < time) {
|
||||
this.addMax(time - this._max);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* setLive: (!function(number):number),
|
||||
* setComplete: (!function(number):undefined),
|
||||
* updateMaxTime: (!function(number):undefined)
|
||||
* }}
|
||||
*/
|
||||
let EventHandlers; // eslint-disable-line no-unused-vars
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* level: number,
|
||||
* startTime: number,
|
||||
* duration: (number|undefined),
|
||||
* name: string,
|
||||
* color: (string|undefined),
|
||||
* hoverData: (Object|undefined|null)
|
||||
* }}
|
||||
*/
|
||||
let EventProperties; // eslint-disable-line no-unused-vars
|
||||
|
||||
/**
|
||||
* Wrapper class for each event displayed on the timeline.
|
||||
* @unrestricted
|
||||
*/
|
||||
class Event {
|
||||
constructor(timelineData, eventHandlers, eventProperties = {}) {
|
||||
// These allow the event to privately change it's own data in the timeline.
|
||||
this._timelineData = timelineData;
|
||||
this._setLive = eventHandlers.setLive;
|
||||
this._setComplete = eventHandlers.setComplete;
|
||||
this._updateMaxTime = eventHandlers.updateMaxTime;
|
||||
|
||||
// This is the index in the timelineData arrays we should be writing to.
|
||||
this._selfIndex = this._timelineData.entryLevels.length;
|
||||
this._live = false;
|
||||
|
||||
// Can't use the dict||or||default syntax, since NaN is a valid expected duration.
|
||||
const duration = eventProperties['duration'] === undefined ? 0 : eventProperties['duration'];
|
||||
|
||||
this._timelineData.entryLevels.push(eventProperties['level'] || 0);
|
||||
this._timelineData.entryStartTimes.push(eventProperties['startTime'] || 0);
|
||||
this._timelineData.entryTotalTimes.push(duration); // May initially push -1
|
||||
|
||||
// If -1 was pushed, we need to update it. The set end time method helps with this.
|
||||
if (duration === -1) {
|
||||
this.endTime = -1;
|
||||
}
|
||||
|
||||
this._title = eventProperties['name'] || '';
|
||||
this._color = eventProperties['color'] || HotColorScheme[0];
|
||||
this._fontColor = calculateFontColor(this._color);
|
||||
this._hoverData = eventProperties['hoverData'] || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render hovertext into the |htmlElement|
|
||||
* @param {!HTMLElement} htmlElement
|
||||
*/
|
||||
decorate(htmlElement) {
|
||||
htmlElement.createChild('span').textContent = `Name: ${this._title}`;
|
||||
htmlElement.createChild('br');
|
||||
|
||||
const startTimeReadable = formatMillisecondsToSeconds(this.startTime, 2);
|
||||
if (this._live) {
|
||||
htmlElement.createChild('span').textContent = `Duration: ${startTimeReadable} - LIVE!`;
|
||||
} else if (!isNaN(this.duration)) {
|
||||
const durationReadable = formatMillisecondsToSeconds(this.duration + this.startTime, 2);
|
||||
htmlElement.createChild('span').textContent = `Duration: ${startTimeReadable} - ${durationReadable}`;
|
||||
} else {
|
||||
htmlElement.createChild('span').textContent = `Time: ${startTimeReadable}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* set an event to be "live" where it's ended time is always the chart maximum
|
||||
* or to be a fixed time.
|
||||
* @param {number} time
|
||||
*/
|
||||
set endTime(time) {
|
||||
// Setting end time to -1 signals that an event becomes live
|
||||
if (time === -1) {
|
||||
this._timelineData.entryTotalTimes[this._selfIndex] = this._setLive(this._selfIndex);
|
||||
this._live = true;
|
||||
} else {
|
||||
this._live = false;
|
||||
const duration = time - this._timelineData.entryStartTimes[this._selfIndex];
|
||||
this._timelineData.entryTotalTimes[this._selfIndex] = duration;
|
||||
this._setComplete(this._selfIndex);
|
||||
this._updateMaxTime(time);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get id() {
|
||||
return this._selfIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} level
|
||||
*/
|
||||
set level(level) {
|
||||
this._timelineData.entryLevels[this._selfIndex] = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
set title(text) {
|
||||
this._title = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
get title() {
|
||||
return this._title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} color
|
||||
*/
|
||||
set color(color) {
|
||||
this._color = color;
|
||||
this._fontColor = calculateFontColor(this._color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
get color() {
|
||||
return this._color;
|
||||
}
|
||||
|
||||
get fontColor() {
|
||||
return this._fontColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get startTime() {
|
||||
// Round it
|
||||
return this._timelineData.entryStartTimes[this._selfIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
get duration() {
|
||||
return this._timelineData.entryTotalTimes[this._selfIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
get live() {
|
||||
return this._live;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @unrestricted
|
||||
*/
|
||||
export class TickingFlameChart extends UI.Widget.VBox {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// set to update once per second _while the tab is active_
|
||||
this._intervalTimer = null;
|
||||
this._lastTimestamp = 0;
|
||||
this._canTick = true;
|
||||
this._ticking = false;
|
||||
this._isShown = false;
|
||||
|
||||
// The max bounds for scroll-out.
|
||||
this._bounds = new Bounds(0, 1000, 30000, 1000);
|
||||
|
||||
// Create the data provider with the initial max bounds,
|
||||
// as well as a function to attempt bounds updating everywhere.
|
||||
this._dataProvider = new TickingFlameChartDataProvider(this._bounds, this._updateMaxTime.bind(this));
|
||||
|
||||
// Delegate doesn't do much for now.
|
||||
this._delegate = new TickingFlameChartDelegate();
|
||||
|
||||
// Chart settings.
|
||||
this._chartGroupExpansionSetting =
|
||||
Common.Settings.Settings.instance().createSetting('mediaFlameChartGroupExpansion', {});
|
||||
|
||||
// Create the chart.
|
||||
this._chart =
|
||||
new PerfUI.FlameChart.FlameChart(this._dataProvider, this._delegate, this._chartGroupExpansionSetting);
|
||||
|
||||
// TODO: needs to have support in the delegate for supporting this.
|
||||
this._chart.disableRangeSelection();
|
||||
|
||||
// Scrolling should change the current bounds, and repaint the chart.
|
||||
this._chart.bindCanvasEvent('wheel', this._onScroll.bind(this));
|
||||
|
||||
// Add the chart.
|
||||
this._chart.show(this.contentElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a marker with |properties| at |time|.
|
||||
* @param {!EventProperties} properties
|
||||
*/
|
||||
addMarker(properties) {
|
||||
properties['duration'] = NaN;
|
||||
this.startEvent(properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event which will be set to live by default.
|
||||
* @param {!EventProperties} properties
|
||||
* @return {!Event}
|
||||
*/
|
||||
startEvent(properties) {
|
||||
// Make sure that an unspecified event gets live duration.
|
||||
// Have to check for undefined, since NaN is allowed but evaluates to false.
|
||||
if (properties['duration'] === undefined) {
|
||||
properties['duration'] = -1;
|
||||
}
|
||||
const time = properties['startTime'] || 0;
|
||||
|
||||
// Event has to be created before the updateMaxTime call.
|
||||
const event = this._dataProvider.startEvent(properties);
|
||||
|
||||
this._updateMaxTime(time);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a group with |name| that can contain |depth| different tracks.
|
||||
* @param {string} name
|
||||
* @param {number} depth
|
||||
*/
|
||||
addGroup(name, depth) {
|
||||
this._dataProvider.addGroup(name, depth);
|
||||
}
|
||||
|
||||
_updateMaxTime(time) {
|
||||
if (this._bounds.pushMaxAtLeastTo(time)) {
|
||||
this._updateRender();
|
||||
}
|
||||
}
|
||||
|
||||
_onScroll(e) {
|
||||
// TODO: is this a good divisor? does it account for high presicision scroll wheels?
|
||||
// low precisision scroll wheels?
|
||||
const scrollTickCount = Math.round(e.deltaY / 50);
|
||||
const scrollPositionRatio = e.offsetX / e.srcElement.clientWidth;
|
||||
if (scrollTickCount > 0) {
|
||||
this._bounds.zoomOut(scrollTickCount, scrollPositionRatio);
|
||||
} else {
|
||||
this._bounds.zoomIn(-scrollTickCount, scrollPositionRatio);
|
||||
}
|
||||
this._updateRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willHide() {
|
||||
this._isShown = false;
|
||||
if (this._ticking) {
|
||||
this._stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
wasShown() {
|
||||
this._isShown = true;
|
||||
if (this._canTick && !this._ticking) {
|
||||
this._start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the timeline allowed to tick forward.
|
||||
* @param {boolean} allowed
|
||||
*/
|
||||
set canTick(allowed) {
|
||||
this._canTick = allowed;
|
||||
if (this._ticking && !allowed) {
|
||||
this._stop();
|
||||
}
|
||||
if (!this._ticking && this._isShown && allowed) {
|
||||
this._start();
|
||||
}
|
||||
}
|
||||
|
||||
_start() {
|
||||
if (this._lastTimestamp === 0) {
|
||||
this._lastTimestamp = Date.now();
|
||||
}
|
||||
if (this._intervalTimer !== null || this._stoppedPermanently) {
|
||||
return;
|
||||
}
|
||||
// 16 ms is roughly 60 fps.
|
||||
this._intervalTimer = setInterval(this._updateRender.bind(this), 16);
|
||||
this._ticking = true;
|
||||
}
|
||||
|
||||
_stop(permanently = false) {
|
||||
clearInterval(this._intervalTimer);
|
||||
this._intervalTimer = null;
|
||||
if (permanently) {
|
||||
this._stoppedPermanently = true;
|
||||
}
|
||||
this._ticking = false;
|
||||
}
|
||||
|
||||
_updateRender() {
|
||||
if (this._ticking) {
|
||||
const currentTimestamp = Date.now();
|
||||
const duration = currentTimestamp - this._lastTimestamp;
|
||||
this._lastTimestamp = currentTimestamp;
|
||||
this._bounds.addMax(duration);
|
||||
}
|
||||
this._dataProvider.updateMaxTime(this._bounds);
|
||||
this._chart.setWindowTimes(this._bounds.low, this._bounds.high, true);
|
||||
this._chart.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Doesn't do much right now, but can be used in the future for selecting events.
|
||||
* @implements {PerfUI.FlameChart.FlameChartDelegate}
|
||||
*/
|
||||
class TickingFlameChartDelegate {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} windowStartTime
|
||||
* @param {number} windowEndTime
|
||||
* @param {boolean} animate
|
||||
*/
|
||||
windowChanged(windowStartTime, windowEndTime, animate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} startTime
|
||||
* @param {number} endTime
|
||||
*/
|
||||
updateRangeSelection(startTime, endTime) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {!PerfUI.FlameChart.FlameChart} flameChart
|
||||
* @param {?PerfUI.FlameChart.Group} group
|
||||
*/
|
||||
updateSelectedGroup(flameChart, group) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @implements {PerfUI.FlameChart.FlameChartDataProvider}
|
||||
*/
|
||||
class TickingFlameChartDataProvider {
|
||||
constructor(initialBounds, updateMaxTime) {
|
||||
// do _not_ call this method from within this class - only for passing to events.
|
||||
this._updateMaxTimeHandle = updateMaxTime;
|
||||
|
||||
this._bounds = initialBounds;
|
||||
|
||||
// All the events which should have their time updated when the chart ticks.
|
||||
this._liveEvents = new Set();
|
||||
|
||||
// All events.
|
||||
// Map<Event>
|
||||
this._eventMap = new Map();
|
||||
|
||||
// Contains the numerical indicies. This is passed as a reference to the events
|
||||
// so that they can update it when they change.
|
||||
this._timelineData = new PerfUI.FlameChart.TimelineData([], [], [], []);
|
||||
|
||||
// The current sum of all group heights.
|
||||
this._maxLevel = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a group with |name| that can contain |depth| different tracks.
|
||||
* @param {string} name
|
||||
* @param {number} depth
|
||||
*/
|
||||
addGroup(name, depth) {
|
||||
this._timelineData.groups.push(
|
||||
{name: name, startLevel: this._maxLevel, expanded: true, selectable: false, style: DefaultStyle});
|
||||
this._maxLevel += depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event which will be set to live by default.
|
||||
* @param {!EventProperties} properties
|
||||
* @return {!Event}
|
||||
*/
|
||||
startEvent(properties) {
|
||||
properties['level'] = properties['level'] || 0;
|
||||
if (properties['level'] > this._maxLevel) {
|
||||
throw `level ${properties['level']} is above the maximum allowed of ${this._maxLevel}`;
|
||||
}
|
||||
|
||||
const event = new Event(
|
||||
this._timelineData, {
|
||||
setLive: this._setLive.bind(this),
|
||||
setComplete: this._setComplete.bind(this),
|
||||
updateMaxTime: this._updateMaxTimeHandle
|
||||
},
|
||||
properties);
|
||||
|
||||
this._eventMap.set(event.id, event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @return {number}
|
||||
*/
|
||||
_setLive(index) {
|
||||
this._liveEvents.add(index);
|
||||
return this._bounds.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
*/
|
||||
_setComplete(index) {
|
||||
this._liveEvents.delete(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!Bounds} bounds
|
||||
*/
|
||||
updateMaxTime(bounds) {
|
||||
this._bounds = bounds;
|
||||
for (const eventID of this._liveEvents.entries()) {
|
||||
// force recalculation of all live events.
|
||||
this._eventMap.get(eventID[0]).endTime = -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @return {number}
|
||||
*/
|
||||
maxStackDepth() {
|
||||
return this._maxLevel + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @return {!PerfUI.FlameChart.TimelineData}
|
||||
*/
|
||||
timelineData() {
|
||||
return this._timelineData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @return {number} time in milliseconds
|
||||
*/
|
||||
minimumBoundary() {
|
||||
return this._bounds.low;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @return {number}
|
||||
*/
|
||||
totalTime() {
|
||||
return this._bounds.high;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} index
|
||||
* @return {string}
|
||||
*/
|
||||
entryColor(index) {
|
||||
return this._eventMap.get(index).color;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} index
|
||||
* @return {string}
|
||||
*/
|
||||
textColor(index) {
|
||||
return this._eventMap.get(index).fontColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} index
|
||||
* @return {?string}
|
||||
*/
|
||||
entryTitle(index) {
|
||||
return this._eventMap.get(index).title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} index
|
||||
* @return {?string}
|
||||
*/
|
||||
entryFont(index) {
|
||||
return defaultFont;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} index
|
||||
* @param {!CanvasRenderingContext2D} context
|
||||
* @param {?string} text
|
||||
* @param {number} barX
|
||||
* @param {number} barY
|
||||
* @param {number} barWidth
|
||||
* @param {number} barHeight
|
||||
* @param {number} unclippedBarX
|
||||
* @param {number} timeToPixelRatio
|
||||
* @return {boolean}
|
||||
*/
|
||||
decorateEntry(index, context, text, barX, barY, barWidth, barHeight, unclippedBarX, timeToPixelRatio) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} index
|
||||
* @return {boolean}
|
||||
*/
|
||||
forceDecoration(index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} index
|
||||
* @return {?Element}
|
||||
*/
|
||||
prepareHighlightedEntryInfo(index) {
|
||||
const element = createElement('div');
|
||||
this._eventMap.get(index).decorate(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} value
|
||||
* @param {number=} precision
|
||||
* @return {string}
|
||||
*/
|
||||
formatValue(value, precision) {
|
||||
// value is always [0, X] so we need to add lower bound
|
||||
value += Math.round(this._bounds.low);
|
||||
|
||||
// Magic numbers of pre-calculated logorithms.
|
||||
|
||||
// we want to show additional decimals at the time when two adjacent labels
|
||||
// would otherwise show the same number. At 3840 pixels wide, that cutoff
|
||||
// happens to be about 30 seconds for one decimal and 2.8 for two decimals.
|
||||
if (this._bounds.range < 2800) {
|
||||
return formatMillisecondsToSeconds(value, 2);
|
||||
}
|
||||
if (this._bounds.range < 30000) {
|
||||
return formatMillisecondsToSeconds(value, 1);
|
||||
}
|
||||
return formatMillisecondsToSeconds(value, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {number} entryIndex
|
||||
* @return {boolean}
|
||||
*/
|
||||
canJumpToEntry(entryIndex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<grit-part>
|
||||
<message name="IDS_DEVTOOLS_3d1f92a565d3b1a61880236e33c49bf3" desc="Title for the timeline tab.">
|
||||
Timeline
|
||||
</message>
|
||||
<message name="IDS_DEVTOOLS_34e2d1989a1dbf75cd631596133ee5ee" desc="The type of media, for example - video, audio, or text. Capitalized.">
|
||||
Video
|
||||
</message>
|
||||
<message name="IDS_DEVTOOLS_b22f0418e8ac915eb66f829d262d14a2" desc="The type of media. Capitalized.">
|
||||
Audio
|
||||
</message>
|
||||
<message name="IDS_DEVTOOLS_421b47ffd946ca083b65cd668c6b17e6" desc="The type of media. Lower case.">
|
||||
video
|
||||
</message>
|
||||
@@ -21,6 +21,9 @@
|
||||
<message name="IDS_DEVTOOLS_42895832ab7605d00676bebb0521991f" desc="Media property giving the distance of the playback quality from the ideal playback.">
|
||||
Video Playback Roughness
|
||||
</message>
|
||||
<message name="IDS_DEVTOOLS_5b999e8e6e64d49616bdf5a24a3d02bf" desc="Hovertext for Timeline tab.">
|
||||
Player timeline
|
||||
</message>
|
||||
<message name="IDS_DEVTOOLS_62933a2951ef01f4eafd9bdf4d3cd2f0" desc="Media player">
|
||||
media
|
||||
</message>
|
||||
@@ -45,6 +48,9 @@
|
||||
<message name="IDS_DEVTOOLS_a603905470e2a5b8c13e96b579ef0dba" desc="Debug log level">
|
||||
Debug
|
||||
</message>
|
||||
<message name="IDS_DEVTOOLS_b22f0418e8ac915eb66f829d262d14a2" desc="The type of media, for example - video, audio, or text. Capitalized.">
|
||||
Audio
|
||||
</message>
|
||||
<message name="IDS_DEVTOOLS_cf6523ecab64a91c7a9b3d83b58f9e61" desc="Hover text for the Events button.">
|
||||
Player events
|
||||
</message>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"sdk",
|
||||
"ui",
|
||||
"data_grid",
|
||||
"source_frame"
|
||||
"source_frame",
|
||||
"perf_ui"
|
||||
],
|
||||
"modules": [
|
||||
"media.js",
|
||||
@@ -28,7 +29,9 @@
|
||||
"PlayerListView.js",
|
||||
"PlayerMessagesView.js",
|
||||
"PlayerPropertiesView.js",
|
||||
"MediaModel.js"
|
||||
"MediaModel.js",
|
||||
"EventTimelineView.js",
|
||||
"TickingFlameChart.js"
|
||||
],
|
||||
"scripts": [],
|
||||
"resources": [
|
||||
|
||||
@@ -627,6 +627,14 @@ export class FlameChart extends UI.Widget.VBox {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} eventName
|
||||
* @param {function(!Event):undefined} onEvent
|
||||
*/
|
||||
bindCanvasEvent(eventName, onEvent) {
|
||||
this._canvas.addEventListener(eventName, onEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {!Event} e
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user