import videojs from 'video.js' import { secondsToTime } from '@shared/core-utils' import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../peertube-videojs-typings' import { bytes } from '../utils' interface StatsCardOptions extends videojs.ComponentOptions { videoUUID: string videoIsLive: boolean mode: 'webtorrent' | 'p2p-media-loader' p2pEnabled: boolean } interface PlayerNetworkInfo { downloadSpeed?: string uploadSpeed?: string totalDownloaded?: string totalUploaded?: string numPeers?: number averageBandwidth?: string downloadedFromServer?: string downloadedFromPeers?: string } const Component = videojs.getComponent('Component') class StatsCard extends Component { options_: StatsCardOptions container: HTMLDivElement list: HTMLDivElement closeButton: HTMLElement updateInterval: any mode: 'webtorrent' | 'p2p-media-loader' metadataStore: any = {} intervalMs = 300 playerNetworkInfo: PlayerNetworkInfo = {} createEl () { const container = super.createEl('div', { className: 'vjs-stats-content', innerHTML: this.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 this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { if (!data) return // HTTP fallback this.mode = 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(data.bandwidthEstimate).join(' ') + '/s' if (data.source === 'p2p-media-loader') { this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') } }) return container } toggle () { if (this.updateInterval) this.hide() else this.show() } show () { this.container.style.display = 'block' this.updateInterval = setInterval(async () => { try { const options = this.mode === 'p2p-media-loader' ? this.buildHLSOptions() : await this.buildWebTorrentOptions() // Default this.list.innerHTML = this.getListTemplate(options) } catch (err) { console.error('Cannot update stats.', err) clearInterval(this.updateInterval) } }, this.intervalMs) } hide () { clearInterval(this.updateInterval) this.container.style.display = 'none' } private buildHLSOptions () { const p2pMediaLoader = this.player_.p2pMediaLoader() const level = p2pMediaLoader.getCurrentLevel() const codecs = level?.videoCodec || level?.audioCodec ? `${level?.videoCodec || ''} / ${level?.audioCodec || ''}` : undefined const resolution = `${level?.height}p${level?.attrs['FRAME-RATE'] || ''}` const buffer = this.timeRangesToString(this.player().buffered()) let progress: number let latency: string if (this.options_.videoIsLive) { latency = secondsToTime(p2pMediaLoader.getLiveLatency()) } else { progress = this.player().bufferedPercent() } return { playerNetworkInfo: this.playerNetworkInfo, resolution, codecs, buffer, latency, progress } } private async buildWebTorrentOptions () { const videoFile = this.player_.webtorrent().getCurrentVideoFile() if (!this.metadataStore[videoFile.fileUrl]) { this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) } const metadata = this.metadataStore[videoFile.fileUrl] let colorSpace = 'unknown' let codecs = 'unknown' if (metadata?.streams[0]) { const stream = metadata.streams[0] colorSpace = stream['color_space'] !== 'unknown' ? stream['color_space'] : 'bt709' codecs = stream['codec_name'] || 'avc1' } const resolution = videoFile?.resolution.label + videoFile?.fps const buffer = this.timeRangesToString(this.player().buffered()) const progress = this.player_.webtorrent().getTorrent()?.progress return { playerNetworkInfo: this.playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer } } private getListTemplate (options: { playerNetworkInfo: PlayerNetworkInfo progress: number codecs: string resolution: string buffer: string latency?: string colorSpace?: string }) { const { playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer, latency } = options const player = this.player() 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 frames = `${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}` const duration = player.duration() let volume = `${Math.round(player.volume() * 100)}` if (player.muted()) volume += ' (muted)' const networkActivity = playerNetworkInfo.downloadSpeed ? `${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑` : undefined const totalTransferred = playerNetworkInfo.totalDownloaded ? `${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑` : undefined const downloadBreakdown = playerNetworkInfo.downloadedFromServer ? `${playerNetworkInfo.downloadedFromServer} from servers ยท ${playerNetworkInfo.downloadedFromPeers} from peers` : undefined const bufferProgress = progress !== undefined ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` : undefined return ` ${this.buildElement(player.localize('Player mode'), this.mode || 'HTTP')} ${this.buildElement(player.localize('P2P'), player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled'))} ${this.buildElement(player.localize('Video UUID'), this.options_.videoUUID)} ${this.buildElement(player.localize('Viewport / Frames'), frames)} ${this.buildElement(player.localize('Resolution'), resolution)} ${this.buildElement(player.localize('Volume'), volume)} ${this.buildElement(player.localize('Codecs'), codecs)} ${this.buildElement(player.localize('Color'), colorSpace)} ${this.buildElement(player.localize('Connection Speed'), playerNetworkInfo.averageBandwidth)} ${this.buildElement(player.localize('Network Activity'), networkActivity)} ${this.buildElement(player.localize('Total Transfered'), totalTransferred)} ${this.buildElement(player.localize('Download Breakdown'), downloadBreakdown)} ${this.buildElement(player.localize('Buffer Progress'), bufferProgress)} ${this.buildElement(player.localize('Buffer State'), buffer)} ${this.buildElement(player.localize('Live Latency'), latency)} ` } private getMainTemplate () { return `
` } private buildElement (label: string, value?: string) { if (!value) return '' return `
${label}
${value}
` } private timeRangesToString (r: videojs.TimeRange) { let result = '' for (let i = 0; i < r.length; i++) { const start = Math.floor(r.start(i)) const end = Math.floor(r.end(i)) result += `[${secondsToTime(start)}, ${secondsToTime(end)}] ` } return result } } videojs.registerComponent('StatsCard', StatsCard) export { StatsCard, StatsCardOptions }