diff options
author | Chocobozzz <me@florianbigard.com> | 2022-04-05 14:03:52 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2022-04-15 09:49:35 +0200 |
commit | 384ba8b77a8e4805c099f5ea12b41c2ca5776e26 (patch) | |
tree | 6b517033d9265d283677b85e0f57486e0e7fd8cf /client/src/app/+stats | |
parent | b211106695bb82f6c32e53306081b5262c3d109d (diff) | |
download | PeerTube-384ba8b77a8e4805c099f5ea12b41c2ca5776e26.tar.gz PeerTube-384ba8b77a8e4805c099f5ea12b41c2ca5776e26.tar.zst PeerTube-384ba8b77a8e4805c099f5ea12b41c2ca5776e26.zip |
Support videos stats in client
Diffstat (limited to 'client/src/app/+stats')
-rw-r--r-- | client/src/app/+stats/index.ts | 1 | ||||
-rw-r--r-- | client/src/app/+stats/stats-routing.module.ts | 25 | ||||
-rw-r--r-- | client/src/app/+stats/stats.module.ts | 27 | ||||
-rw-r--r-- | client/src/app/+stats/video/index.ts | 2 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.html | 38 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.scss | 54 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.ts | 295 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.service.ts | 34 |
8 files changed, 476 insertions, 0 deletions
diff --git a/client/src/app/+stats/index.ts b/client/src/app/+stats/index.ts new file mode 100644 index 000000000..d880024a5 --- /dev/null +++ b/client/src/app/+stats/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './stats.module' | |||
diff --git a/client/src/app/+stats/stats-routing.module.ts b/client/src/app/+stats/stats-routing.module.ts new file mode 100644 index 000000000..59519a703 --- /dev/null +++ b/client/src/app/+stats/stats-routing.module.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { VideoResolver } from '@app/shared/shared-main' | ||
4 | import { VideoStatsComponent } from './video' | ||
5 | |||
6 | const statsRoutes: Routes = [ | ||
7 | { | ||
8 | path: 'videos/:videoId', | ||
9 | component: VideoStatsComponent, | ||
10 | data: { | ||
11 | meta: { | ||
12 | title: $localize`Video stats` | ||
13 | } | ||
14 | }, | ||
15 | resolve: { | ||
16 | video: VideoResolver | ||
17 | } | ||
18 | } | ||
19 | ] | ||
20 | |||
21 | @NgModule({ | ||
22 | imports: [ RouterModule.forChild(statsRoutes) ], | ||
23 | exports: [ RouterModule ] | ||
24 | }) | ||
25 | export class StatsRoutingModule {} | ||
diff --git a/client/src/app/+stats/stats.module.ts b/client/src/app/+stats/stats.module.ts new file mode 100644 index 000000000..0497576e7 --- /dev/null +++ b/client/src/app/+stats/stats.module.ts | |||
@@ -0,0 +1,27 @@ | |||
1 | import { ChartModule } from 'primeng/chart' | ||
2 | import { NgModule } from '@angular/core' | ||
3 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | ||
4 | import { SharedMainModule } from '@app/shared/shared-main' | ||
5 | import { StatsRoutingModule } from './stats-routing.module' | ||
6 | import { VideoStatsComponent, VideoStatsService } from './video' | ||
7 | |||
8 | @NgModule({ | ||
9 | imports: [ | ||
10 | StatsRoutingModule, | ||
11 | |||
12 | SharedMainModule, | ||
13 | SharedGlobalIconModule, | ||
14 | |||
15 | ChartModule | ||
16 | ], | ||
17 | |||
18 | declarations: [ | ||
19 | VideoStatsComponent | ||
20 | ], | ||
21 | |||
22 | exports: [], | ||
23 | providers: [ | ||
24 | VideoStatsService | ||
25 | ] | ||
26 | }) | ||
27 | export class StatsModule { } | ||
diff --git a/client/src/app/+stats/video/index.ts b/client/src/app/+stats/video/index.ts new file mode 100644 index 000000000..e948d4f4e --- /dev/null +++ b/client/src/app/+stats/video/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './video-stats.component' | ||
2 | export * from './video-stats.service' | ||
diff --git a/client/src/app/+stats/video/video-stats.component.html b/client/src/app/+stats/video/video-stats.component.html new file mode 100644 index 000000000..ef43c9fba --- /dev/null +++ b/client/src/app/+stats/video/video-stats.component.html | |||
@@ -0,0 +1,38 @@ | |||
1 | <div class="margin-content"> | ||
2 | <h1 class="title-page title-page-single" i18n>Stats for {{ video.name }}</h1> | ||
3 | |||
4 | <div class="overall-stats-embed"> | ||
5 | <div class="overall-stats"> | ||
6 | <div *ngFor="let card of overallStatCards" class="card overall-stats-card"> | ||
7 | <div class="label">{{ card.label }}</div> | ||
8 | <div class="value">{{ card.value }}</div> | ||
9 | <div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div> | ||
10 | </div> | ||
11 | </div> | ||
12 | |||
13 | <my-embed [video]="video"></my-embed> | ||
14 | </div> | ||
15 | |||
16 | <div class="timeserie"> | ||
17 | <div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs"> | ||
18 | |||
19 | <ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id"> | ||
20 | <a ngbNavLink i18n> | ||
21 | <span>{{ availableChart.label }}</span> | ||
22 | </a> | ||
23 | |||
24 | <ng-template ngbNavContent> | ||
25 | <div [ngStyle]="{ 'min-height': chartHeight }"> | ||
26 | <p-chart | ||
27 | *ngIf="chartOptions[availableChart.id]" | ||
28 | [height]="chartHeight" [width]="chartWidth" | ||
29 | [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data" | ||
30 | ></p-chart> | ||
31 | </div> | ||
32 | </ng-template> | ||
33 | </ng-container> | ||
34 | </div> | ||
35 | |||
36 | <div [ngbNavOutlet]="nav"></div> | ||
37 | </div> | ||
38 | </div> | ||
diff --git a/client/src/app/+stats/video/video-stats.component.scss b/client/src/app/+stats/video/video-stats.component.scss new file mode 100644 index 000000000..190499b5c --- /dev/null +++ b/client/src/app/+stats/video/video-stats.component.scss | |||
@@ -0,0 +1,54 @@ | |||
1 | @use '_variables' as *; | ||
2 | @use '_mixins' as *; | ||
3 | @use '_nav' as *; | ||
4 | |||
5 | .overall-stats-embed { | ||
6 | display: flex; | ||
7 | justify-content: space-between; | ||
8 | } | ||
9 | |||
10 | .overall-stats { | ||
11 | display: flex; | ||
12 | flex-wrap: wrap; | ||
13 | } | ||
14 | |||
15 | .overall-stats-card { | ||
16 | display: flex; | ||
17 | justify-content: center; | ||
18 | align-items: center; | ||
19 | height: fit-content; | ||
20 | min-height: 100px; | ||
21 | min-width: 200px; | ||
22 | margin-right: 15px; | ||
23 | background-color: pvar(--submenuBackgroundColor); | ||
24 | |||
25 | .label, | ||
26 | .more-info { | ||
27 | font-size: 14px; | ||
28 | } | ||
29 | |||
30 | .label { | ||
31 | color: pvar(--greyForegroundColor); | ||
32 | font-weight: $font-semibold; | ||
33 | opacity: 0.8; | ||
34 | } | ||
35 | |||
36 | .value { | ||
37 | font-size: 24px; | ||
38 | font-weight: $font-semibold; | ||
39 | } | ||
40 | } | ||
41 | |||
42 | my-embed { | ||
43 | display: block; | ||
44 | max-width: 500px; | ||
45 | width: 100%; | ||
46 | } | ||
47 | |||
48 | .tab-content { | ||
49 | margin-top: 15px; | ||
50 | } | ||
51 | |||
52 | .nav-tabs { | ||
53 | @include peertube-nav-tabs($border-width: 2px); | ||
54 | } | ||
diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts new file mode 100644 index 000000000..05319539b --- /dev/null +++ b/client/src/app/+stats/video/video-stats.component.ts | |||
@@ -0,0 +1,295 @@ | |||
1 | import { ChartConfiguration, ChartData } from 'chart.js' | ||
2 | import { Observable, of } from 'rxjs' | ||
3 | import { Component, OnInit } from '@angular/core' | ||
4 | import { ActivatedRoute } from '@angular/router' | ||
5 | import { Notifier } from '@app/core' | ||
6 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' | ||
7 | import { secondsToTime } from '@shared/core-utils' | ||
8 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' | ||
9 | import { VideoStatsService } from './video-stats.service' | ||
10 | |||
11 | type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' | ||
12 | |||
13 | type CountryData = { name: string, viewers: number }[] | ||
14 | |||
15 | type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData | ||
16 | type ChartBuilderResult = { | ||
17 | type: 'line' | 'bar' | ||
18 | data: ChartData<'line' | 'bar'> | ||
19 | displayLegend: boolean | ||
20 | } | ||
21 | |||
22 | @Component({ | ||
23 | templateUrl: './video-stats.component.html', | ||
24 | styleUrls: [ './video-stats.component.scss' ], | ||
25 | providers: [ NumberFormatterPipe ] | ||
26 | }) | ||
27 | export class VideoStatsComponent implements OnInit { | ||
28 | overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = [] | ||
29 | |||
30 | chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {} | ||
31 | chartHeight = '300px' | ||
32 | chartWidth: string = null | ||
33 | |||
34 | availableCharts = [ | ||
35 | { | ||
36 | id: 'viewers', | ||
37 | label: $localize`Viewers` | ||
38 | }, | ||
39 | { | ||
40 | id: 'aggregateWatchTime', | ||
41 | label: $localize`Watch time` | ||
42 | }, | ||
43 | { | ||
44 | id: 'retention', | ||
45 | label: $localize`Retention` | ||
46 | }, | ||
47 | { | ||
48 | id: 'countries', | ||
49 | label: $localize`Countries` | ||
50 | } | ||
51 | ] | ||
52 | |||
53 | activeGraphId: ActiveGraphId = 'viewers' | ||
54 | |||
55 | video: VideoDetails | ||
56 | |||
57 | countries: CountryData = [] | ||
58 | |||
59 | constructor ( | ||
60 | private route: ActivatedRoute, | ||
61 | private notifier: Notifier, | ||
62 | private statsService: VideoStatsService, | ||
63 | private numberFormatter: NumberFormatterPipe | ||
64 | ) {} | ||
65 | |||
66 | ngOnInit () { | ||
67 | this.video = this.route.snapshot.data.video | ||
68 | |||
69 | this.loadOverallStats() | ||
70 | this.loadChart() | ||
71 | } | ||
72 | |||
73 | hasCountries () { | ||
74 | return this.countries.length !== 0 | ||
75 | } | ||
76 | |||
77 | onChartChange (newActive: ActiveGraphId) { | ||
78 | this.activeGraphId = newActive | ||
79 | |||
80 | this.loadChart() | ||
81 | } | ||
82 | |||
83 | private loadOverallStats () { | ||
84 | this.statsService.getOverallStats(this.video.uuid) | ||
85 | .subscribe({ | ||
86 | next: res => { | ||
87 | this.countries = res.countries.slice(0, 10).map(c => ({ | ||
88 | name: this.countryCodeToName(c.isoCode), | ||
89 | viewers: c.viewers | ||
90 | })) | ||
91 | |||
92 | this.buildOverallStatCard(res) | ||
93 | }, | ||
94 | |||
95 | error: err => this.notifier.error(err.message) | ||
96 | }) | ||
97 | } | ||
98 | |||
99 | private buildOverallStatCard (overallStats: VideoStatsOverall) { | ||
100 | this.overallStatCards = [ | ||
101 | { | ||
102 | label: $localize`Views`, | ||
103 | value: this.numberFormatter.transform(overallStats.views) | ||
104 | }, | ||
105 | { | ||
106 | label: $localize`Comments`, | ||
107 | value: this.numberFormatter.transform(overallStats.comments) | ||
108 | }, | ||
109 | { | ||
110 | label: $localize`Likes`, | ||
111 | value: this.numberFormatter.transform(overallStats.likes) | ||
112 | }, | ||
113 | { | ||
114 | label: $localize`Average watch time`, | ||
115 | value: secondsToTime(overallStats.averageWatchTime) | ||
116 | }, | ||
117 | { | ||
118 | label: $localize`Peak viewers`, | ||
119 | value: this.numberFormatter.transform(overallStats.viewersPeak), | ||
120 | moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}` | ||
121 | } | ||
122 | ] | ||
123 | } | ||
124 | |||
125 | private loadChart () { | ||
126 | const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = { | ||
127 | retention: this.statsService.getRetentionStats(this.video.uuid), | ||
128 | aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'), | ||
129 | viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'), | ||
130 | countries: of(this.countries) | ||
131 | } | ||
132 | |||
133 | obsBuilders[this.activeGraphId].subscribe({ | ||
134 | next: res => { | ||
135 | this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res) | ||
136 | }, | ||
137 | |||
138 | error: err => this.notifier.error(err.message) | ||
139 | }) | ||
140 | } | ||
141 | |||
142 | private buildChartOptions ( | ||
143 | graphId: ActiveGraphId, | ||
144 | rawData: ChartIngestData | ||
145 | ): ChartConfiguration<'line' | 'bar'> { | ||
146 | const dataBuilders: { | ||
147 | [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult | ||
148 | } = { | ||
149 | retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData), | ||
150 | aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData), | ||
151 | viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData), | ||
152 | countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) | ||
153 | } | ||
154 | |||
155 | const { type, data, displayLegend } = dataBuilders[graphId](rawData) | ||
156 | |||
157 | return { | ||
158 | type, | ||
159 | data, | ||
160 | |||
161 | options: { | ||
162 | responsive: true, | ||
163 | |||
164 | scales: { | ||
165 | y: { | ||
166 | beginAtZero: true, | ||
167 | |||
168 | max: this.activeGraphId === 'retention' | ||
169 | ? 100 | ||
170 | : undefined, | ||
171 | |||
172 | ticks: { | ||
173 | callback: value => this.formatTick(graphId, value) | ||
174 | } | ||
175 | } | ||
176 | }, | ||
177 | |||
178 | plugins: { | ||
179 | legend: { | ||
180 | display: displayLegend | ||
181 | }, | ||
182 | tooltip: { | ||
183 | callbacks: { | ||
184 | label: value => this.formatTick(graphId, value.raw as number | string) | ||
185 | } | ||
186 | } | ||
187 | } | ||
188 | } | ||
189 | } | ||
190 | } | ||
191 | |||
192 | private buildRetentionChartOptions (rawData: VideoStatsRetention) { | ||
193 | const labels: string[] = [] | ||
194 | const data: number[] = [] | ||
195 | |||
196 | for (const d of rawData.data) { | ||
197 | labels.push(secondsToTime(d.second)) | ||
198 | data.push(d.retentionPercent) | ||
199 | } | ||
200 | |||
201 | return { | ||
202 | type: 'line' as 'line', | ||
203 | |||
204 | displayLegend: false, | ||
205 | |||
206 | data: { | ||
207 | labels, | ||
208 | datasets: [ | ||
209 | { | ||
210 | data, | ||
211 | borderColor: this.buildChartColor() | ||
212 | } | ||
213 | ] | ||
214 | } | ||
215 | } | ||
216 | } | ||
217 | |||
218 | private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) { | ||
219 | const labels: string[] = [] | ||
220 | const data: number[] = [] | ||
221 | |||
222 | for (const d of rawData.data) { | ||
223 | labels.push(new Date(d.date).toLocaleDateString()) | ||
224 | data.push(d.value) | ||
225 | } | ||
226 | |||
227 | return { | ||
228 | type: 'line' as 'line', | ||
229 | |||
230 | displayLegend: false, | ||
231 | |||
232 | data: { | ||
233 | labels, | ||
234 | datasets: [ | ||
235 | { | ||
236 | data, | ||
237 | borderColor: this.buildChartColor() | ||
238 | } | ||
239 | ] | ||
240 | } | ||
241 | } | ||
242 | } | ||
243 | |||
244 | private buildCountryChartOptions (rawData: CountryData) { | ||
245 | const labels: string[] = [] | ||
246 | const data: number[] = [] | ||
247 | |||
248 | for (const d of rawData) { | ||
249 | labels.push(d.name) | ||
250 | data.push(d.viewers) | ||
251 | } | ||
252 | |||
253 | return { | ||
254 | type: 'bar' as 'bar', | ||
255 | |||
256 | displayLegend: true, | ||
257 | |||
258 | options: { | ||
259 | indexAxis: 'y' | ||
260 | }, | ||
261 | |||
262 | data: { | ||
263 | labels, | ||
264 | datasets: [ | ||
265 | { | ||
266 | label: $localize`Viewers`, | ||
267 | backgroundColor: this.buildChartColor(), | ||
268 | maxBarThickness: 20, | ||
269 | data | ||
270 | } | ||
271 | ] | ||
272 | } | ||
273 | } | ||
274 | } | ||
275 | |||
276 | private buildChartColor () { | ||
277 | return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') | ||
278 | } | ||
279 | |||
280 | private formatTick (graphId: ActiveGraphId, value: number | string) { | ||
281 | if (graphId === 'retention') return value + ' %' | ||
282 | if (graphId === 'aggregateWatchTime') return secondsToTime(+value) | ||
283 | |||
284 | return value.toLocaleString() | ||
285 | } | ||
286 | |||
287 | private countryCodeToName (code: string) { | ||
288 | const intl: any = Intl | ||
289 | if (!intl.DisplayNames) return code | ||
290 | |||
291 | const regionNames = new intl.DisplayNames([], { type: 'region' }) | ||
292 | |||
293 | return regionNames.of(code) | ||
294 | } | ||
295 | } | ||
diff --git a/client/src/app/+stats/video/video-stats.service.ts b/client/src/app/+stats/video/video-stats.service.ts new file mode 100644 index 000000000..8f9d48f60 --- /dev/null +++ b/client/src/app/+stats/video/video-stats.service.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | import { catchError } from 'rxjs' | ||
2 | import { environment } from 'src/environments/environment' | ||
3 | import { HttpClient } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { RestExtractor } from '@app/core' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' | ||
8 | |||
9 | @Injectable({ | ||
10 | providedIn: 'root' | ||
11 | }) | ||
12 | export class VideoStatsService { | ||
13 | static BASE_VIDEO_STATS_URL = environment.apiUrl + '/api/v1/videos/' | ||
14 | |||
15 | constructor ( | ||
16 | private authHttp: HttpClient, | ||
17 | private restExtractor: RestExtractor | ||
18 | ) { } | ||
19 | |||
20 | getOverallStats (videoId: string) { | ||
21 | return this.authHttp.get<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall') | ||
22 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
23 | } | ||
24 | |||
25 | getTimeserieStats (videoId: string, metric: VideoStatsTimeserieMetric) { | ||
26 | return this.authHttp.get<VideoStatsTimeserie>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric) | ||
27 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
28 | } | ||
29 | |||
30 | getRetentionStats (videoId: string) { | ||
31 | return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention') | ||
32 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
33 | } | ||
34 | } | ||