]>
Commit | Line | Data |
---|---|---|
ff563914 | 1 | import videojs from 'video.js' |
42b40636 | 2 | import { logger } from '@root-helpers/logger' |
15a7eafb | 3 | import { secondsToTime } from '@shared/core-utils' |
57d65032 C |
4 | import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../../types' |
5 | import { bytes } from '../common' | |
ff563914 RK |
6 | |
7 | interface 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 |
14 | interface 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 |
26 | interface InfoElement { |
27 | root: HTMLElement | |
28 | value: HTMLElement | |
29 | } | |
30 | ||
ff563914 RK |
31 | const Component = videojs.getComponent('Component') |
32 | class 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 | ||
37229de2 | 148 | const resolution = level?.height |
7804e577 | 149 | ? `${level.height}p${level?.attrs['FRAME-RATE'] || ''}` |
37229de2 C |
150 | : undefined |
151 | ||
4e11d8f3 C |
152 | const buffer = this.timeRangesToString(this.player().buffered()) |
153 | ||
154 | let progress: number | |
155 | let latency: string | |
156 | ||
157 | if (this.options_.videoIsLive) { | |
158 | latency = secondsToTime(p2pMediaLoader.getLiveLatency()) | |
159 | } else { | |
160 | progress = this.player().bufferedPercent() | |
161 | } | |
162 | ||
163 | return { | |
164 | playerNetworkInfo: this.playerNetworkInfo, | |
165 | resolution, | |
166 | codecs, | |
167 | buffer, | |
168 | latency, | |
169 | progress | |
170 | } | |
171 | } | |
172 | ||
173 | private async buildWebTorrentOptions () { | |
174 | const videoFile = this.player_.webtorrent().getCurrentVideoFile() | |
175 | ||
176 | if (!this.metadataStore[videoFile.fileUrl]) { | |
177 | this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) | |
178 | } | |
179 | ||
180 | const metadata = this.metadataStore[videoFile.fileUrl] | |
181 | ||
182 | let colorSpace = 'unknown' | |
183 | let codecs = 'unknown' | |
184 | ||
d97d36b4 | 185 | if (metadata?.streams?.[0]) { |
4e11d8f3 C |
186 | const stream = metadata.streams[0] |
187 | ||
188 | colorSpace = stream['color_space'] !== 'unknown' | |
189 | ? stream['color_space'] | |
190 | : 'bt709' | |
191 | ||
192 | codecs = stream['codec_name'] || 'avc1' | |
193 | } | |
194 | ||
195 | const resolution = videoFile?.resolution.label + videoFile?.fps | |
d97d36b4 | 196 | const buffer = this.timeRangesToString(this.player_.buffered()) |
4e11d8f3 C |
197 | const progress = this.player_.webtorrent().getTorrent()?.progress |
198 | ||
199 | return { | |
200 | playerNetworkInfo: this.playerNetworkInfo, | |
201 | progress, | |
202 | colorSpace, | |
203 | codecs, | |
204 | resolution, | |
205 | buffer | |
206 | } | |
207 | } | |
208 | ||
68e72ba9 C |
209 | private populateInfoBlocks () { |
210 | this.playerMode = this.buildInfoRow(this.player().localize('Player mode')) | |
211 | this.p2p = this.buildInfoRow(this.player().localize('P2P')) | |
212 | this.uuid = this.buildInfoRow(this.player().localize('Video UUID')) | |
213 | this.viewport = this.buildInfoRow(this.player().localize('Viewport / Frames')) | |
214 | this.resolution = this.buildInfoRow(this.player().localize('Resolution')) | |
215 | this.volume = this.buildInfoRow(this.player().localize('Volume')) | |
216 | this.codecs = this.buildInfoRow(this.player().localize('Codecs')) | |
217 | this.color = this.buildInfoRow(this.player().localize('Color')) | |
218 | this.connection = this.buildInfoRow(this.player().localize('Connection Speed')) | |
219 | ||
220 | this.network = this.buildInfoRow(this.player().localize('Network Activity')) | |
221 | this.transferred = this.buildInfoRow(this.player().localize('Total Transfered')) | |
222 | this.download = this.buildInfoRow(this.player().localize('Download Breakdown')) | |
223 | ||
224 | this.bufferProgress = this.buildInfoRow(this.player().localize('Buffer Progress')) | |
225 | this.bufferState = this.buildInfoRow(this.player().localize('Buffer State')) | |
226 | ||
227 | this.liveLatency = this.buildInfoRow(this.player().localize('Live Latency')) | |
228 | ||
229 | this.infoListEl.appendChild(this.playerMode.root) | |
230 | this.infoListEl.appendChild(this.p2p.root) | |
231 | this.infoListEl.appendChild(this.uuid.root) | |
232 | this.infoListEl.appendChild(this.viewport.root) | |
233 | this.infoListEl.appendChild(this.resolution.root) | |
234 | this.infoListEl.appendChild(this.volume.root) | |
235 | this.infoListEl.appendChild(this.codecs.root) | |
236 | this.infoListEl.appendChild(this.color.root) | |
237 | this.infoListEl.appendChild(this.connection.root) | |
238 | this.infoListEl.appendChild(this.network.root) | |
239 | this.infoListEl.appendChild(this.transferred.root) | |
240 | this.infoListEl.appendChild(this.download.root) | |
241 | this.infoListEl.appendChild(this.bufferProgress.root) | |
242 | this.infoListEl.appendChild(this.bufferState.root) | |
243 | this.infoListEl.appendChild(this.liveLatency.root) | |
244 | } | |
245 | ||
246 | private populateInfoValues (options: { | |
4e11d8f3 C |
247 | playerNetworkInfo: PlayerNetworkInfo |
248 | progress: number | |
249 | codecs: string | |
250 | resolution: string | |
251 | buffer: string | |
252 | ||
253 | latency?: string | |
254 | colorSpace?: string | |
255 | }) { | |
256 | const { playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer, latency } = options | |
257 | const player = this.player() | |
258 | ||
259 | const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality() | |
260 | const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) | |
261 | const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0) | |
262 | const pr = (window.devicePixelRatio || 1).toFixed(2) | |
192edf16 EM |
263 | const vp = `${vw}x${vh}*${pr}` |
264 | const { droppedVideoFrames, totalVideoFrames } = videoQuality | |
265 | const frames = player.localize('{1} / {2} dropped of {3}', [ vp, droppedVideoFrames + '', totalVideoFrames + '' ]) | |
4e11d8f3 C |
266 | const duration = player.duration() |
267 | ||
b76db2ff | 268 | let volume = `${Math.round(player.volume() * 100)}` |
192edf16 | 269 | if (player.muted()) volume += player.localize(' (muted)') |
4e11d8f3 C |
270 | |
271 | const networkActivity = playerNetworkInfo.downloadSpeed | |
272 | ? `${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑` | |
273 | : undefined | |
274 | ||
275 | const totalTransferred = playerNetworkInfo.totalDownloaded | |
276 | ? `${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑` | |
277 | : undefined | |
192edf16 | 278 | const { downloadedFromServer, downloadedFromPeers } = playerNetworkInfo |
4e11d8f3 | 279 | const downloadBreakdown = playerNetworkInfo.downloadedFromServer |
192edf16 | 280 | ? player.localize('{1} from servers ยท {2} from peers', [ downloadedFromServer, downloadedFromPeers ]) |
4e11d8f3 C |
281 | : undefined |
282 | ||
283 | const bufferProgress = progress !== undefined | |
284 | ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` | |
285 | : undefined | |
286 | ||
68e72ba9 C |
287 | this.setInfoValue(this.playerMode, this.mode || 'HTTP') |
288 | this.setInfoValue(this.p2p, player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled')) | |
289 | this.setInfoValue(this.uuid, this.options_.videoUUID) | |
4e11d8f3 | 290 | |
68e72ba9 C |
291 | this.setInfoValue(this.viewport, frames) |
292 | this.setInfoValue(this.resolution, resolution) | |
293 | this.setInfoValue(this.volume, volume) | |
294 | this.setInfoValue(this.codecs, codecs) | |
295 | this.setInfoValue(this.color, colorSpace) | |
296 | this.setInfoValue(this.connection, playerNetworkInfo.averageBandwidth) | |
4e11d8f3 | 297 | |
68e72ba9 C |
298 | this.setInfoValue(this.network, networkActivity) |
299 | this.setInfoValue(this.transferred, totalTransferred) | |
300 | this.setInfoValue(this.download, downloadBreakdown) | |
4e11d8f3 | 301 | |
68e72ba9 C |
302 | this.setInfoValue(this.bufferProgress, bufferProgress) |
303 | this.setInfoValue(this.bufferState, buffer) | |
4e11d8f3 | 304 | |
68e72ba9 C |
305 | this.setInfoValue(this.liveLatency, latency) |
306 | } | |
4e11d8f3 | 307 | |
68e72ba9 C |
308 | private setInfoValue (el: InfoElement, value: string) { |
309 | if (!value) { | |
310 | el.root.style.display = 'none' | |
311 | return | |
312 | } | |
4e11d8f3 | 313 | |
68e72ba9 | 314 | el.root.style.display = 'block' |
4e11d8f3 | 315 | |
68e72ba9 C |
316 | if (el.value.innerHTML === value) return |
317 | el.value.innerHTML = value | |
4e11d8f3 C |
318 | } |
319 | ||
68e72ba9 C |
320 | private buildInfoRow (labelText: string, valueHTML?: string) { |
321 | const root = videojs.dom.createEl('div') as HTMLElement | |
322 | root.style.display = 'none' | |
323 | ||
324 | const label = videojs.dom.createEl('div', { innerText: labelText }) as HTMLElement | |
325 | const value = videojs.dom.createEl('span', { innerHTML: valueHTML }) as HTMLElement | |
4e11d8f3 | 326 | |
68e72ba9 C |
327 | root.appendChild(label) |
328 | root.appendChild(value) | |
4e11d8f3 | 329 | |
68e72ba9 | 330 | return { root, value } |
4e11d8f3 C |
331 | } |
332 | ||
333 | private timeRangesToString (r: videojs.TimeRange) { | |
334 | let result = '' | |
335 | ||
336 | for (let i = 0; i < r.length; i++) { | |
337 | const start = Math.floor(r.start(i)) | |
338 | const end = Math.floor(r.end(i)) | |
339 | ||
340 | result += `[${secondsToTime(start)}, ${secondsToTime(end)}] ` | |
341 | } | |
342 | ||
343 | return result | |
344 | } | |
ff563914 RK |
345 | } |
346 | ||
347 | videojs.registerComponent('StatsCard', StatsCard) | |
348 | ||
349 | export { | |
350 | StatsCard, | |
351 | StatsCardOptions | |
352 | } |