diff --git a/BUILD.gn b/BUILD.gn index aa392105bd..7e1e87eca0 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -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", diff --git a/front_end/media/EventTimelineView.js b/front_end/media/EventTimelineView.js new file mode 100644 index 0000000000..0763515ffb --- /dev/null +++ b/front_end/media/EventTimelineView.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: + } + } +} diff --git a/front_end/media/PlayerDetailView.js b/front_end/media/PlayerDetailView.js index 1dd3d2f8f8..d1021ae71e 100644 --- a/front_end/media/PlayerDetailView.js +++ b/front_end/media/PlayerDetailView.js @@ -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); } } diff --git a/front_end/media/TickingFlameChart.js b/front_end/media/TickingFlameChart.js new file mode 100644 index 0000000000..5085a5a342 --- /dev/null +++ b/front_end/media/TickingFlameChart.js @@ -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 + 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; + } +} diff --git a/front_end/media/media_strings.grdp b/front_end/media/media_strings.grdp index 1575e9fa9b..c9e3fce7db 100644 --- a/front_end/media/media_strings.grdp +++ b/front_end/media/media_strings.grdp @@ -1,11 +1,11 @@ + + Timeline + Video - - Audio - video @@ -21,6 +21,9 @@ Video Playback Roughness + + Player timeline + media @@ -45,6 +48,9 @@ Debug + + Audio + Player events diff --git a/front_end/media/module.json b/front_end/media/module.json index 593ff2e874..199bd18fc2 100644 --- a/front_end/media/module.json +++ b/front_end/media/module.json @@ -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": [ diff --git a/front_end/perf_ui/FlameChart.js b/front_end/perf_ui/FlameChart.js index 77fa0182c0..a2ffeb0dde 100644 --- a/front_end/perf_ui/FlameChart.js +++ b/front_end/perf_ui/FlameChart.js @@ -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 */