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