diff options
Diffstat (limited to 'client/src/assets/player/stats')
-rw-r--r-- | client/src/assets/player/stats/stats-card.ts | 307 | ||||
-rw-r--r-- | client/src/assets/player/stats/stats-plugin.ts | 6 |
2 files changed, 200 insertions, 113 deletions
diff --git a/client/src/assets/player/stats/stats-card.ts b/client/src/assets/player/stats/stats-card.ts index 278899b72..f66766089 100644 --- a/client/src/assets/player/stats/stats-card.ts +++ b/client/src/assets/player/stats/stats-card.ts | |||
@@ -1,103 +1,42 @@ | |||
1 | import videojs from 'video.js' | 1 | import videojs from 'video.js' |
2 | import { PlayerNetworkInfo } from '../peertube-videojs-typings' | 2 | import { PlayerNetworkInfo as EventPlayerNetworkInfo } from '../peertube-videojs-typings' |
3 | import { getAverageBandwidthInStore } from '../peertube-player-local-storage' | 3 | import { bytes, secondsToTime } from '../utils' |
4 | import { bytes } from '../utils' | ||
5 | 4 | ||
6 | interface StatsCardOptions extends videojs.ComponentOptions { | 5 | interface StatsCardOptions extends videojs.ComponentOptions { |
7 | videoUUID?: string, | 6 | videoUUID: string |
8 | videoIsLive?: boolean, | 7 | videoIsLive: boolean |
9 | mode?: 'webtorrent' | 'p2p-media-loader' | 8 | mode: 'webtorrent' | 'p2p-media-loader' |
10 | } | 9 | } |
11 | 10 | ||
12 | function getListTemplate ( | 11 | interface PlayerNetworkInfo { |
13 | options: StatsCardOptions, | 12 | downloadSpeed?: string |
14 | player: videojs.Player, | 13 | uploadSpeed?: string |
15 | args: { | 14 | totalDownloaded?: string |
16 | playerNetworkInfo?: any | 15 | totalUploaded?: string |
17 | videoFile?: any | 16 | numPeers?: number |
18 | progress?: number | 17 | averageBandwidth?: string |
19 | }) { | ||
20 | const { playerNetworkInfo, videoFile, progress } = args | ||
21 | |||
22 | const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality() | ||
23 | const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) | ||
24 | const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0) | ||
25 | const pr = (window.devicePixelRatio || 1).toFixed(2) | ||
26 | const colorspace = videoFile?.metadata?.streams[0]['color_space'] !== "unknown" | ||
27 | ? videoFile?.metadata?.streams[0]['color_space'] | ||
28 | : undefined | ||
29 | |||
30 | return ` | ||
31 | <div> | ||
32 | <div>${player.localize('Video UUID')}</div> | ||
33 | <span>${options.videoUUID || ''}</span> | ||
34 | </div> | ||
35 | <div> | ||
36 | <div>Viewport / ${player.localize('Frames')}</div> | ||
37 | <span>${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}</span> | ||
38 | </div> | ||
39 | <div${videoFile !== undefined ? '' : ' style="display: none;"'}> | ||
40 | <div>${player.localize('Resolution')}</div> | ||
41 | <span>${videoFile?.resolution.label + videoFile?.fps}</span> | ||
42 | </div> | ||
43 | <div> | ||
44 | <div>${player.localize('Volume')}</div> | ||
45 | <span>${~~(player.volume() * 100)}%${player.muted() ? ' (muted)' : ''}</span> | ||
46 | </div> | ||
47 | <div${videoFile !== undefined ? '' : ' style="display: none;"'}> | ||
48 | <div>${player.localize('Codecs')}</div> | ||
49 | <span>${videoFile?.metadata?.streams[0]['codec_name'] || 'avc1'}</span> | ||
50 | </div> | ||
51 | <div${videoFile !== undefined ? '' : ' style="display: none;"'}> | ||
52 | <div>${player.localize('Color')}</div> | ||
53 | <span>${colorspace || 'bt709'}</span> | ||
54 | </div> | ||
55 | <div${playerNetworkInfo.averageBandwidth !== undefined ? '' : ' style="display: none;"'}> | ||
56 | <div>${player.localize('Connection Speed')}</div> | ||
57 | <span>${playerNetworkInfo.averageBandwidth}</span> | ||
58 | </div> | ||
59 | <div${playerNetworkInfo.downloadSpeed !== undefined ? '' : ' style="display: none;"'}> | ||
60 | <div>${player.localize('Network Activity')}</div> | ||
61 | <span>${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑</span> | ||
62 | </div> | ||
63 | <div${playerNetworkInfo.totalDownloaded !== undefined ? '' : ' style="display: none;"'}> | ||
64 | <div>${player.localize('Total Transfered')}</div> | ||
65 | <span>${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑</span> | ||
66 | </div> | ||
67 | <div${playerNetworkInfo.downloadedFromServer ? '' : ' style="display: none;"'}> | ||
68 | <div>${player.localize('Download Breakdown')}</div> | ||
69 | <span>${playerNetworkInfo.downloadedFromServer} from server · ${playerNetworkInfo.downloadedFromPeers} from peers</span> | ||
70 | </div> | ||
71 | <div${progress !== undefined && videoFile !== undefined ? '' : ' style="display: none;"'}> | ||
72 | <div>${player.localize('Buffer Health')}</div> | ||
73 | <span>${(progress * 100).toFixed(1)}% (${(progress * videoFile?.metadata?.format.duration).toFixed(1)}s)</span> | ||
74 | </div> | ||
75 | <div style="display: none;"> <!-- TODO: implement live latency measure --> | ||
76 | <div>${player.localize('Live Latency')}</div> | ||
77 | <span></span> | ||
78 | </div> | ||
79 | ` | ||
80 | } | ||
81 | 18 | ||
82 | function getMainTemplate () { | 19 | downloadedFromServer?: string |
83 | return ` | 20 | downloadedFromPeers?: string |
84 | <button class="vjs-stats-close" tabindex=0 aria-label="Close stats" title="Close stats">[x]</button> | ||
85 | <div class="vjs-stats-list"></div> | ||
86 | ` | ||
87 | } | 21 | } |
88 | 22 | ||
89 | const Component = videojs.getComponent('Component') | 23 | const Component = videojs.getComponent('Component') |
90 | class StatsCard extends Component { | 24 | class StatsCard extends Component { |
91 | options_: StatsCardOptions | 25 | options_: StatsCardOptions |
26 | |||
92 | container: HTMLDivElement | 27 | container: HTMLDivElement |
28 | |||
93 | list: HTMLDivElement | 29 | list: HTMLDivElement |
94 | closeButton: HTMLElement | 30 | closeButton: HTMLElement |
95 | update: any | ||
96 | source: any | ||
97 | 31 | ||
98 | interval = 300 | 32 | updateInterval: any |
99 | playerNetworkInfo: any = {} | 33 | |
100 | statsForNerdsEvents = new videojs.EventTarget() | 34 | mode: 'webtorrent' | 'p2p-media-loader' |
35 | |||
36 | metadataStore: any = {} | ||
37 | |||
38 | intervalMs = 300 | ||
39 | playerNetworkInfo: PlayerNetworkInfo = {} | ||
101 | 40 | ||
102 | constructor (player: videojs.Player, options: StatsCardOptions) { | 41 | constructor (player: videojs.Player, options: StatsCardOptions) { |
103 | super(player, options) | 42 | super(player, options) |
@@ -106,7 +45,7 @@ class StatsCard extends Component { | |||
106 | createEl () { | 45 | createEl () { |
107 | const container = super.createEl('div', { | 46 | const container = super.createEl('div', { |
108 | className: 'vjs-stats-content', | 47 | className: 'vjs-stats-content', |
109 | innerHTML: getMainTemplate() | 48 | innerHTML: this.getMainTemplate() |
110 | }) as HTMLDivElement | 49 | }) as HTMLDivElement |
111 | this.container = container | 50 | this.container = container |
112 | this.container.style.display = 'none' | 51 | this.container.style.display = 'none' |
@@ -116,12 +55,10 @@ class StatsCard extends Component { | |||
116 | 55 | ||
117 | this.list = this.container.getElementsByClassName('vjs-stats-list')[0] as HTMLDivElement | 56 | this.list = this.container.getElementsByClassName('vjs-stats-list')[0] as HTMLDivElement |
118 | 57 | ||
119 | console.log(this.player_.qualityLevels()) | 58 | this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { |
120 | |||
121 | this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { | ||
122 | if (!data) return // HTTP fallback | 59 | if (!data) return // HTTP fallback |
123 | 60 | ||
124 | this.source = data.source | 61 | this.mode = data.source |
125 | 62 | ||
126 | const p2pStats = data.p2p | 63 | const p2pStats = data.p2p |
127 | const httpStats = data.http | 64 | const httpStats = data.http |
@@ -131,7 +68,7 @@ class StatsCard extends Component { | |||
131 | this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ') | 68 | this.playerNetworkInfo.totalDownloaded = bytes(p2pStats.downloaded + httpStats.downloaded).join(' ') |
132 | this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded).join(' ') | 69 | this.playerNetworkInfo.totalUploaded = bytes(p2pStats.uploaded + httpStats.uploaded).join(' ') |
133 | this.playerNetworkInfo.numPeers = p2pStats.numPeers | 70 | this.playerNetworkInfo.numPeers = p2pStats.numPeers |
134 | this.playerNetworkInfo.averageBandwidth = bytes(getAverageBandwidthInStore() || p2pStats.downloaded + httpStats.downloaded).join(' ') | 71 | this.playerNetworkInfo.averageBandwidth = bytes(data.bandwidthEstimate).join(' ') + '/s' |
135 | 72 | ||
136 | if (data.source === 'p2p-media-loader') { | 73 | if (data.source === 'p2p-media-loader') { |
137 | this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') | 74 | this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') |
@@ -143,37 +80,187 @@ class StatsCard extends Component { | |||
143 | } | 80 | } |
144 | 81 | ||
145 | toggle () { | 82 | toggle () { |
146 | this.update | 83 | this.updateInterval |
147 | ? this.hide() | 84 | ? this.hide() |
148 | : this.show() | 85 | : this.show() |
149 | } | 86 | } |
150 | 87 | ||
151 | show (options?: StatsCardOptions) { | 88 | show () { |
152 | if (options) this.options_ = options | ||
153 | |||
154 | let metadata = {} | ||
155 | |||
156 | this.container.style.display = 'block' | 89 | this.container.style.display = 'block' |
157 | this.update = setInterval(async () => { | 90 | this.updateInterval = setInterval(async () => { |
158 | try { | 91 | try { |
159 | if (this.source === 'webtorrent') { | 92 | const options = this.mode === 'webtorrent' |
160 | const progress = this.player_.webtorrent().getTorrent()?.progress | 93 | ? await this.buildWebTorrentOptions() |
161 | const videoFile = this.player_.webtorrent().getCurrentVideoFile() | 94 | : await this.buildHLSOptions() |
162 | videoFile.metadata = metadata[videoFile.fileUrl] = videoFile.metadata || metadata[videoFile.fileUrl] || videoFile.metadataUrl && await fetch(videoFile.metadataUrl).then(res => res.json()) | 95 | |
163 | this.list.innerHTML = getListTemplate(this.options_, this.player_, { playerNetworkInfo: this.playerNetworkInfo, videoFile, progress }) | 96 | this.list.innerHTML = this.getListTemplate(options) |
164 | } else { | 97 | } catch (err) { |
165 | this.list.innerHTML = getListTemplate(this.options_, this.player_, { playerNetworkInfo: this.playerNetworkInfo }) | 98 | console.error('Cannot update stats.', err) |
166 | } | 99 | clearInterval(this.updateInterval) |
167 | } catch (e) { | ||
168 | clearInterval(this.update) | ||
169 | } | 100 | } |
170 | }, this.interval) | 101 | }, this.intervalMs) |
171 | } | 102 | } |
172 | 103 | ||
173 | hide () { | 104 | hide () { |
174 | clearInterval(this.update) | 105 | clearInterval(this.updateInterval) |
175 | this.container.style.display = 'none' | 106 | this.container.style.display = 'none' |
176 | } | 107 | } |
108 | |||
109 | private async buildHLSOptions () { | ||
110 | const p2pMediaLoader = this.player_.p2pMediaLoader() | ||
111 | const level = p2pMediaLoader.getCurrentLevel() | ||
112 | |||
113 | const codecs = level?.videoCodec || level?.audioCodec | ||
114 | ? `${level?.videoCodec || ''} / ${level?.audioCodec || ''}` | ||
115 | : undefined | ||
116 | |||
117 | const resolution = `${level?.height}p${level?.attrs['FRAME-RATE'] || ''}` | ||
118 | const buffer = this.timeRangesToString(this.player().buffered()) | ||
119 | |||
120 | let progress: number | ||
121 | let latency: string | ||
122 | |||
123 | if (this.options_.videoIsLive) { | ||
124 | latency = secondsToTime(p2pMediaLoader.getLiveLatency()) | ||
125 | } else { | ||
126 | progress = this.player().bufferedPercent() | ||
127 | } | ||
128 | |||
129 | return { | ||
130 | playerNetworkInfo: this.playerNetworkInfo, | ||
131 | resolution, | ||
132 | codecs, | ||
133 | buffer, | ||
134 | latency, | ||
135 | progress | ||
136 | } | ||
137 | } | ||
138 | |||
139 | private async buildWebTorrentOptions () { | ||
140 | const videoFile = this.player_.webtorrent().getCurrentVideoFile() | ||
141 | |||
142 | if (!this.metadataStore[videoFile.fileUrl]) { | ||
143 | this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) | ||
144 | } | ||
145 | |||
146 | const metadata = this.metadataStore[videoFile.fileUrl] | ||
147 | |||
148 | let colorSpace = 'unknown' | ||
149 | let codecs = 'unknown' | ||
150 | |||
151 | if (metadata?.streams[0]) { | ||
152 | const stream = metadata.streams[0] | ||
153 | |||
154 | colorSpace = stream['color_space'] !== 'unknown' | ||
155 | ? stream['color_space'] | ||
156 | : 'bt709' | ||
157 | |||
158 | codecs = stream['codec_name'] || 'avc1' | ||
159 | } | ||
160 | |||
161 | const resolution = videoFile?.resolution.label + videoFile?.fps | ||
162 | const buffer = this.timeRangesToString(this.player().buffered()) | ||
163 | const progress = this.player_.webtorrent().getTorrent()?.progress | ||
164 | |||
165 | return { | ||
166 | playerNetworkInfo: this.playerNetworkInfo, | ||
167 | progress, | ||
168 | colorSpace, | ||
169 | codecs, | ||
170 | resolution, | ||
171 | buffer | ||
172 | } | ||
173 | } | ||
174 | |||
175 | private getListTemplate (options: { | ||
176 | playerNetworkInfo: PlayerNetworkInfo | ||
177 | progress: number | ||
178 | codecs: string | ||
179 | resolution: string | ||
180 | buffer: string | ||
181 | |||
182 | latency?: string | ||
183 | colorSpace?: string | ||
184 | }) { | ||
185 | const { playerNetworkInfo, progress, colorSpace, codecs, resolution, buffer, latency } = options | ||
186 | const player = this.player() | ||
187 | |||
188 | const videoQuality: VideoPlaybackQuality = player.getVideoPlaybackQuality() | ||
189 | const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) | ||
190 | const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0) | ||
191 | const pr = (window.devicePixelRatio || 1).toFixed(2) | ||
192 | const frames = `${vw}x${vh}*${pr} / ${videoQuality.droppedVideoFrames} dropped of ${videoQuality.totalVideoFrames}` | ||
193 | |||
194 | const duration = player.duration() | ||
195 | |||
196 | let volume = `${player.volume() * 100}` | ||
197 | if (player.muted()) volume += ' (muted)' | ||
198 | |||
199 | const networkActivity = playerNetworkInfo.downloadSpeed | ||
200 | ? `${playerNetworkInfo.downloadSpeed} ⇓ / ${playerNetworkInfo.uploadSpeed} ⇑` | ||
201 | : undefined | ||
202 | |||
203 | const totalTransferred = playerNetworkInfo.totalDownloaded | ||
204 | ? `${playerNetworkInfo.totalDownloaded} ⇓ / ${playerNetworkInfo.totalUploaded} ⇑` | ||
205 | : undefined | ||
206 | const downloadBreakdown = playerNetworkInfo.downloadedFromServer | ||
207 | ? `${playerNetworkInfo.downloadedFromServer} from server · ${playerNetworkInfo.downloadedFromPeers} from peers` | ||
208 | : undefined | ||
209 | |||
210 | const bufferProgress = progress !== undefined | ||
211 | ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` | ||
212 | : undefined | ||
213 | |||
214 | return ` | ||
215 | ${this.buildElement(player.localize('Video UUID'), this.options_.videoUUID)} | ||
216 | |||
217 | ${this.buildElement(player.localize('Viewport / Frames'), frames)} | ||
218 | |||
219 | ${this.buildElement(player.localize('Resolution'), resolution)} | ||
220 | |||
221 | ${this.buildElement(player.localize('Volume'), volume)} | ||
222 | |||
223 | ${this.buildElement(player.localize('Codecs'), codecs)} | ||
224 | ${this.buildElement(player.localize('Color'), colorSpace)} | ||
225 | |||
226 | ${this.buildElement(player.localize('Connection Speed'), playerNetworkInfo.averageBandwidth)} | ||
227 | |||
228 | ${this.buildElement(player.localize('Network Activity'), networkActivity)} | ||
229 | ${this.buildElement(player.localize('Total Transfered'), totalTransferred)} | ||
230 | ${this.buildElement(player.localize('Download Breakdown'), downloadBreakdown)} | ||
231 | |||
232 | ${this.buildElement(player.localize('Buffer Progress'), bufferProgress)} | ||
233 | ${this.buildElement(player.localize('Buffer State'), buffer)} | ||
234 | |||
235 | ${this.buildElement(player.localize('Live Latency'), latency)} | ||
236 | ` | ||
237 | } | ||
238 | |||
239 | private getMainTemplate () { | ||
240 | return ` | ||
241 | <button class="vjs-stats-close" tabindex=0 aria-label="Close stats" title="Close stats">[x]</button> | ||
242 | <div class="vjs-stats-list"></div> | ||
243 | ` | ||
244 | } | ||
245 | |||
246 | private buildElement (label: string, value?: string) { | ||
247 | if (!value) return '' | ||
248 | |||
249 | return `<div><div>${label}</div><span>${value}</span></div>` | ||
250 | } | ||
251 | |||
252 | private timeRangesToString (r: videojs.TimeRange) { | ||
253 | let result = '' | ||
254 | |||
255 | for (let i = 0; i < r.length; i++) { | ||
256 | const start = Math.floor(r.start(i)) | ||
257 | const end = Math.floor(r.end(i)) | ||
258 | |||
259 | result += `[${secondsToTime(start)}, ${secondsToTime(end)}] ` | ||
260 | } | ||
261 | |||
262 | return result | ||
263 | } | ||
177 | } | 264 | } |
178 | 265 | ||
179 | videojs.registerComponent('StatsCard', StatsCard) | 266 | videojs.registerComponent('StatsCard', StatsCard) |
diff --git a/client/src/assets/player/stats/stats-plugin.ts b/client/src/assets/player/stats/stats-plugin.ts index 3402e7861..8aad80e8a 100644 --- a/client/src/assets/player/stats/stats-plugin.ts +++ b/client/src/assets/player/stats/stats-plugin.ts | |||
@@ -6,7 +6,7 @@ const Plugin = videojs.getPlugin('plugin') | |||
6 | class StatsForNerdsPlugin extends Plugin { | 6 | class StatsForNerdsPlugin extends Plugin { |
7 | private statsCard: StatsCard | 7 | private statsCard: StatsCard |
8 | 8 | ||
9 | constructor (player: videojs.Player, options: Partial<StatsCardOptions> = {}) { | 9 | constructor (player: videojs.Player, options: StatsCardOptions) { |
10 | const settings = { | 10 | const settings = { |
11 | ...options | 11 | ...options |
12 | } | 12 | } |
@@ -22,8 +22,8 @@ class StatsForNerdsPlugin extends Plugin { | |||
22 | player.addChild(this.statsCard, settings) | 22 | player.addChild(this.statsCard, settings) |
23 | } | 23 | } |
24 | 24 | ||
25 | show (options?: StatsCardOptions) { | 25 | show () { |
26 | this.statsCard.show(options) | 26 | this.statsCard.show() |
27 | } | 27 | } |
28 | } | 28 | } |
29 | 29 | ||