): any
+
qualityLevels (): QualityLevels
textTracks (): TextTrackList & {
diff --git a/client/src/assets/player/stats/stats-card.ts b/client/src/assets/player/stats/stats-card.ts
new file mode 100644
index 000000000..278899b72
--- /dev/null
+++ b/client/src/assets/player/stats/stats-card.ts
@@ -0,0 +1,184 @@
+import videojs from 'video.js'
+import { PlayerNetworkInfo } from '../peertube-videojs-typings'
+import { getAverageBandwidthInStore } from '../peertube-player-local-storage'
+import { bytes } from '../utils'
+
+interface StatsCardOptions extends videojs.ComponentOptions {
+ videoUUID?: string,
+ videoIsLive?: boolean,
+ mode?: 'webtorrent' | 'p2p-media-loader'
+}
+
+function getListTemplate (
+ options: StatsCardOptions,
+ player: videojs.Player,
+ args: {
+ playerNetworkInfo?: any
+ videoFile?: any
+ progress?: number
+ }) {
+ const { playerNetworkInfo, videoFile, progress } = args
+
+ const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality()
+ const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
+ const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
+ const pr = (window.devicePixelRatio || 1).toFixed(2)
+ const colorspace = videoFile?.metadata?.streams[0]['color_space'] !== "unknown"
+ ? videoFile?.metadata?.streams[0]['color_space']
+ : undefined
+
+ return `
+
+
${player.localize('Video UUID')}
+
${options.videoUUID || ''}
+
+
+
Viewport / ${player.localize('Frames')}
+
${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}
+
+
+
${player.localize('Resolution')}
+
${videoFile?.resolution.label + videoFile?.fps}
+
+
+
${player.localize('Volume')}
+
${~~(player.volume() * 100)}%${player.muted() ? ' (muted)' : ''}
+
+
+
${player.localize('Codecs')}
+
${videoFile?.metadata?.streams[0]['codec_name'] || 'avc1'}
+
+
+
${player.localize('Color')}
+
${colorspace || 'bt709'}
+
+
+
${player.localize('Connection Speed')}
+
${playerNetworkInfo.averageBandwidth}
+
+
+
${player.localize('Network Activity')}
+
${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑
+
+
+
${player.localize('Total Transfered')}
+
${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑
+
+
+
${player.localize('Download Breakdown')}
+
${playerNetworkInfo.downloadedFromServer} from server ยท ${playerNetworkInfo.downloadedFromPeers} from peers
+
+
+
${player.localize('Buffer Health')}
+
${(progress * 100).toFixed(1)}% (${(progress * videoFile?.metadata?.format.duration).toFixed(1)}s)
+
+
+
${player.localize('Live Latency')}
+
+
+ `
+}
+
+function getMainTemplate () {
+ return `
+
+
+ `
+}
+
+const Component = videojs.getComponent('Component')
+class StatsCard extends Component {
+ options_: StatsCardOptions
+ container: HTMLDivElement
+ list: HTMLDivElement
+ closeButton: HTMLElement
+ update: any
+ source: any
+
+ interval = 300
+ playerNetworkInfo: any = {}
+ statsForNerdsEvents = new videojs.EventTarget()
+
+ constructor (player: videojs.Player, options: StatsCardOptions) {
+ super(player, options)
+ }
+
+ createEl () {
+ const container = super.createEl('div', {
+ className: 'vjs-stats-content',
+ innerHTML: getMainTemplate()
+ }) as HTMLDivElement
+ this.container = container
+ this.container.style.display = 'none'
+
+ this.closeButton = this.container.getElementsByClassName('vjs-stats-close')[0] as HTMLElement
+ this.closeButton.onclick = () => this.hide()
+
+ this.list = this.container.getElementsByClassName('vjs-stats-list')[0] as HTMLDivElement
+
+ console.log(this.player_.qualityLevels())
+
+ this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => {
+ if (!data) return // HTTP fallback
+
+ this.source = data.source
+
+ const p2pStats = data.p2p
+ const httpStats = data.http
+
+ this.playerNetworkInfo.downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed).join(' ')
+ this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed + httpStats.uploadSpeed).join(' ')
+ this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ')
+ this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded).join(' ')
+ this.playerNetworkInfo.numPeers = p2pStats.numPeers
+ this.playerNetworkInfo.averageBandwidth = bytes(getAverageBandwidthInStore() || p2pStats.downloaded + httpStats.downloaded).join(' ')
+
+ if (data.source === 'p2p-media-loader') {
+ this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ')
+ this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ')
+ }
+ })
+
+ return container
+ }
+
+ toggle () {
+ this.update
+ ? this.hide()
+ : this.show()
+ }
+
+ show (options?: StatsCardOptions) {
+ if (options) this.options_ = options
+
+ let metadata = {}
+
+ this.container.style.display = 'block'
+ this.update = setInterval(async () => {
+ try {
+ if (this.source === 'webtorrent') {
+ const progress = this.player_.webtorrent().getTorrent()?.progress
+ const videoFile = this.player_.webtorrent().getCurrentVideoFile()
+ videoFile.metadata = metadata[videoFile.fileUrl] = videoFile.metadata || metadata[videoFile.fileUrl] || videoFile.metadataUrl && await fetch(videoFile.metadataUrl).then(res => res.json())
+ this.list.innerHTML = getListTemplate(this.options_, this.player_, { playerNetworkInfo: this.playerNetworkInfo, videoFile, progress })
+ } else {
+ this.list.innerHTML = getListTemplate(this.options_, this.player_, { playerNetworkInfo: this.playerNetworkInfo })
+ }
+ } catch (e) {
+ clearInterval(this.update)
+ }
+ }, this.interval)
+ }
+
+ hide () {
+ clearInterval(this.update)
+ this.container.style.display = 'none'
+ }
+}
+
+videojs.registerComponent('StatsCard', StatsCard)
+
+export {
+ StatsCard,
+ StatsCardOptions
+}
diff --git a/client/src/assets/player/stats/stats-plugin.ts b/client/src/assets/player/stats/stats-plugin.ts
new file mode 100644
index 000000000..3402e7861
--- /dev/null
+++ b/client/src/assets/player/stats/stats-plugin.ts
@@ -0,0 +1,31 @@
+import videojs from 'video.js'
+import { StatsCard, StatsCardOptions } from './stats-card'
+
+const Plugin = videojs.getPlugin('plugin')
+
+class StatsForNerdsPlugin extends Plugin {
+ private statsCard: StatsCard
+
+ constructor (player: videojs.Player, options: Partial = {}) {
+ const settings = {
+ ...options
+ }
+
+ super(player)
+
+ this.player.ready(() => {
+ player.addClass('vjs-stats-for-nerds')
+ })
+
+ this.statsCard = new StatsCard(player, options)
+
+ player.addChild(this.statsCard, settings)
+ }
+
+ show (options?: StatsCardOptions) {
+ this.statsCard.show(options)
+ }
+}
+
+videojs.registerPlugin('stats', StatsForNerdsPlugin)
+export { StatsForNerdsPlugin }
diff --git a/client/src/sass/player/context-menu.scss b/client/src/sass/player/context-menu.scss
index df78916c6..6bc66af0c 100644
--- a/client/src/sass/player/context-menu.scss
+++ b/client/src/sass/player/context-menu.scss
@@ -8,7 +8,7 @@ $context-menu-width: 350px;
.video-js .vjs-contextmenu-ui-menu {
position: absolute;
- background-color: rgba(0, 0, 0, 0.5);
+ background-color: $primary-background-color;
padding: 8px 0;
border-radius: 4px;
width: $context-menu-width;
@@ -42,7 +42,7 @@ $context-menu-width: 350px;
mask-size: cover;
margin-right: 0.8rem !important;
- $icons: 'link-2', 'repeat', 'code', 'tick-white';
+ $icons: 'link-2', 'repeat', 'code', 'tick-white', 'info';
@each $icon in $icons {
&[class$="-#{$icon}"] {
diff --git a/client/src/sass/player/index.scss b/client/src/sass/player/index.scss
index fe92ce5e0..502ee33ff 100644
--- a/client/src/sass/player/index.scss
+++ b/client/src/sass/player/index.scss
@@ -6,3 +6,4 @@
@import './upnext';
@import './bezels.scss';
@import './playlist.scss';
+@import './stats.scss';
diff --git a/client/src/sass/player/stats.scss b/client/src/sass/player/stats.scss
new file mode 100644
index 000000000..953f6032a
--- /dev/null
+++ b/client/src/sass/player/stats.scss
@@ -0,0 +1,42 @@
+@import './_player-variables';
+
+$stats-width: 420px;
+$contextmenu-background-color: rgba(0, 0, 0, 0.6);
+
+.video-js {
+
+ .vjs-stats-content {
+ position: absolute;
+ background-color: $contextmenu-background-color;
+ padding: 5px 0;
+ border-radius: 4px;
+ width: $stats-width;
+ min-width: 27em;
+ max-width: calc(100vw - 20px);
+ left: 10px;
+ top: 10px;
+ z-index: 64;
+ font-size: 12px;
+ line-height: 1.2;
+
+ @include transition(opacity 0.1s);
+ }
+
+ .vjs-stats-close {
+ cursor: pointer;
+ position: absolute;
+ right: 3px;
+ top: 3px;
+ padding: 0;
+ }
+
+ .vjs-stats-list > div > div {
+ display: inline-block;
+ font-weight: 600;
+ padding: 0 .5em;
+ text-align: right;
+ width: 11.5em;
+ white-space: nowrap;
+ }
+
+}