]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - 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
CommitLineData
ff563914 1import videojs from 'video.js'
42b40636 2import { logger } from '@root-helpers/logger'
15a7eafb 3import { secondsToTime } from '@shared/core-utils'
57d65032
C
4import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../../types'
5import { bytes } from '../common'
ff563914
RK
6
7interface StatsCardOptions extends videojs.ComponentOptions {
4e11d8f3
C
8 videoUUID: string
9 videoIsLive: boolean
10 mode: 'webtorrent' | 'p2p-media-loader'
95765067 11 p2pEnabled: boolean
ff563914
RK
12}
13
4e11d8f3
C
14interface PlayerNetworkInfo {
15 downloadSpeed?: string
16 uploadSpeed?: string
17 totalDownloaded?: string
18 totalUploaded?: string
19 numPeers?: number
20 averageBandwidth?: string
ff563914 21
4e11d8f3
C
22 downloadedFromServer?: string
23 downloadedFromPeers?: string
ff563914
RK
24}
25
68e72ba9
C
26interface InfoElement {
27 root: HTMLElement
28 value: HTMLElement
29}
30
ff563914
RK
31const Component = videojs.getComponent('Component')
32class StatsCard extends Component {
33 options_: StatsCardOptions
4e11d8f3 34
4e11d8f3
C
35 updateInterval: any
36
37 mode: 'webtorrent' | 'p2p-media-loader'
38
39 metadataStore: any = {}
40
41 intervalMs = 300
42 playerNetworkInfo: PlayerNetworkInfo = {}
ff563914 43
68e72ba9
C
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
ff563914 66 createEl () {
68e72ba9
C
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'
ff563914 74 }) as HTMLDivElement
ff563914 75
68e72ba9
C
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()
ff563914 83
68e72ba9
C
84 this.containerEl.appendChild(closeButton)
85 this.containerEl.appendChild(this.infoListEl)
86
87 this.populateInfoBlocks()
ff563914 88
4e11d8f3 89 this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => {
ff563914
RK
90 if (!data) return // HTTP fallback
91
4e11d8f3 92 this.mode = data.source
ff563914
RK
93
94 const p2pStats = data.p2p
95 const httpStats = data.http
96
97 this.playerNetworkInfo.downloadSpeed = bytes(p2pStats.downloadSpeed + httpStats.downloadSpeed).join(' ')
fd3c2e87 98 this.playerNetworkInfo.uploadSpeed = bytes(p2pStats.uploadSpeed).join(' ')
ff563914 99 this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ')
fd3c2e87 100 this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded).join(' ')
ff563914 101 this.playerNetworkInfo.numPeers = p2pStats.numPeers
4e11d8f3 102 this.playerNetworkInfo.averageBandwidth = bytes(data.bandwidthEstimate).join(' ') + '/s'
ff563914
RK
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
68e72ba9 110 return this.containerEl
ff563914
RK
111 }
112
113 toggle () {
9df52d66
C
114 if (this.updateInterval) this.hide()
115 else this.show()
ff563914
RK
116 }
117
4e11d8f3 118 show () {
68e72ba9
C
119 this.containerEl.style.display = 'block'
120
4e11d8f3 121 this.updateInterval = setInterval(async () => {
ff563914 122 try {
db0159c7 123 const options = this.mode === 'p2p-media-loader'
98ab5dc8 124 ? this.buildHLSOptions()
db0159c7 125 : await this.buildWebTorrentOptions() // Default
4e11d8f3 126
68e72ba9 127 this.populateInfoValues(options)
4e11d8f3 128 } catch (err) {
42b40636 129 logger.error('Cannot update stats.', err)
4e11d8f3 130 clearInterval(this.updateInterval)
ff563914 131 }
4e11d8f3 132 }, this.intervalMs)
ff563914
RK
133 }
134
135 hide () {
4e11d8f3 136 clearInterval(this.updateInterval)
68e72ba9 137 this.containerEl.style.display = 'none'
ff563914 138 }
4e11d8f3 139
98ab5dc8 140 private buildHLSOptions () {
4e11d8f3
C
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
68e72ba9
C
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: {
4e11d8f3
C
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
b76db2ff 264 let volume = `${Math.round(player.volume() * 100)}`
4e11d8f3
C
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
95765067 275 ? `${playerNetworkInfo.downloadedFromServer} from servers ยท ${playerNetworkInfo.downloadedFromPeers} from peers`
4e11d8f3
C
276 : undefined
277
278 const bufferProgress = progress !== undefined
279 ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)`
280 : undefined
281
68e72ba9
C
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)
4e11d8f3 285
68e72ba9
C
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)
4e11d8f3 292
68e72ba9
C
293 this.setInfoValue(this.network, networkActivity)
294 this.setInfoValue(this.transferred, totalTransferred)
295 this.setInfoValue(this.download, downloadBreakdown)
4e11d8f3 296
68e72ba9
C
297 this.setInfoValue(this.bufferProgress, bufferProgress)
298 this.setInfoValue(this.bufferState, buffer)
4e11d8f3 299
68e72ba9
C
300 this.setInfoValue(this.liveLatency, latency)
301 }
4e11d8f3 302
68e72ba9
C
303 private setInfoValue (el: InfoElement, value: string) {
304 if (!value) {
305 el.root.style.display = 'none'
306 return
307 }
4e11d8f3 308
68e72ba9 309 el.root.style.display = 'block'
4e11d8f3 310
68e72ba9
C
311 if (el.value.innerHTML === value) return
312 el.value.innerHTML = value
4e11d8f3
C
313 }
314
68e72ba9
C
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
4e11d8f3 321
68e72ba9
C
322 root.appendChild(label)
323 root.appendChild(value)
4e11d8f3 324
68e72ba9 325 return { root, value }
4e11d8f3
C
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 }
ff563914
RK
340}
341
342videojs.registerComponent('StatsCard', StatsCard)
343
344export {
345 StatsCard,
346 StatsCardOptions
347}