]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - client/src/assets/player/shared/stats/stats-card.ts
Add playback metric endpoint sent to OTEL
[github/Chocobozzz/PeerTube.git] / client / src / assets / player / shared / stats / stats-card.ts
1 import videojs from 'video.js'
2 import { logger } from '@root-helpers/logger'
3 import { secondsToTime } from '@shared/core-utils'
4 import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../../types'
5 import { bytes } from '../common'
6
7 interface StatsCardOptions extends videojs.ComponentOptions {
8 videoUUID: string
9 videoIsLive: boolean
10 mode: 'webtorrent' | 'p2p-media-loader'
11 p2pEnabled: boolean
12 }
13
14 interface PlayerNetworkInfo {
15 downloadSpeed?: string
16 uploadSpeed?: string
17 totalDownloaded?: string
18 totalUploaded?: string
19 numPeers?: number
20 averageBandwidth?: string
21
22 downloadedFromServer?: string
23 downloadedFromPeers?: string
24 }
25
26 interface InfoElement {
27 root: HTMLElement
28 value: HTMLElement
29 }
30
31 const Component = videojs.getComponent('Component')
32 class StatsCard extends Component {
33 options_: StatsCardOptions
34
35 updateInterval: any
36
37 mode: 'webtorrent' | 'p2p-media-loader'
38
39 metadataStore: any = {}
40
41 intervalMs = 300
42 playerNetworkInfo: PlayerNetworkInfo = {}
43
44 private containerEl: HTMLDivElement
45 private infoListEl: HTMLDivElement
46
47 private playerMode: InfoElement
48 private p2p: InfoElement
49 private uuid: InfoElement
50 private viewport: InfoElement
51 private resolution: InfoElement
52 private volume: InfoElement
53 private codecs: InfoElement
54 private color: InfoElement
55 private connection: InfoElement
56
57 private network: InfoElement
58 private transferred: InfoElement
59 private download: InfoElement
60
61 private bufferProgress: InfoElement
62 private bufferState: InfoElement
63
64 private liveLatency: InfoElement
65
66 createEl () {
67 this.containerEl = videojs.dom.createEl('div', {
68 className: 'vjs-stats-content'
69 }) as HTMLDivElement
70 this.containerEl.style.display = 'none'
71
72 this.infoListEl = videojs.dom.createEl('div', {
73 className: 'vjs-stats-list'
74 }) as HTMLDivElement
75
76 const closeButton = videojs.dom.createEl('button', {
77 className: 'vjs-stats-close',
78 tabindex: '0',
79 title: 'Close stats',
80 innerText: '[x]'
81 }, { 'aria-label': 'Close stats' }) as HTMLElement
82 closeButton.onclick = () => this.hide()
83
84 this.containerEl.appendChild(closeButton)
85 this.containerEl.appendChild(this.infoListEl)
86
87 this.populateInfoBlocks()
88
89 this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => {
90 if (!data) return // HTTP fallback
91
92 this.mode = data.source
93
94 const p2pStats = data.p2p
95 const httpStats = data.http
96
97 this.playerNetworkInfo.downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed).join(' ')
98 this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed).join(' ')
99 this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ')
100 this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded).join(' ')
101 this.playerNetworkInfo.numPeers = p2pStats.numPeers
102 this.playerNetworkInfo.averageBandwidth = bytes(data.bandwidthEstimate).join(' ') + '/s'
103
104 if (data.source === 'p2p-media-loader') {
105 this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ')
106 this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ')
107 }
108 })
109
110 return this.containerEl
111 }
112
113 toggle () {
114 if (this.updateInterval) this.hide()
115 else this.show()
116 }
117
118 show () {
119 this.containerEl.style.display = 'block'
120
121 this.updateInterval = setInterval(async () => {
122 try {
123 const options = this.mode === 'p2p-media-loader'
124 ? this.buildHLSOptions()
125 : await this.buildWebTorrentOptions() // Default
126
127 this.populateInfoValues(options)
128 } catch (err) {
129 logger.error('Cannot update stats.', err)
130 clearInterval(this.updateInterval)
131 }
132 }, this.intervalMs)
133 }
134
135 hide () {
136 clearInterval(this.updateInterval)
137 this.containerEl.style.display = 'none'
138 }
139
140 private buildHLSOptions () {
141 const p2pMediaLoader = this.player_.p2pMediaLoader()
142 const level = p2pMediaLoader.getCurrentLevel()
143
144 const codecs = level?.videoCodec || level?.audioCodec
145 ? `${level?.videoCodec || ''} / ${level?.audioCodec || ''}`
146 : undefined
147
148 const resolution = `${level?.height}p${level?.attrs['FRAME-RATE'] || ''}`
149 const buffer = this.timeRangesToString(this.player().buffered())
150
151 let progress: number
152 let latency: string
153
154 if (this.options_.videoIsLive) {
155 latency = secondsToTime(p2pMediaLoader.getLiveLatency())
156 } else {
157 progress = this.player().bufferedPercent()
158 }
159
160 return {
161 playerNetworkInfo: this.playerNetworkInfo,
162 resolution,
163 codecs,
164 buffer,
165 latency,
166 progress
167 }
168 }
169
170 private async buildWebTorrentOptions () {
171 const videoFile = this.player_.webtorrent().getCurrentVideoFile()
172
173 if (!this.metadataStore[videoFile.fileUrl]) {
174 this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json())
175 }
176
177 const metadata = this.metadataStore[videoFile.fileUrl]
178
179 let colorSpace = 'unknown'
180 let codecs = 'unknown'
181
182 if (metadata?.streams[0]) {
183 const stream = metadata.streams[0]
184
185 colorSpace = stream['color_space'] !== 'unknown'
186 ? stream['color_space']
187 : 'bt709'
188
189 codecs = stream['codec_name'] || 'avc1'
190 }
191
192 const resolution = videoFile?.resolution.label + videoFile?.fps
193 const buffer = this.timeRangesToString(this.player().buffered())
194 const progress = this.player_.webtorrent().getTorrent()?.progress
195
196 return {
197 playerNetworkInfo: this.playerNetworkInfo,
198 progress,
199 colorSpace,
200 codecs,
201 resolution,
202 buffer
203 }
204 }
205
206 private populateInfoBlocks () {
207 this.playerMode = this.buildInfoRow(this.player().localize('Player mode'))
208 this.p2p = this.buildInfoRow(this.player().localize('P2P'))
209 this.uuid = this.buildInfoRow(this.player().localize('Video UUID'))
210 this.viewport = this.buildInfoRow(this.player().localize('Viewport / Frames'))
211 this.resolution = this.buildInfoRow(this.player().localize('Resolution'))
212 this.volume = this.buildInfoRow(this.player().localize('Volume'))
213 this.codecs = this.buildInfoRow(this.player().localize('Codecs'))
214 this.color = this.buildInfoRow(this.player().localize('Color'))
215 this.connection = this.buildInfoRow(this.player().localize('Connection Speed'))
216
217 this.network = this.buildInfoRow(this.player().localize('Network Activity'))
218 this.transferred = this.buildInfoRow(this.player().localize('Total Transfered'))
219 this.download = this.buildInfoRow(this.player().localize('Download Breakdown'))
220
221 this.bufferProgress = this.buildInfoRow(this.player().localize('Buffer Progress'))
222 this.bufferState = this.buildInfoRow(this.player().localize('Buffer State'))
223
224 this.liveLatency = this.buildInfoRow(this.player().localize('Live Latency'))
225
226 this.infoListEl.appendChild(this.playerMode.root)
227 this.infoListEl.appendChild(this.p2p.root)
228 this.infoListEl.appendChild(this.uuid.root)
229 this.infoListEl.appendChild(this.viewport.root)
230 this.infoListEl.appendChild(this.resolution.root)
231 this.infoListEl.appendChild(this.volume.root)
232 this.infoListEl.appendChild(this.codecs.root)
233 this.infoListEl.appendChild(this.color.root)
234 this.infoListEl.appendChild(this.connection.root)
235 this.infoListEl.appendChild(this.network.root)
236 this.infoListEl.appendChild(this.transferred.root)
237 this.infoListEl.appendChild(this.download.root)
238 this.infoListEl.appendChild(this.bufferProgress.root)
239 this.infoListEl.appendChild(this.bufferState.root)
240 this.infoListEl.appendChild(this.liveLatency.root)
241 }
242
243 private populateInfoValues (options: {
244 playerNetworkInfo: PlayerNetworkInfo
245 progress: number
246 codecs: string
247 resolution: string
248 buffer: string
249
250 latency?: string
251 colorSpace?: string
252 }) {
253 const { playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer, latency } = options
254 const player = this.player()
255
256 const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality()
257 const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
258 const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
259 const pr = (window.devicePixelRatio || 1).toFixed(2)
260 const frames = `${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}`
261
262 const duration = player.duration()
263
264 let volume = `${Math.round(player.volume() * 100)}`
265 if (player.muted()) volume += ' (muted)'
266
267 const networkActivity = playerNetworkInfo.downloadSpeed
268 ? `${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑`
269 : undefined
270
271 const totalTransferred = playerNetworkInfo.totalDownloaded
272 ? `${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑`
273 : undefined
274 const downloadBreakdown = playerNetworkInfo.downloadedFromServer
275 ? `${playerNetworkInfo.downloadedFromServer} from servers ยท ${playerNetworkInfo.downloadedFromPeers} from peers`
276 : undefined
277
278 const bufferProgress = progress !== undefined
279 ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)`
280 : undefined
281
282 this.setInfoValue(this.playerMode, this.mode || 'HTTP')
283 this.setInfoValue(this.p2p, player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled'))
284 this.setInfoValue(this.uuid, this.options_.videoUUID)
285
286 this.setInfoValue(this.viewport, frames)
287 this.setInfoValue(this.resolution, resolution)
288 this.setInfoValue(this.volume, volume)
289 this.setInfoValue(this.codecs, codecs)
290 this.setInfoValue(this.color, colorSpace)
291 this.setInfoValue(this.connection, playerNetworkInfo.averageBandwidth)
292
293 this.setInfoValue(this.network, networkActivity)
294 this.setInfoValue(this.transferred, totalTransferred)
295 this.setInfoValue(this.download, downloadBreakdown)
296
297 this.setInfoValue(this.bufferProgress, bufferProgress)
298 this.setInfoValue(this.bufferState, buffer)
299
300 this.setInfoValue(this.liveLatency, latency)
301 }
302
303 private setInfoValue (el: InfoElement, value: string) {
304 if (!value) {
305 el.root.style.display = 'none'
306 return
307 }
308
309 el.root.style.display = 'block'
310
311 if (el.value.innerHTML === value) return
312 el.value.innerHTML = value
313 }
314
315 private buildInfoRow (labelText: string, valueHTML?: string) {
316 const root = videojs.dom.createEl('div') as HTMLElement
317 root.style.display = 'none'
318
319 const label = videojs.dom.createEl('div', { innerText: labelText }) as HTMLElement
320 const value = videojs.dom.createEl('span', { innerHTML: valueHTML }) as HTMLElement
321
322 root.appendChild(label)
323 root.appendChild(value)
324
325 return { root, value }
326 }
327
328 private timeRangesToString (r: videojs.TimeRange) {
329 let result = ''
330
331 for (let i = 0; i < r.length; i++) {
332 const start = Math.floor(r.start(i))
333 const end = Math.floor(r.end(i))
334
335 result += `[${secondsToTime(start)}, ${secondsToTime(end)}] `
336 }
337
338 return result
339 }
340 }
341
342 videojs.registerComponent('StatsCard', StatsCard)
343
344 export {
345 StatsCard,
346 StatsCardOptions
347 }