mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-03 08:28:31 +00:00
Fixed: Bug with player stats metadata creating an error when the player object is unavailable.
474 lines
No EOL
12 KiB
JavaScript
474 lines
No EOL
12 KiB
JavaScript
'use strict';
|
|
|
|
// ============================================================================
|
|
// Channel Metadata
|
|
// ============================================================================
|
|
|
|
import {createElement, ClickOutside} from 'utilities/dom';
|
|
import {maybe_call} from 'utilities/object';
|
|
|
|
import {duration_to_string} from 'utilities/time';
|
|
|
|
import Tooltip from 'utilities/tooltip';
|
|
import Module from 'utilities/module';
|
|
|
|
export default class Metadata extends Module {
|
|
constructor(...args) {
|
|
super(...args);
|
|
|
|
this.inject('settings');
|
|
this.inject('i18n');
|
|
|
|
this.should_enable = true;
|
|
this.definitions = {};
|
|
|
|
this.settings.add('metadata.player-stats', {
|
|
default: false,
|
|
|
|
ui: {
|
|
path: 'Channel > Metadata >> Player',
|
|
title: 'Playback Statistics',
|
|
description: 'Show the current stream delay, with playback rate and dropped frames in the tooltip.',
|
|
component: 'setting-check-box'
|
|
},
|
|
|
|
changed: () => this.updateMetadata('player-stats')
|
|
});
|
|
|
|
this.settings.add('metadata.uptime', {
|
|
default: 1,
|
|
|
|
ui: {
|
|
path: 'Channel > Metadata >> Player',
|
|
title: 'Stream Uptime',
|
|
|
|
component: 'setting-select-box',
|
|
|
|
data: [
|
|
{value: 0, title: 'Disabled'},
|
|
{value: 1, title: 'Enabled'},
|
|
{value: 2, title: 'Enabled (with Seconds)'}
|
|
]
|
|
},
|
|
|
|
changed: () => this.updateMetadata('uptime')
|
|
});
|
|
|
|
|
|
this.definitions.uptime = {
|
|
refresh() { return this.settings.get('metadata.uptime') > 0 },
|
|
|
|
setup() {
|
|
const socket = this.resolve('socket'),
|
|
apollo = this.resolve('site.apollo'),
|
|
created_at = apollo.getFromQuery('ChannelPage_ChannelHeader', 'data.user.stream.createdAt');
|
|
|
|
if ( ! created_at )
|
|
return {};
|
|
|
|
const created = new Date(created_at),
|
|
now = Date.now() - socket._time_drift;
|
|
|
|
return {
|
|
created,
|
|
uptime: created ? Math.floor((now - created.getTime()) / 1000) : -1
|
|
}
|
|
},
|
|
|
|
order: 2,
|
|
icon: 'ffz-i-clock',
|
|
|
|
label(data) {
|
|
const setting = this.settings.get('metadata.uptime');
|
|
if ( ! setting || ! data.created )
|
|
return null;
|
|
|
|
return duration_to_string(data.uptime, false, false, false, setting !== 2);
|
|
},
|
|
|
|
tooltip(data) {
|
|
if ( ! data.created )
|
|
return null;
|
|
|
|
return `${this.i18n.t(
|
|
'metadata.uptime.tooltip',
|
|
'Stream Uptime'
|
|
)}<div class="pd-t-05">${this.i18n.t(
|
|
'metadata.uptime.since',
|
|
'(since %{since})',
|
|
{since: data.created.toLocaleString()}
|
|
)}</div>`;
|
|
}
|
|
}
|
|
|
|
this.definitions['player-stats'] = {
|
|
refresh() {
|
|
return this.settings.get('metadata.player-stats')
|
|
},
|
|
|
|
setup() {
|
|
const Player = this.resolve('site.player'),
|
|
socket = this.resolve('socket'),
|
|
player = Player.current;
|
|
|
|
let stats;
|
|
|
|
if ( ! player )
|
|
stats = null;
|
|
|
|
else if ( typeof player.getPlaybackStats === 'function' ) {
|
|
stats = player.getPlaybackStats();
|
|
|
|
} else if ( typeof player.getVideoInfo === 'function' ) {
|
|
const temp = player.getVideoInfo();
|
|
stats = {
|
|
backendVersion: maybe_call(player.getVersion, player),
|
|
bufferSize: temp.video_buffer_size,
|
|
displayResolution: `${temp.vid_display_width}x${temp.vid_display_height}`,
|
|
fps: temp.current_fps,
|
|
hlsLatencyBroadcaster: temp.hls_latency_broadcaster / 1000,
|
|
hlsLatencyEncoder: temp.hls_latency_encoder / 1000,
|
|
memoryUsage: `${temp.totalMemoryNumber} MB`,
|
|
playbackRate: temp.current_bitrate,
|
|
skippedFrames: temp.dropped_frames,
|
|
videoResolution: `${temp.vid_width}x${temp.vid_height}`
|
|
}
|
|
}
|
|
|
|
if ( ! stats )
|
|
return {stats};
|
|
|
|
let drift = 0;
|
|
|
|
if ( socket && socket.connected )
|
|
drift = socket._time_drift;
|
|
|
|
return {
|
|
stats,
|
|
drift,
|
|
delay: stats.hlsLatencyBroadcaster,
|
|
old: stats.hlsLatencyBroadcaster > 180
|
|
}
|
|
},
|
|
|
|
order: 3,
|
|
icon: 'ffz-i-gauge',
|
|
|
|
label(data) {
|
|
if ( ! this.settings.get('metadata.player-stats') || ! data.delay )
|
|
return null;
|
|
|
|
const delayed = data.drift > 5000 ? '(!) ' : '';
|
|
|
|
if ( data.old )
|
|
return `${delayed}${data.delay.toFixed(2)}s old`;
|
|
else
|
|
return `${delayed}${data.delay.toFixed(2)}s`;
|
|
},
|
|
|
|
color(data) {
|
|
const setting = this.settings.get('some.thing');
|
|
if ( setting == null || ! data.delay || data.old )
|
|
return;
|
|
|
|
if ( data.delay > (setting * 2) )
|
|
return '#ec1313';
|
|
|
|
else if ( data.delay > setting )
|
|
return '#fc7835';
|
|
},
|
|
|
|
tooltip(data) {
|
|
const delayed = data.drift > 5000 ?
|
|
`${this.i18n.t(
|
|
'metadata.player-stats.delay-warning',
|
|
'Your local clock seems to be off by roughly %{count} seconds, which could make this inaccurate.',
|
|
Math.round(data.drift / 10) / 100
|
|
)}<hr>` :
|
|
'';
|
|
|
|
if ( ! data.stats || ! data.delay )
|
|
return delayed + this.i18n.t('metadata.player-stats.latency-tip', 'Stream Latency');
|
|
|
|
const stats = data.stats,
|
|
video_info = this.i18n.t(
|
|
'metadata.player-stats.video-info',
|
|
'Video: %{videoResolution}p%{fps}\nPlayback Rate: %{playbackRate|number} Kbps\nDropped Frames:%{skippedFrames|number}',
|
|
stats
|
|
);
|
|
|
|
if ( data.old )
|
|
return `${delayed}${this.i18n.t(
|
|
'metadata.player-stats.video-tip',
|
|
'Video Information'
|
|
)}<div class="pd-t-05">${this.i18n.t(
|
|
'metadata.player-stats.broadcast-ago',
|
|
'Broadcast %{count}s Ago',
|
|
data.delay
|
|
)}</div><div class="pd-t-05">${video_info}</div>`;
|
|
|
|
return `${delayed}${this.i18n.t(
|
|
'metadata.player-stats.latency-tip',
|
|
'Stream Latency'
|
|
)}<div class="pd-t-05">${video_info}</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
get keys() {
|
|
return Object.keys(this.definitions);
|
|
}
|
|
|
|
|
|
updateMetadata(keys) {
|
|
const bar = this.resolve('site.channel_bar');
|
|
if ( bar ) {
|
|
for(const inst of bar.ChannelBar.instances)
|
|
bar.updateMetadata(inst, keys);
|
|
|
|
for(const inst of bar.HostBar.instances)
|
|
bar.updateMetadata(inst, keys);
|
|
}
|
|
}
|
|
|
|
|
|
async render(key, data, container, timers, refresh_fn) {
|
|
if ( timers[key] )
|
|
clearTimeout(timers[key]);
|
|
|
|
let el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
|
|
|
|
const def = this.definitions[key],
|
|
destroy = () => {
|
|
if ( el ) {
|
|
if ( el.tooltip )
|
|
el.tooltip.destroy();
|
|
|
|
if ( el.popper )
|
|
el.popper.destroy();
|
|
|
|
if ( el._ffz_destroy )
|
|
el._ffz_destroy();
|
|
|
|
el._ffz_destroy = el.tooltip = el.popper = null;
|
|
el.remove();
|
|
}
|
|
};
|
|
|
|
if ( ! def )
|
|
return destroy();
|
|
|
|
try {
|
|
// Process the data if a setup method is defined.
|
|
if ( def.setup )
|
|
data = await def.setup.call(this, data);
|
|
|
|
// Let's get refresh logic out of the way now.
|
|
const refresh = maybe_call(def.refresh, this, data);
|
|
if ( refresh )
|
|
timers[key] = setTimeout(
|
|
() => refresh_fn(key),
|
|
typeof refresh === 'number' ? refresh : 1000
|
|
);
|
|
|
|
|
|
// Grab the element again in case it changed, somehow.
|
|
el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
|
|
|
|
let stat, old_color;
|
|
|
|
const label = maybe_call(def.label, this, data);
|
|
|
|
if ( ! label )
|
|
return destroy();
|
|
|
|
const tooltip = maybe_call(def.tooltip, this, data),
|
|
order = maybe_call(def.order, this, data),
|
|
color = maybe_call(def.color, this, data) || '';
|
|
|
|
if ( ! el ) {
|
|
let icon = maybe_call(def.icon, this, data);
|
|
|
|
if ( def.popup || def.click ) {
|
|
let btn, popup;
|
|
let cls = maybe_call(def.button, this, data);
|
|
if ( typeof cls !== 'string' )
|
|
cls = `tw-button--${cls ? 'hollow' : 'text'}`;
|
|
|
|
const fix = cls === 'tw-button--text';
|
|
|
|
if ( typeof icon === 'string' )
|
|
icon = (<span class="tw-button__icon tw-button__icon--left"><figure class={icon} /></span>);
|
|
|
|
if ( def.popup && def.click ) {
|
|
el = (<span
|
|
class={`ffz-stat${fix ? ' ffz-fix-padding--left' : ''}`}
|
|
data-key={key}
|
|
tip_content={tooltip}
|
|
>
|
|
{btn = (<button class={`tw-button ${cls}`}>
|
|
{icon}
|
|
{stat = (<span class="ffz-stat-text tw-button__text" />)}
|
|
</button>)}
|
|
{popup = (<button class={`tw-button ${cls} ffz-stat-arrow`}>
|
|
<span class="tw-button__icon tw-pd-x-0">
|
|
<figure class="ffz-i-down-dir" />
|
|
</span>
|
|
</button>)}
|
|
</span>);
|
|
|
|
} else
|
|
btn = popup = el = (<button
|
|
class={`ffz-stat${fix ? ' ffz-fix-padding' : ''} tw-button ${cls}`}
|
|
data-key={key}
|
|
tip_content={tooltip}
|
|
>
|
|
{icon}
|
|
{stat = <span class="ffz-stat-text tw-button__text" />}
|
|
{def.popup && <span class="tw-button__icon tw-button__icon--right">
|
|
<figure class="ffz-i-down-dir" />
|
|
</span>}
|
|
</button>);
|
|
|
|
if ( def.click )
|
|
btn.addEventListener('click', e => {
|
|
if ( btn.disabled || btn.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
|
|
return false;
|
|
|
|
def.click.call(this, el._ffz_data, e, () => refresh_fn(key));
|
|
});
|
|
|
|
if ( def.popup )
|
|
popup.addEventListener('click', () => {
|
|
if ( popup.disabled || popup.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
|
|
return false;
|
|
|
|
if ( el._ffz_popup )
|
|
return el._ffz_destroy();
|
|
|
|
const destroy = el._ffz_destroy = () => {
|
|
if ( el._ffz_outside )
|
|
el._ffz_outside.destroy();
|
|
|
|
if ( el._ffz_popup ) {
|
|
const fp = el._ffz_popup;
|
|
el._ffz_popup = null;
|
|
fp.destroy();
|
|
}
|
|
|
|
el._ffz_destroy = el._ffz_outside = null;
|
|
};
|
|
|
|
const parent = document.body.querySelector('.twilight-root') || document.body,
|
|
tt = el._ffz_popup = new Tooltip(parent, el, {
|
|
logger: this.log,
|
|
manual: true,
|
|
html: true,
|
|
|
|
tooltipClass: 'ffz-metadata-balloon tw-balloon tw-block tw-border tw-elevation-1 tw-border-radius-small tw-c-background',
|
|
// Hide the arrow for now, until we re-do our CSS to make it render correctly.
|
|
arrowClass: 'tw-balloon__tail tw-overflow-hidden tw-absolute',
|
|
arrowInner: 'tw-balloon__tail-symbol tw-border-t tw-border-r tw-border-b tw-border-l tw-border-radius-small tw-c-background tw-absolute',
|
|
innerClass: 'tw-pd-1',
|
|
|
|
popper: {
|
|
placement: 'top-end',
|
|
modifiers: {
|
|
preventOverflow: {
|
|
boundariesElement: parent
|
|
},
|
|
flip: {
|
|
behavior: ['top', 'bottom', 'left', 'right']
|
|
}
|
|
}
|
|
},
|
|
content: (t, tip) => def.popup.call(this, el._ffz_data, tip, () => refresh_fn(key)),
|
|
onShow: (t, tip) =>
|
|
setTimeout(() => {
|
|
el._ffz_outside = new ClickOutside(tip.outer, destroy);
|
|
}),
|
|
onHide: destroy
|
|
});
|
|
|
|
tt._enter(el);
|
|
});
|
|
|
|
} else {
|
|
if ( typeof icon === 'string' )
|
|
icon = (<span class="tw-stat__icon"><figure class={icon} /></span>);
|
|
|
|
el = (<div
|
|
class="ffz-stat tw-stat"
|
|
data-key={key}
|
|
tip_content={tooltip}
|
|
>
|
|
{icon}
|
|
{stat = <span class="ffz-stat-text tw-stat__value" />}
|
|
</div>)
|
|
}
|
|
|
|
el._ffz_order = order;
|
|
|
|
if ( order != null )
|
|
el.style.order = order;
|
|
|
|
container.appendChild(el);
|
|
|
|
if ( def.tooltip ) {
|
|
const parent = document.body.querySelector('.twilight-root') || document.body;
|
|
el.tooltip = new Tooltip(container, el, {
|
|
logger: this.log,
|
|
live: false,
|
|
html: true,
|
|
content: () => el.tip_content,
|
|
onShow: (t, tip) => el.tip = tip,
|
|
onHide: () => el.tip = null,
|
|
popper: {
|
|
placement: 'bottom',
|
|
modifiers: {
|
|
flip: {
|
|
behavior: ['bottom', 'top']
|
|
},
|
|
preventOverflow: {
|
|
boundariesElement: parent
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
} else {
|
|
stat = el.querySelector('.ffz-stat-text');
|
|
old_color = el.dataset.color || '';
|
|
|
|
if ( el._ffz_order !== order )
|
|
el.style.order = el._ffz_order = order;
|
|
|
|
if ( el.tip_content !== tooltip ) {
|
|
el.tip_content = tooltip;
|
|
if ( el.tip )
|
|
el.tip.element.innerHTML = tooltip;
|
|
}
|
|
}
|
|
|
|
if ( old_color !== color )
|
|
el.dataset.color = el.style.color = color;
|
|
|
|
el._ffz_data = data;
|
|
stat.innerHTML = label;
|
|
|
|
if ( def.disabled !== undefined )
|
|
el.disabled = maybe_call(def.disabled, this, data);
|
|
|
|
} catch(err) {
|
|
this.log.capture(err, {
|
|
tags: {
|
|
metadata: key
|
|
}
|
|
});
|
|
this.log.error(`Error rendering metadata for ${key}`, err);
|
|
return destroy();
|
|
}
|
|
}
|
|
} |