blob: 837057cd4a4957edb21fa708dedf13ff7f1c1588 [file] [log] [blame]
// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {$} from 'chrome://resources/js/util.js';
import {millisecondsToString} from './util.js';
/**
* CSS classes added / removed in JS to trigger styling changes.
* @enum {string}
*/
const ClientRendererCss = {
NO_PLAYERS_SELECTED: 'no-players-selected',
NO_COMPONENTS_SELECTED: 'no-components-selected',
SELECTABLE_BUTTON: 'selectable-button',
ERRORED_PLAYER: 'errored-player',
ENDED_PLAYER: 'ended-player',
ACTIVE_PLAYER: 'active-player',
};
function removeChildren(element) {
while (element.hasChildNodes()) {
element.removeChild(element.lastChild);
}
}
function createSelectableButton(
id, groupName, buttonLabel, selectCb, playerState) {
// For CSS styling.
const radioButton = document.createElement('input');
radioButton.classList.add(ClientRendererCss.SELECTABLE_BUTTON);
radioButton.type = 'radio';
radioButton.id = id;
radioButton.name = groupName;
buttonLabel.classList.add(ClientRendererCss.SELECTABLE_BUTTON);
if (playerState === 'errored') {
buttonLabel.classList.add(ClientRendererCss.ERRORED_PLAYER);
} else if (playerState === 'ended') {
buttonLabel.classList.add(ClientRendererCss.ENDED_PLAYER);
} else {
buttonLabel.classList.add(ClientRendererCss.ACTIVE_PLAYER);
}
buttonLabel.setAttribute('for', radioButton.id);
const fragment = document.createDocumentFragment();
fragment.appendChild(radioButton);
fragment.appendChild(buttonLabel);
// Listen to 'change' rather than 'click' to keep styling in sync with
// button behavior.
radioButton.addEventListener('change', selectCb);
return fragment;
}
function selectSelectableButton(id) {
// |id| is usually not a valid selector for querySelector so we cannot use $
// here.
const element = document.getElementById(id);
if (!element) {
console.error('failed to select button with id: ' + id);
return;
}
element.checked = true;
}
function downloadLog(text) {
const file = new Blob([text], {type: 'text/plain'});
const a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = 'media-internals.txt';
a.click();
}
export class ClientRenderer {
constructor() {
this.playerListElement = $('player-list');
const audioTableElement = $('audio-property-table');
if (audioTableElement) {
this.audioPropertiesTable = audioTableElement.querySelector('tbody');
}
const playerTableElement = $('player-property-table');
if (playerTableElement) {
this.playerPropertiesTable = playerTableElement.querySelector('tbody');
}
const logElement = $('log');
if (logElement) {
this.logTable = logElement.querySelector('tbody');
}
this.graphElement = $('graphs');
this.audioPropertyName = $('audio-property-name');
this.audioFocusSessionListElement_ = $('audio-focus-session-list');
this.cdmListElement_ = $('cdm-list');
const generalAudioInformationTableElement = $('general-audio-info-table');
if (generalAudioInformationTableElement) {
this.generalAudioInformationTable =
generalAudioInformationTableElement.querySelector('tbody');
}
this.players = null;
this.selectedPlayer = null;
this.selectedAudioComponentType = null;
this.selectedAudioComponentId = null;
this.selectedAudioCompontentData = null;
this.selectedPlayerLogIndex = 0;
this.filterFunction = function() {
return true;
};
this.filterText = $('filter-text');
if (this.filterText) {
this.filterText.onkeyup = this.onTextChange_.bind(this);
}
this.copyLogButton = $('copy-log-button');
if (this.copyLogButton) {
this.copyLogButton.onclick = this.copyLog_.bind(this);
}
this.saveLogButton = $('save-log-button');
if (this.saveLogButton) {
this.saveLogButton.onclick = this.saveLog_.bind(this);
}
this.closePlayerViewButton = $('close-player-view-button');
if (this.closePlayerViewButton) {
this.closePlayerViewButton.onclick = () => {
$('main-container').classList.remove('mobile-player-view-active');
document.body.classList.add(ClientRendererCss.NO_PLAYERS_SELECTED);
if (this.selectedPlayer) {
const element = this.playerListElement.querySelector(
`.tree-item[data-id="${this.selectedPlayer.id}"]`);
if (element) {
element.classList.remove('selected');
}
this.selectedPlayer = null;
const titleElement = $('player-details-title');
if (titleElement) {
titleElement.textContent = 'Player Properties';
titleElement.title = '';
}
}
};
}
this.hiddenKeys = ['component_id', 'component_type', 'owner_id'];
document.body.classList.add(ClientRendererCss.NO_PLAYERS_SELECTED);
}
/**
* Called to set general audio information.
* @param audioInfo The map of information.
*/
generalAudioInformationSet(audioInfo) {
this.drawProperties_(audioInfo, this.generalAudioInformationTable);
}
/**
* Called when an audio component is added to the collection.
* @param componentType Integer AudioComponent enum value; must match values
* from the AudioLogFactory::AudioComponent enum.
* @param components The entire map of components (name -> dict).
*/
audioComponentAdded(componentType, components) {
this.redrawAudioComponentList_(componentType, components);
// Redraw the component if it's currently selected.
if (this.selectedAudioComponentType === componentType &&
this.selectedAudioComponentId &&
this.selectedAudioComponentId in components) {
// TODO(chcunningham): This path is used both for adding and updating
// the components. Split this up to have a separate update method.
// At present, this selectAudioComponent call is key to *updating* the
// the property table for existing audio components.
this.selectAudioComponent_(
componentType, this.selectedAudioComponentId,
components[this.selectedAudioComponentId]);
}
}
/**
* Called when the list of audio focus sessions has changed.
* @param sessions A list of media sessions that contain the current state.
*/
audioFocusSessionUpdated(sessions) {
removeChildren(this.audioFocusSessionListElement_);
sessions.forEach(session => {
this.audioFocusSessionListElement_.appendChild(
this.createAudioFocusSessionRow_(session));
});
}
/**
* Called when the list of CDM info has changed.
* @param sessions A list of CDM info that contain the current state.
*/
updateRegisteredCdms(cdms) {
removeChildren(this.cdmListElement_);
cdms.forEach(cdm => {
this.cdmListElement_.appendChild(this.createCdmRow_(cdm));
});
}
/**
* Called when an audio component is removed from the collection.
* @param componentType Integer AudioComponent enum value; must match values
* from the AudioLogFactory::AudioComponent enum.
* @param components The entire map of components (name -> dict).
*/
audioComponentRemoved(componentType, components) {
this.redrawAudioComponentList_(componentType, components);
}
/**
* Called when a player is added to the collection.
* @param players The entire map of id -> player.
* @param player_added The player that is added.
*/
playerAdded(players, playerAdded) {
this.redrawPlayerList_(players);
}
/**
* Called when a player is removed from the collection.
* @param players The entire map of id -> player.
* @param playerRemoved The player that was removed.
*/
playerRemoved(players, playerRemoved) {
if (playerRemoved === this.selectedPlayer) {
removeChildren(this.playerPropertiesTable);
removeChildren(this.logTable);
removeChildren(this.graphElement);
document.body.classList.add(ClientRendererCss.NO_PLAYERS_SELECTED);
this.selectedPlayer = null;
const titleElement = $('player-details-title');
if (titleElement) {
titleElement.textContent = 'Player Properties';
titleElement.title = '';
}
}
this.redrawPlayerList_(players);
}
/**
* Called when a property on a player is changed.
* @param players The entire map of id -> player.
* @param player The player that had its property changed.
* @param key The name of the property that was changed.
* @param value The new value of the property.
*/
playerUpdated(players, player, key, value) {
if (player === this.selectedPlayer) {
this.drawProperties_(player.properties, this.playerPropertiesTable);
this.drawLog_();
}
if (key === 'error') {
player.playerState = 'errored';
} else if (
key === 'event' && value === 'kWebMediaPLayerDestroyed' &&
player.playerState !== 'errored') {
player.playerState = 'ended';
}
if ([
'url',
'frame_url',
'frame_title',
'audio_codec_name',
'video_codec_name',
'width',
'height',
'event',
'error',
].includes(key)) {
this.redrawPlayerList_(players);
}
}
createVideoCaptureFormatTable(formats) {
if (!formats || formats.length === 0) {
return document.createTextNode('No formats');
}
const table = document.createElement('table');
const thead = document.createElement('thead');
const theadRow = document.createElement('tr');
for (const key in formats[0]) {
const th = document.createElement('th');
th.appendChild(document.createTextNode(key));
theadRow.appendChild(th);
}
thead.appendChild(theadRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (let i = 0; i < formats.length; ++i) {
const tr = document.createElement('tr');
for (const key in formats[i]) {
const td = document.createElement('td');
td.appendChild(document.createTextNode(formats[i][key]));
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
table.classList.add('video-capture-formats-table');
return table;
}
redrawVideoCaptureCapabilities(videoCaptureCapabilities, keys) {
const copyButtonElement = $('video-capture-capabilities-copy-button');
copyButtonElement.onclick = function() {
this.renderClipboard(JSON.stringify(videoCaptureCapabilities, null, 2));
}.bind(this);
const videoTableBodyElement = $('video-capture-capabilities-tbody');
removeChildren(videoTableBodyElement);
for (const component in videoCaptureCapabilities) {
const tableRow = document.createElement('tr');
const device = videoCaptureCapabilities[component];
for (const i in keys) {
const value = device[keys[i]];
const tableCell = document.createElement('td');
let cellElement;
if ((typeof value) === (typeof[])) {
cellElement = this.createVideoCaptureFormatTable(value);
} else {
cellElement = document.createTextNode(
((typeof value) === 'undefined') ? 'n/a' : value);
}
tableCell.appendChild(cellElement);
tableRow.appendChild(tableCell);
}
videoTableBodyElement.appendChild(tableRow);
}
}
getAudioComponentName_(componentType, id) {
let baseName;
switch (componentType) {
case 0:
case 1:
baseName = 'Controller';
break;
case 2:
baseName = 'Stream';
break;
default:
baseName = 'UnknownType';
console.error('Unrecognized component type: ' + componentType);
break;
}
return baseName + ' ' + id;
}
getListElementForAudioComponent_(componentType) {
let listElement;
switch (componentType) {
case 0:
listElement = $('audio-input-controller-list');
break;
case 1:
listElement = $('audio-output-controller-list');
break;
case 2:
listElement = $('audio-output-stream-list');
break;
default:
console.error('Unrecognized component type: ' + componentType);
listElement = null;
break;
}
return listElement;
}
redrawAudioComponentList_(componentType, components) {
const listElement = this.getListElementForAudioComponent_(componentType);
if (!listElement) {
console.error(
'Failed to find list element for component type: ' + componentType);
return;
}
const fragment = document.createDocumentFragment();
for (const id in components) {
const component = components[id];
const treeItem = document.createElement('div');
treeItem.classList.add('tree-item');
treeItem.dataset.id = id;
treeItem.classList.add(ClientRendererCss.ACTIVE_PLAYER);
const treeItemHeader = document.createElement('div');
treeItemHeader.classList.add('tree-item-header');
treeItemHeader.textContent =
this.getAudioComponentName_(componentType, id);
treeItem.appendChild(treeItemHeader);
const children = document.createElement('div');
children.classList.add('tree-item-children');
treeItem.appendChild(children);
treeItemHeader.addEventListener('click', (e) => {
treeItem.classList.toggle('expanded');
this.selectAudioComponent_(componentType, id, component);
});
fragment.appendChild(treeItem);
}
removeChildren(listElement);
listElement.appendChild(fragment);
if (this.selectedAudioComponentType &&
this.selectedAudioComponentType === componentType &&
this.selectedAudioComponentId in components) {
// Re-select the selected component since the button was just recreated.
const element = listElement.querySelector(
`.tree-item[data-id="${this.selectedAudioComponentId}"]`);
if (element) {
element.classList.add('selected');
}
}
}
selectAudioComponent_(componentType, componentId, componentData) {
const audioWrapper = $('audio-component-list-wrapper');
if (audioWrapper) {
const previouslySelected =
audioWrapper.querySelector('.tree-item.selected');
if (previouslySelected) {
previouslySelected.classList.remove('selected');
}
}
const listElement = this.getListElementForAudioComponent_(componentType);
if (listElement) {
const element =
listElement.querySelector(`.tree-item[data-id="${componentId}"]`);
if (element) {
element.classList.add('selected');
}
}
this.selectedAudioComponentType = componentType;
this.selectedAudioComponentId = componentId;
this.selectedAudioCompontentData = componentData;
this.drawProperties_(componentData, this.audioPropertiesTable);
removeChildren(this.audioPropertyName);
this.audioPropertyName.appendChild(document.createTextNode(
this.getAudioComponentName_(componentType, componentId)));
}
redrawPlayerList_(players) {
this.players = players;
const fragment = document.createDocumentFragment();
for (const id in players) {
const player = players[id];
const p = player.properties;
const treeItem = document.createElement('div');
treeItem.classList.add('tree-item');
if (player.playerState === 'errored') {
treeItem.classList.add(ClientRendererCss.ERRORED_PLAYER);
} else if (player.playerState === 'ended') {
treeItem.classList.add(ClientRendererCss.ENDED_PLAYER);
} else {
treeItem.classList.add(ClientRendererCss.ACTIVE_PLAYER);
}
treeItem.dataset.id = id;
const treeItemHeader = document.createElement('div');
treeItemHeader.classList.add('tree-item-header');
treeItemHeader.classList.add('selectable-button');
const playerName = document.createElement('div');
playerName.classList.add('player-name');
const url = p.url || 'Player ' + player.id;
if (url.length > 64) {
playerName.textContent = url.substring(0, 61) + '...';
} else {
playerName.textContent = url;
}
playerName.title = url;
treeItemHeader.appendChild(playerName);
let lastEvent = '';
for (let i = player.allEvents.length - 1; i >= 0; i--) {
if (player.allEvents[i].key === 'event') {
lastEvent = player.allEvents[i].value;
break;
}
}
if (lastEvent) {
const playerFrame = document.createElement('div');
playerFrame.classList.add('player-frame');
playerFrame.textContent = lastEvent;
treeItemHeader.appendChild(playerFrame);
}
treeItem.appendChild(treeItemHeader);
const children = document.createElement('div');
children.classList.add('tree-item-children');
treeItem.appendChild(children);
treeItemHeader.addEventListener('click', (e) => {
treeItem.classList.toggle('expanded');
this.selectPlayer_(player);
});
fragment.appendChild(treeItem);
}
removeChildren(this.playerListElement);
this.playerListElement.appendChild(fragment);
if (this.selectedPlayer && this.selectedPlayer.id in players) {
// Re-select the selected player since the button was just recreated.
const element = this.playerListElement.querySelector(
`.tree-item[data-id="${this.selectedPlayer.id}"]`);
if (element) {
element.classList.add('selected');
}
}
}
selectPlayer_(player) {
if (window.innerWidth <= 768) {
$('main-container').classList.add('mobile-player-view-active');
}
document.body.classList.remove(ClientRendererCss.NO_PLAYERS_SELECTED);
const previouslySelected =
this.playerListElement.querySelector('.tree-item.selected');
if (previouslySelected) {
previouslySelected.classList.remove('selected');
}
const element = this.playerListElement.querySelector(
`.tree-item[data-id="${player.id}"]`);
if (element) {
element.classList.add('selected');
}
this.selectedPlayer = player;
this.selectedPlayerLogIndex = 0;
this.selectedAudioComponentType = null;
this.selectedAudioComponentId = null;
this.selectedAudioCompontentData = null;
this.drawProperties_(player.properties, this.playerPropertiesTable);
removeChildren(this.logTable);
removeChildren(this.graphElement);
this.drawLog_();
const titleElement = $('player-details-title');
if (titleElement) {
const playerName = player.properties.url || 'Player ' + player.id;
titleElement.textContent = playerName;
titleElement.title = playerName;
}
}
drawProperties_(propertyMap, propertiesTable) {
removeChildren(propertiesTable);
const sortedKeys = Object.keys(propertyMap).sort();
for (let i = 0; i < sortedKeys.length; ++i) {
const key = sortedKeys[i];
if (this.hiddenKeys.indexOf(key) >= 0) {
continue;
}
const value = propertyMap[key];
const row = propertiesTable.insertRow(-1);
const keyCell = row.insertCell(-1);
const valueCell = row.insertCell(-1);
keyCell.appendChild(document.createTextNode(key));
try {
if (key === 'kHlsBufferedRanges') {
throw new Error('Do Not Render As JSON');
}
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(value, null, 2);
valueCell.appendChild(pre);
} catch (e) {
valueCell.appendChild(document.createTextNode(JSON.stringify(value)));
}
}
}
appendEventToLog_(event) {
if (this.filterFunction(event.key)) {
const row = this.logTable.insertRow(-1);
row.classList.add('log-entry');
const timestampCell = row.insertCell(-1);
timestampCell.classList.add('log-timestamp');
timestampCell.textContent = millisecondsToString(event.time);
const propertyCell = row.insertCell(-1);
propertyCell.classList.add('log-property');
propertyCell.textContent = event.key;
const valueCell = row.insertCell(-1);
valueCell.classList.add('log-value');
try {
if (event.key === 'kHlsBufferedRanges') {
throw new Error('Do Not Render As JSON');
}
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(event.value, null, 2);
valueCell.appendChild(pre);
} catch (e) {
valueCell.appendChild(
document.createTextNode(JSON.stringify(event.value)));
}
if (event.key.toLowerCase().includes('error')) {
row.classList.add('log-error');
} else if (event.key.toLowerCase().includes('warning')) {
row.classList.add('log-warning');
}
}
}
drawLog_() {
const toDraw =
this.selectedPlayer.allEvents.slice(this.selectedPlayerLogIndex);
toDraw.forEach(this.appendEventToLog_.bind(this));
this.selectedPlayerLogIndex = this.selectedPlayer.allEvents.length;
}
saveLog_() {
const strippedPlayers = [];
for (const id in this.players) {
const p = this.players[id];
strippedPlayers.push({properties: p.properties, events: p.allEvents});
}
downloadLog(JSON.stringify(strippedPlayers, null, 2));
}
copyLog_() {
if (!this.selectedPlayer) {
return;
}
// Copy both properties and events for convenience since both are useful
// in bug reports.
const p = this.selectedPlayer;
const playerLog = {properties: p.properties, events: p.allEvents};
this.renderClipboard(JSON.stringify(playerLog, null, 2));
}
renderClipboard(string) {
navigator.clipboard.writeText(string);
}
onTextChange_(event) {
const text = this.filterText.value.toLowerCase();
const parts = text.split(',')
.map(function(part) {
return part.trim();
})
.filter(function(part) {
return part.trim().length > 0;
});
this.filterFunction = function(text) {
text = text.toLowerCase();
return parts.length === 0 || parts.some(function(part) {
return text.indexOf(part) !== -1;
});
};
if (this.selectedPlayer) {
removeChildren(this.logTable);
this.selectedPlayerLogIndex = 0;
this.drawLog_();
}
}
createAudioFocusSessionRow_(session) {
const template = $('audio-focus-session-row');
const span = template.content.querySelectorAll('span');
span[0].textContent = session.name;
span[1].textContent = session.owner;
span[2].textContent = session.state;
return document.importNode(template.content, true);
}
createCdmRow_(cdm) {
const template = $('cdm-row');
const clone = document.importNode(template.content, true);
const header = clone.querySelector('.cdm-header');
const tableBody = clone.querySelector('tbody');
header.textContent = cdm.key_system;
const addRow = (key, value) => {
const row = tableBody.insertRow(-1);
const keyCell = row.insertCell(-1);
const valueCell = row.insertCell(-1);
keyCell.textContent = key;
if (typeof value === 'object') {
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(value, null, 2);
valueCell.appendChild(pre);
} else {
valueCell.textContent = value;
}
};
addRow('Robustness', cdm.robustness);
addRow('Name', cdm.name);
addRow('Version', cdm.version);
addRow('Path', cdm.path);
addRow('Status', cdm.status);
addRow('Capabilities', cdm.capability);
return clone;
}
}