aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-04-05 14:03:52 +0200
committerChocobozzz <chocobozzz@cpy.re>2022-04-15 09:49:35 +0200
commit384ba8b77a8e4805c099f5ea12b41c2ca5776e26 (patch)
tree6b517033d9265d283677b85e0f57486e0e7fd8cf /client
parentb211106695bb82f6c32e53306081b5262c3d109d (diff)
downloadPeerTube-384ba8b77a8e4805c099f5ea12b41c2ca5776e26.tar.gz
PeerTube-384ba8b77a8e4805c099f5ea12b41c2ca5776e26.tar.zst
PeerTube-384ba8b77a8e4805c099f5ea12b41c2ca5776e26.zip
Support videos stats in client
Diffstat (limited to 'client')
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts4
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.html6
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts77
-rw-r--r--client/src/app/+stats/index.ts1
-rw-r--r--client/src/app/+stats/stats-routing.module.ts25
-rw-r--r--client/src/app/+stats/stats.module.ts27
-rw-r--r--client/src/app/+stats/video/index.ts2
-rw-r--r--client/src/app/+stats/video/video-stats.component.html38
-rw-r--r--client/src/app/+stats/video/video-stats.component.scss54
-rw-r--r--client/src/app/+stats/video/video-stats.component.ts295
-rw-r--r--client/src/app/+stats/video/video-stats.service.ts34
-rw-r--r--client/src/app/+video-studio/edit/index.ts1
-rw-r--r--client/src/app/+video-studio/video-studio-routing.module.ts5
-rw-r--r--client/src/app/+video-studio/video-studio.module.ts5
-rw-r--r--client/src/app/+videos/+video-edit/video-add.component.scss31
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts3
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts13
-rw-r--r--client/src/app/app-routing.module.ts6
-rw-r--r--client/src/app/shared/shared-icons/global-icon.component.ts3
-rw-r--r--client/src/app/shared/shared-main/angular/number-formatter.pipe.ts1
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts3
-rw-r--r--client/src/app/shared/shared-main/video/index.ts1
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts11
-rw-r--r--client/src/app/shared/shared-main/video/video.resolver.ts (renamed from client/src/app/+video-studio/edit/video-studio-edit.resolver.ts)5
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts4
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts17
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts23
-rw-r--r--client/src/assets/images/feather/stats.svg1
-rw-r--r--client/src/assets/player/shared/manager-options/manager-options-builder.ts20
-rw-r--r--client/src/assets/player/shared/peertube/peertube-plugin.ts118
-rw-r--r--client/src/assets/player/types/manager-options.ts6
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts12
-rw-r--r--client/src/sass/include/_nav.scss44
33 files changed, 697 insertions, 199 deletions
diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts
index 4aed5221b..82ff372aa 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.ts
+++ b/client/src/app/+admin/overview/videos/video-list.component.ts
@@ -41,7 +41,9 @@ export class VideoListComponent extends RestTable implements OnInit {
41 mute: true, 41 mute: true,
42 liveInfo: false, 42 liveInfo: false,
43 removeFiles: true, 43 removeFiles: true,
44 transcoding: true 44 transcoding: true,
45 studio: true,
46 stats: true
45 } 47 }
46 48
47 loading = true 49 loading = true
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.html b/client/src/app/+my-library/my-videos/my-videos.component.html
index 9f81f0ad7..7f12e2c71 100644
--- a/client/src/app/+my-library/my-videos/my-videos.component.html
+++ b/client/src/app/+my-library/my-videos/my-videos.component.html
@@ -55,10 +55,12 @@
55 <div class="action-button"> 55 <div class="action-button">
56 <my-edit-button label [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button> 56 <my-edit-button label [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
57 57
58 <my-action-dropdown [actions]="videoActions" [entry]="{ video: video }"></my-action-dropdown> 58 <my-video-actions-dropdown
59 [video]="video" [displayOptions]="videoDropdownDisplayOptions" [moreActions]="moreVideoActions"
60 [buttonStyled]="true" buttonDirection="horizontal" (videoRemoved)="onVideoRemoved(video)"
61 ></my-video-actions-dropdown>
59 </div> 62 </div>
60 </ng-template> 63 </ng-template>
61</my-videos-selection> 64</my-videos-selection>
62 65
63<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership> 66<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
64<my-live-stream-information #liveStreamInformationModal></my-live-stream-information>
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts
index a364b9b6a..8da2bc890 100644
--- a/client/src/app/+my-library/my-videos/my-videos.component.ts
+++ b/client/src/app/+my-library/my-videos/my-videos.component.ts
@@ -8,7 +8,12 @@ import { immutableAssign } from '@app/helpers'
8import { AdvancedInputFilter } from '@app/shared/shared-forms' 8import { AdvancedInputFilter } from '@app/shared/shared-forms'
9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' 10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
11import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' 11import {
12 MiniatureDisplayOptions,
13 SelectionType,
14 VideoActionsDisplayType,
15 VideosSelectionComponent
16} from '@app/shared/shared-video-miniature'
12import { VideoChannel, VideoSortField } from '@shared/models' 17import { VideoChannel, VideoSortField } from '@shared/models'
13import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' 18import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
14 19
@@ -37,8 +42,23 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
37 state: true, 42 state: true,
38 blacklistInfo: true 43 blacklistInfo: true
39 } 44 }
45 videoDropdownDisplayOptions: VideoActionsDisplayType = {
46 playlist: false,
47 download: false,
48 update: false,
49 blacklist: false,
50 delete: true,
51 report: false,
52 duplicate: false,
53 mute: false,
54 liveInfo: false,
55 removeFiles: false,
56 transcoding: false,
57 studio: true,
58 stats: true
59 }
40 60
41 videoActions: DropdownAction<{ video: Video }>[] = [] 61 moreVideoActions: DropdownAction<{ video: Video }>[][] = []
42 62
43 videos: Video[] = [] 63 videos: Video[] = []
44 getVideosObservableFunction = this.getVideosObservable.bind(this) 64 getVideosObservableFunction = this.getVideosObservable.bind(this)
@@ -172,60 +192,27 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
172 }) 192 })
173 } 193 }
174 194
175 async deleteVideo (video: Video) { 195 onVideoRemoved (video: Video) {
176 const res = await this.confirmService.confirm( 196 this.removeVideoFromArray(video.id)
177 $localize`Do you really want to delete ${video.name}?`,
178 $localize`Delete`
179 )
180 if (res === false) return
181
182 this.videoService.removeVideo(video.id)
183 .subscribe({
184 next: () => {
185 this.notifier.success($localize`Video ${video.name} deleted.`)
186 this.removeVideoFromArray(video.id)
187 },
188
189 error: err => this.notifier.error(err.message)
190 })
191 } 197 }
192 198
193 changeOwnership (video: Video) { 199 changeOwnership (video: Video) {
194 this.videoChangeOwnershipModal.show(video) 200 this.videoChangeOwnershipModal.show(video)
195 } 201 }
196 202
197 displayLiveInformation (video: Video) {
198 this.liveStreamInformationModal.show(video)
199 }
200
201 private removeVideoFromArray (id: number) { 203 private removeVideoFromArray (id: number) {
202 this.videos = this.videos.filter(v => v.id !== id) 204 this.videos = this.videos.filter(v => v.id !== id)
203 } 205 }
204 206
205 private buildActions () { 207 private buildActions () {
206 this.videoActions = [ 208 this.moreVideoActions = [
207 { 209 [
208 label: $localize`Studio`, 210 {
209 linkBuilder: ({ video }) => [ '/studio/edit', video.uuid ], 211 label: $localize`Change ownership`,
210 isDisplayed: ({ video }) => video.isEditableBy(this.authService.getUser(), this.serverService.getHTMLConfig().videoStudio.enabled), 212 handler: ({ video }) => this.changeOwnership(video),
211 iconName: 'film' 213 iconName: 'ownership-change'
212 }, 214 }
213 { 215 ]
214 label: $localize`Display live information`,
215 handler: ({ video }) => this.displayLiveInformation(video),
216 isDisplayed: ({ video }) => video.isLive,
217 iconName: 'live'
218 },
219 {
220 label: $localize`Change ownership`,
221 handler: ({ video }) => this.changeOwnership(video),
222 iconName: 'ownership-change'
223 },
224 {
225 label: $localize`Delete`,
226 handler: ({ video }) => this.deleteVideo(video),
227 iconName: 'delete'
228 }
229 ] 216 ]
230 } 217 }
231} 218}
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 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { VideoResolver } from '@app/shared/shared-main'
4import { VideoStatsComponent } from './video'
5
6const 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})
25export 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 @@
1import { ChartModule } from 'primeng/chart'
2import { NgModule } from '@angular/core'
3import { SharedGlobalIconModule } from '@app/shared/shared-icons'
4import { SharedMainModule } from '@app/shared/shared-main'
5import { StatsRoutingModule } from './stats-routing.module'
6import { 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})
27export 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 @@
1export * from './video-stats.component'
2export * 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
42my-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 @@
1import { ChartConfiguration, ChartData } from 'chart.js'
2import { Observable, of } from 'rxjs'
3import { Component, OnInit } from '@angular/core'
4import { ActivatedRoute } from '@angular/router'
5import { Notifier } from '@app/core'
6import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
7import { secondsToTime } from '@shared/core-utils'
8import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
9import { VideoStatsService } from './video-stats.service'
10
11type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
12
13type CountryData = { name: string, viewers: number }[]
14
15type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
16type 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})
27export 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 @@
1import { catchError } from 'rxjs'
2import { environment } from 'src/environments/environment'
3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { RestExtractor } from '@app/core'
6import { VideoService } from '@app/shared/shared-main'
7import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
8
9@Injectable({
10 providedIn: 'root'
11})
12export 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}
diff --git a/client/src/app/+video-studio/edit/index.ts b/client/src/app/+video-studio/edit/index.ts
index ff1d77fc0..15b57e1c8 100644
--- a/client/src/app/+video-studio/edit/index.ts
+++ b/client/src/app/+video-studio/edit/index.ts
@@ -1,2 +1 @@
1export * from './video-studio-edit.component' export * from './video-studio-edit.component'
2export * from './video-studio-edit.resolver'
diff --git a/client/src/app/+video-studio/video-studio-routing.module.ts b/client/src/app/+video-studio/video-studio-routing.module.ts
index bcd9b79a5..4c08631a1 100644
--- a/client/src/app/+video-studio/video-studio-routing.module.ts
+++ b/client/src/app/+video-studio/video-studio-routing.module.ts
@@ -1,6 +1,7 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { VideoStudioEditComponent, VideoStudioEditResolver } from './edit' 3import { VideoResolver } from '@app/shared/shared-main'
4import { VideoStudioEditComponent } from './edit'
4 5
5const videoStudioRoutes: Routes = [ 6const videoStudioRoutes: Routes = [
6 { 7 {
@@ -15,7 +16,7 @@ const videoStudioRoutes: Routes = [
15 } 16 }
16 }, 17 },
17 resolve: { 18 resolve: {
18 video: VideoStudioEditResolver 19 video: VideoResolver
19 } 20 }
20 } 21 }
21 ] 22 ]
diff --git a/client/src/app/+video-studio/video-studio.module.ts b/client/src/app/+video-studio/video-studio.module.ts
index 1a8763539..7c1dc02ea 100644
--- a/client/src/app/+video-studio/video-studio.module.ts
+++ b/client/src/app/+video-studio/video-studio.module.ts
@@ -1,7 +1,7 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { SharedFormModule } from '@app/shared/shared-forms' 2import { SharedFormModule } from '@app/shared/shared-forms'
3import { SharedMainModule } from '@app/shared/shared-main' 3import { SharedMainModule } from '@app/shared/shared-main'
4import { VideoStudioEditComponent, VideoStudioEditResolver } from './edit' 4import { VideoStudioEditComponent } from './edit'
5import { VideoStudioService } from './shared' 5import { VideoStudioService } from './shared'
6import { VideoStudioRoutingModule } from './video-studio-routing.module' 6import { VideoStudioRoutingModule } from './video-studio-routing.module'
7 7
@@ -20,8 +20,7 @@ import { VideoStudioRoutingModule } from './video-studio-routing.module'
20 exports: [], 20 exports: [],
21 21
22 providers: [ 22 providers: [
23 VideoStudioService, 23 VideoStudioService
24 VideoStudioEditResolver
25 ] 24 ]
26}) 25})
27export class VideoStudioModule { } 26export class VideoStudioModule { }
diff --git a/client/src/app/+videos/+video-edit/video-add.component.scss b/client/src/app/+videos/+video-edit/video-add.component.scss
index 0f0cc406c..dda868789 100644
--- a/client/src/app/+videos/+video-edit/video-add.component.scss
+++ b/client/src/app/+videos/+video-edit/video-add.component.scss
@@ -1,5 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3@use '_nav' as *;
3 4
4$border-width: 3px; 5$border-width: 3px;
5$border-type: solid; 6$border-type: solid;
@@ -51,39 +52,11 @@ $nav-link-height: 40px;
51} 52}
52 53
53::ng-deep .video-add-nav { 54::ng-deep .video-add-nav {
54 border-bottom: $border-width $border-type $border-color; 55 @include peertube-nav-tabs($border-width, $border-type, $border-color, $nav-link-height);
55 margin: 20px 0 0 !important;
56
57 &.hide-nav {
58 display: none !important;
59 }
60 56
61 a.nav-link { 57 a.nav-link {
62 @include disable-default-a-behaviour;
63
64 margin-bottom: -$border-width;
65 height: $nav-link-height !important;
66 padding: 0 30px !important;
67 font-size: 15px;
68
69 border: $border-width $border-type transparent;
70
71 span {
72 border-bottom: 2px solid transparent;
73 }
74
75 &.active { 58 &.active {
76 border-color: $border-color;
77 border-bottom-color: transparent;
78 background-color: pvar(--submenuBackgroundColor) !important; 59 background-color: pvar(--submenuBackgroundColor) !important;
79
80 span {
81 border-bottom-color: pvar(--mainColor);
82 }
83 }
84
85 &:hover:not(.active) {
86 border-color: transparent;
87 } 60 }
88 } 61 }
89} 62}
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index af26ea04d..51718827d 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -41,7 +41,8 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
41 report: true, 41 report: true,
42 duplicate: true, 42 duplicate: true,
43 mute: true, 43 mute: true,
44 liveInfo: true 44 liveInfo: true,
45 stats: true
45 } 46 }
46 47
47 userRating: UserVideoRateType 48 userRating: UserVideoRateType
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index f13c885f2..61b440859 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -553,9 +553,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
553 videoCaptions: VideoCaption[] 553 videoCaptions: VideoCaption[]
554 urlOptions: CustomizationOptions & { playerMode: PlayerMode } 554 urlOptions: CustomizationOptions & { playerMode: PlayerMode }
555 loggedInOrAnonymousUser: User 555 loggedInOrAnonymousUser: User
556 user?: AuthUser 556 user?: AuthUser // Keep for plugins
557 }) { 557 }) {
558 const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params 558 const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser } = params
559 559
560 const getStartTime = () => { 560 const getStartTime = () => {
561 const byUrl = urlOptions.startTime !== undefined 561 const byUrl = urlOptions.startTime !== undefined
@@ -615,6 +615,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
615 videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE 615 videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
616 ? this.videoService.getVideoViewUrl(video.uuid) 616 ? this.videoService.getVideoViewUrl(video.uuid)
617 : null, 617 : null,
618 authorizationHeader: this.authService.getRequestHeaderValue(),
619
618 embedUrl: video.embedUrl, 620 embedUrl: video.embedUrl,
619 embedTitle: video.name, 621 embedTitle: video.name,
620 622
@@ -623,13 +625,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
623 625
624 language: this.localeId, 626 language: this.localeId,
625 627
626 userWatching: user && user.videosHistoryEnabled === true
627 ? {
628 url: this.videoService.getUserWatchingVideoUrl(video.uuid),
629 authorizationHeader: this.authService.getRequestHeaderValue()
630 }
631 : undefined,
632
633 serverUrl: environment.apiUrl, 628 serverUrl: environment.apiUrl,
634 629
635 videoCaptions: playerCaptions, 630 videoCaptions: playerCaptions,
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index db48b2eea..a9d9c723a 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -151,6 +151,12 @@ const routes: Routes = [
151 canActivateChild: [ MetaGuard ] 151 canActivateChild: [ MetaGuard ]
152 }, 152 },
153 153
154 {
155 path: 'stats',
156 loadChildren: () => import('./+stats/stats.module').then(m => m.StatsModule),
157 canActivateChild: [ MetaGuard ]
158 },
159
154 // Matches /@:actorName 160 // Matches /@:actorName
155 { 161 {
156 matcher: (url): UrlMatchResult => { 162 matcher: (url): UrlMatchResult => {
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts
index a4c62c234..ba23edde0 100644
--- a/client/src/app/shared/shared-icons/global-icon.component.ts
+++ b/client/src/app/shared/shared-icons/global-icon.component.ts
@@ -75,7 +75,8 @@ const icons = {
75 'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default, 75 'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default,
76 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, 76 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
77 codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, 77 codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
78 award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default 78 award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default,
79 stats: require('!!raw-loader?!../../../assets/images/feather/stats.svg').default
79} 80}
80 81
81export type GlobalIconName = keyof typeof icons 82export type GlobalIconName = keyof typeof icons
diff --git a/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts b/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts
index 8badb1573..7c18b7f67 100644
--- a/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts
+++ b/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts
@@ -22,6 +22,7 @@ export class NumberFormatterPipe implements PipeTransform {
22 { max: 1000000, type: 'K' }, 22 { max: 1000000, type: 'K' },
23 { max: 1000000000, type: 'M' } 23 { max: 1000000000, type: 'M' }
24 ] 24 ]
25
25 constructor (@Inject(LOCALE_ID) private localeId: string) {} 26 constructor (@Inject(LOCALE_ID) private localeId: string) {}
26 27
27 transform (value: number) { 28 transform (value: number) {
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index d83af9a66..5629640bc 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -45,7 +45,7 @@ import {
45import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins' 45import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins'
46import { ActorRedirectGuard } from './router' 46import { ActorRedirectGuard } from './router'
47import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' 47import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
48import { EmbedComponent, RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' 48import { EmbedComponent, RedundancyService, VideoImportService, VideoOwnershipService, VideoResolver, VideoService } from './video'
49import { VideoCaptionService } from './video-caption' 49import { VideoCaptionService } from './video-caption'
50import { VideoChannelService } from './video-channel' 50import { VideoChannelService } from './video-channel'
51 51
@@ -190,6 +190,7 @@ import { VideoChannelService } from './video-channel'
190 VideoImportService, 190 VideoImportService,
191 VideoOwnershipService, 191 VideoOwnershipService,
192 VideoService, 192 VideoService,
193 VideoResolver,
193 194
194 VideoCaptionService, 195 VideoCaptionService,
195 196
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts
index e72c0c3d6..361601456 100644
--- a/client/src/app/shared/shared-main/video/index.ts
+++ b/client/src/app/shared/shared-main/video/index.ts
@@ -5,4 +5,5 @@ export * from './video-edit.model'
5export * from './video-import.service' 5export * from './video-import.service'
6export * from './video-ownership.service' 6export * from './video-ownership.service'
7export * from './video.model' 7export * from './video.model'
8export * from './video.resolver'
8export * from './video.service' 9export * from './video.service'
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts
index 2d4db9a28..022bb95ad 100644
--- a/client/src/app/shared/shared-main/video/video.model.ts
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -58,8 +58,7 @@ export class Video implements VideoServerModel {
58 url: string 58 url: string
59 59
60 views: number 60 views: number
61 // If live 61 viewers: number
62 viewers?: number
63 62
64 likes: number 63 likes: number
65 dislikes: number 64 dislikes: number
@@ -234,9 +233,13 @@ export class Video implements VideoServerModel {
234 this.isUpdatableBy(user) 233 this.isUpdatableBy(user)
235 } 234 }
236 235
236 canSeeStats (user: AuthUser) {
237 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS))
238 }
239
237 canRemoveFiles (user: AuthUser) { 240 canRemoveFiles (user: AuthUser) {
238 return this.isLocal && 241 return this.isLocal &&
239 user.hasRight(UserRight.MANAGE_VIDEO_FILES) && 242 user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&
240 this.state.id !== VideoState.TO_TRANSCODE && 243 this.state.id !== VideoState.TO_TRANSCODE &&
241 this.hasHLS() && 244 this.hasHLS() &&
242 this.hasWebTorrent() 245 this.hasWebTorrent()
@@ -244,7 +247,7 @@ export class Video implements VideoServerModel {
244 247
245 canRunTranscoding (user: AuthUser) { 248 canRunTranscoding (user: AuthUser) {
246 return this.isLocal && 249 return this.isLocal &&
247 user.hasRight(UserRight.RUN_VIDEO_TRANSCODING) && 250 user && user.hasRight(UserRight.RUN_VIDEO_TRANSCODING) &&
248 this.state.id !== VideoState.TO_TRANSCODE 251 this.state.id !== VideoState.TO_TRANSCODE
249 } 252 }
250 253
diff --git a/client/src/app/+video-studio/edit/video-studio-edit.resolver.ts b/client/src/app/shared/shared-main/video/video.resolver.ts
index c658be50b..65b7230ce 100644
--- a/client/src/app/+video-studio/edit/video-studio-edit.resolver.ts
+++ b/client/src/app/shared/shared-main/video/video.resolver.ts
@@ -1,10 +1,9 @@
1
2import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve } from '@angular/router' 2import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
4import { VideoService } from '@app/shared/shared-main' 3import { VideoService } from './video.service'
5 4
6@Injectable() 5@Injectable()
7export class VideoStudioEditResolver implements Resolve<any> { 6export class VideoResolver implements Resolve<any> {
8 constructor ( 7 constructor (
9 private videoService: VideoService 8 private videoService: VideoService
10 ) { 9 ) {
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index 94af9cd38..bc15c326f 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -65,10 +65,6 @@ export class VideoService {
65 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` 65 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
66 } 66 }
67 67
68 getUserWatchingVideoUrl (uuid: string) {
69 return `${VideoService.BASE_VIDEO_URL}/${uuid}/watching`
70 }
71
72 getVideo (options: { videoId: string }): Observable<VideoDetails> { 68 getVideo (options: { videoId: string }): Observable<VideoDetails> {
73 return this.serverService.getServerLocale() 69 return this.serverService.getServerLocale()
74 .pipe( 70 .pipe(
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index 5eef96145..ed6a4afc0 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -30,6 +30,7 @@ export type VideoActionsDisplayType = {
30 removeFiles?: boolean 30 removeFiles?: boolean
31 transcoding?: boolean 31 transcoding?: boolean
32 studio?: boolean 32 studio?: boolean
33 stats?: boolean
33} 34}
34 35
35@Component({ 36@Component({
@@ -61,9 +62,11 @@ export class VideoActionsDropdownComponent implements OnChanges {
61 liveInfo: false, 62 liveInfo: false,
62 removeFiles: false, 63 removeFiles: false,
63 transcoding: false, 64 transcoding: false,
64 studio: true 65 studio: true,
66 stats: true
65 } 67 }
66 @Input() placement = 'left' 68 @Input() placement = 'left'
69 @Input() moreActions: DropdownAction<{ video: Video }>[][] = []
67 70
68 @Input() label: string 71 @Input() label: string
69 72
@@ -156,6 +159,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
156 return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled) 159 return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled)
157 } 160 }
158 161
162 isVideoStatsAvailable () {
163 return this.video.canSeeStats(this.user)
164 }
165
159 isVideoRemovable () { 166 isVideoRemovable () {
160 return this.video.isRemovableBy(this.user) 167 return this.video.isRemovableBy(this.user)
161 } 168 }
@@ -343,6 +350,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
343 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.studio && this.isVideoEditable() 350 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.studio && this.isVideoEditable()
344 }, 351 },
345 { 352 {
353 label: $localize`Stats`,
354 linkBuilder: ({ video }) => [ '/stats/videos', video.uuid ],
355 iconName: 'stats',
356 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.stats && this.isVideoStatsAvailable()
357 },
358 {
346 label: $localize`Block`, 359 label: $localize`Block`,
347 handler: () => this.showBlockModal(), 360 handler: () => this.showBlockModal(),
348 iconName: 'no', 361 iconName: 'no',
@@ -408,5 +421,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
408 } 421 }
409 ] 422 ]
410 ] 423 ]
424
425 this.videoActions = this.videoActions.concat(this.moreActions)
411 } 426 }
412} 427}
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 7de9fc8e2..42c472579 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -49,7 +49,20 @@ export class VideoMiniatureComponent implements OnInit {
49 state: false, 49 state: false,
50 blacklistInfo: false 50 blacklistInfo: false
51 } 51 }
52
52 @Input() displayVideoActions = true 53 @Input() displayVideoActions = true
54 @Input() videoActionsDisplayOptions: VideoActionsDisplayType = {
55 playlist: true,
56 download: false,
57 update: true,
58 blacklist: true,
59 delete: true,
60 report: true,
61 duplicate: true,
62 mute: true,
63 studio: false,
64 stats: false
65 }
53 66
54 @Input() actorImageSize: ActorAvatarSize = '40' 67 @Input() actorImageSize: ActorAvatarSize = '40'
55 68
@@ -62,16 +75,6 @@ export class VideoMiniatureComponent implements OnInit {
62 @Output() videoRemoved = new EventEmitter() 75 @Output() videoRemoved = new EventEmitter()
63 @Output() videoAccountMuted = new EventEmitter() 76 @Output() videoAccountMuted = new EventEmitter()
64 77
65 videoActionsDisplayOptions: VideoActionsDisplayType = {
66 playlist: true,
67 download: false,
68 update: true,
69 blacklist: true,
70 delete: true,
71 report: true,
72 duplicate: true,
73 mute: true
74 }
75 showActions = false 78 showActions = false
76 serverConfig: HTMLServerConfig 79 serverConfig: HTMLServerConfig
77 80
diff --git a/client/src/assets/images/feather/stats.svg b/client/src/assets/images/feather/stats.svg
new file mode 100644
index 000000000..864167a6c
--- /dev/null
+++ b/client/src/assets/images/feather/stats.svg
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-bar-chart-2"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg> \ No newline at end of file
diff --git a/client/src/assets/player/shared/manager-options/manager-options-builder.ts b/client/src/assets/player/shared/manager-options/manager-options-builder.ts
index 29e851c1c..e454c719e 100644
--- a/client/src/assets/player/shared/manager-options/manager-options-builder.ts
+++ b/client/src/assets/player/shared/manager-options/manager-options-builder.ts
@@ -32,14 +32,18 @@ export class ManagerOptionsBuilder {
32 peertube: { 32 peertube: {
33 mode: this.mode, 33 mode: this.mode,
34 autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent 34 autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
35 videoViewUrl: commonOptions.videoViewUrl, 35
36 videoDuration: commonOptions.videoDuration, 36 ...pick(commonOptions, [
37 userWatching: commonOptions.userWatching, 37 'videoViewUrl',
38 subtitle: commonOptions.subtitle, 38 'authorizationHeader',
39 videoCaptions: commonOptions.videoCaptions, 39 'startTime',
40 stopTime: commonOptions.stopTime, 40 'videoDuration',
41 isLive: commonOptions.isLive, 41 'subtitle',
42 videoUUID: commonOptions.videoUUID 42 'videoCaptions',
43 'stopTime',
44 'isLive',
45 'videoUUID'
46 ])
43 } 47 }
44 } 48 }
45 49
diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts
index 1dc3e3de0..8b65903f9 100644
--- a/client/src/assets/player/shared/peertube/peertube-plugin.ts
+++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts
@@ -2,6 +2,7 @@ import debug from 'debug'
2import videojs from 'video.js' 2import videojs from 'video.js'
3import { isMobile } from '@root-helpers/web-browser' 3import { isMobile } from '@root-helpers/web-browser'
4import { timeToInt } from '@shared/core-utils' 4import { timeToInt } from '@shared/core-utils'
5import { VideoView, VideoViewEvent } from '@shared/models/videos'
5import { 6import {
6 getStoredLastSubtitle, 7 getStoredLastSubtitle,
7 getStoredMute, 8 getStoredMute,
@@ -11,7 +12,7 @@ import {
11 saveVideoWatchHistory, 12 saveVideoWatchHistory,
12 saveVolumeInStore 13 saveVolumeInStore
13} from '../../peertube-player-local-storage' 14} from '../../peertube-player-local-storage'
14import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types' 15import { PeerTubePluginOptions, VideoJSCaption } from '../../types'
15import { SettingsButton } from '../settings/settings-menu-button' 16import { SettingsButton } from '../settings/settings-menu-button'
16 17
17const logger = debug('peertube:player:peertube') 18const logger = debug('peertube:player:peertube')
@@ -20,18 +21,19 @@ const Plugin = videojs.getPlugin('plugin')
20 21
21class PeerTubePlugin extends Plugin { 22class PeerTubePlugin extends Plugin {
22 private readonly videoViewUrl: string 23 private readonly videoViewUrl: string
23 private readonly videoDuration: number 24 private readonly authorizationHeader: string
25
26 private readonly videoUUID: string
27 private readonly startTime: number
28
24 private readonly CONSTANTS = { 29 private readonly CONSTANTS = {
25 USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video 30 USER_VIEW_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
26 } 31 }
27 32
28 private videoCaptions: VideoJSCaption[] 33 private videoCaptions: VideoJSCaption[]
29 private defaultSubtitle: string 34 private defaultSubtitle: string
30 35
31 private videoViewInterval: any 36 private videoViewInterval: any
32 private userWatchingVideoInterval: any
33
34 private isLive: boolean
35 37
36 private menuOpened = false 38 private menuOpened = false
37 private mouseInControlBar = false 39 private mouseInControlBar = false
@@ -42,9 +44,11 @@ class PeerTubePlugin extends Plugin {
42 super(player) 44 super(player)
43 45
44 this.videoViewUrl = options.videoViewUrl 46 this.videoViewUrl = options.videoViewUrl
45 this.videoDuration = options.videoDuration 47 this.authorizationHeader = options.authorizationHeader
48 this.videoUUID = options.videoUUID
49 this.startTime = timeToInt(options.startTime)
50
46 this.videoCaptions = options.videoCaptions 51 this.videoCaptions = options.videoCaptions
47 this.isLive = options.isLive
48 this.initialInactivityTimeout = this.player.options_.inactivityTimeout 52 this.initialInactivityTimeout = this.player.options_.inactivityTimeout
49 53
50 if (options.autoplay) this.player.addClass('vjs-has-autoplay') 54 if (options.autoplay) this.player.addClass('vjs-has-autoplay')
@@ -101,15 +105,12 @@ class PeerTubePlugin extends Plugin {
101 this.player.duration(options.videoDuration) 105 this.player.duration(options.videoDuration)
102 106
103 this.initializePlayer() 107 this.initializePlayer()
104 this.runViewAdd() 108 this.runUserViewing()
105
106 this.runUserWatchVideo(options.userWatching, options.videoUUID)
107 }) 109 })
108 } 110 }
109 111
110 dispose () { 112 dispose () {
111 if (this.videoViewInterval) clearInterval(this.videoViewInterval) 113 if (this.videoViewInterval) clearInterval(this.videoViewInterval)
112 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
113 } 114 }
114 115
115 onMenuOpened () { 116 onMenuOpened () {
@@ -142,74 +143,65 @@ class PeerTubePlugin extends Plugin {
142 this.listenFullScreenChange() 143 this.listenFullScreenChange()
143 } 144 }
144 145
145 private runViewAdd () { 146 private runUserViewing () {
146 this.clearVideoViewInterval() 147 let lastCurrentTime = this.startTime
148 let lastViewEvent: VideoViewEvent
147 149
148 // After 30 seconds (or 3/4 of the video), add a view to the video 150 this.player.one('play', () => {
149 let minSecondsToView = 30 151 this.notifyUserIsWatching(this.startTime, lastViewEvent)
152 })
150 153
151 if (!this.isLive && this.videoDuration < minSecondsToView) { 154 this.player.on('seeked', () => {
152 minSecondsToView = (this.videoDuration * 3) / 4 155 // Don't take into account small seek events
153 } 156 if (Math.abs(this.player.currentTime() - lastCurrentTime) < 3) return
154 157
155 let secondsViewed = 0 158 lastViewEvent = 'seek'
156 this.videoViewInterval = setInterval(() => { 159 })
157 if (this.player && !this.player.paused()) {
158 secondsViewed += 1
159
160 if (secondsViewed > minSecondsToView) {
161 // Restart the loop if this is a live
162 if (this.isLive) {
163 secondsViewed = 0
164 } else {
165 this.clearVideoViewInterval()
166 }
167 160
168 this.addViewToVideo().catch(err => console.error(err)) 161 this.player.one('ended', () => {
169 } 162 const currentTime = Math.floor(this.player.duration())
170 } 163 lastCurrentTime = currentTime
171 }, 1000)
172 }
173 164
174 private runUserWatchVideo (options: UserWatching, videoUUID: string) { 165 this.notifyUserIsWatching(currentTime, lastViewEvent)
175 let lastCurrentTime = 0
176 166
177 this.userWatchingVideoInterval = setInterval(() => { 167 lastViewEvent = undefined
168 })
169
170 this.videoViewInterval = setInterval(() => {
178 const currentTime = Math.floor(this.player.currentTime()) 171 const currentTime = Math.floor(this.player.currentTime())
179 172
180 if (currentTime - lastCurrentTime >= 1) { 173 // No need to update
181 lastCurrentTime = currentTime 174 if (currentTime === lastCurrentTime) return
182 175
183 if (options) { 176 lastCurrentTime = currentTime
184 this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
185 .catch(err => console.error('Cannot notify user is watching.', err))
186 } else {
187 saveVideoWatchHistory(videoUUID, currentTime)
188 }
189 }
190 }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL)
191 }
192 177
193 private clearVideoViewInterval () { 178 this.notifyUserIsWatching(currentTime, lastViewEvent)
194 if (this.videoViewInterval !== undefined) { 179 .catch(err => console.error('Cannot notify user is watching.', err))
195 clearInterval(this.videoViewInterval) 180
196 this.videoViewInterval = undefined 181 lastViewEvent = undefined
197 } 182
183 // Server won't save history, so save the video position in local storage
184 if (!this.authorizationHeader) {
185 saveVideoWatchHistory(this.videoUUID, currentTime)
186 }
187 }, this.CONSTANTS.USER_VIEW_VIDEO_INTERVAL)
198 } 188 }
199 189
200 private addViewToVideo () { 190 private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
201 if (!this.videoViewUrl) return Promise.resolve(undefined) 191 if (!this.videoViewUrl) return Promise.resolve(undefined)
202 192
203 return fetch(this.videoViewUrl, { method: 'POST' }) 193 const body: VideoView = {
204 } 194 currentTime,
195 viewEvent
196 }
205 197
206 private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { 198 const headers = new Headers({
207 const body = new URLSearchParams() 199 'Content-type': 'application/json; charset=UTF-8'
208 body.append('currentTime', currentTime.toString()) 200 })
209 201
210 const headers = new Headers({ Authorization: authorizationHeader }) 202 if (this.authorizationHeader) headers.set('Authorization', this.authorizationHeader)
211 203
212 return fetch(url, { method: 'PUT', body, headers }) 204 return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers })
213 } 205 }
214 206
215 private listenFullScreenChange () { 207 private listenFullScreenChange () {
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
index b3ad7e337..456ef115e 100644
--- a/client/src/assets/player/types/manager-options.ts
+++ b/client/src/assets/player/types/manager-options.ts
@@ -1,6 +1,6 @@
1import { PluginsManager } from '@root-helpers/plugins-manager' 1import { PluginsManager } from '@root-helpers/plugins-manager'
2import { LiveVideoLatencyMode, VideoFile } from '@shared/models' 2import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
3import { PlaylistPluginOptions, UserWatching, VideoJSCaption } from './peertube-videojs-typings' 3import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings'
4 4
5export type PlayerMode = 'webtorrent' | 'p2p-media-loader' 5export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
6 6
@@ -53,6 +53,8 @@ export interface CommonOptions extends CustomizationOptions {
53 captions: boolean 53 captions: boolean
54 54
55 videoViewUrl: string 55 videoViewUrl: string
56 authorizationHeader?: string
57
56 embedUrl: string 58 embedUrl: string
57 embedTitle: string 59 embedTitle: string
58 60
@@ -68,8 +70,6 @@ export interface CommonOptions extends CustomizationOptions {
68 videoUUID: string 70 videoUUID: string
69 videoShortUUID: string 71 videoShortUUID: string
70 72
71 userWatching?: UserWatching
72
73 serverUrl: string 73 serverUrl: string
74 74
75 errorNotifier: (message: string) => void 75 errorNotifier: (message: string) => void
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index d9a388681..ad284a671 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -88,23 +88,20 @@ type VideoJSCaption = {
88 src: string 88 src: string
89} 89}
90 90
91type UserWatching = {
92 url: string
93 authorizationHeader: string
94}
95
96type PeerTubePluginOptions = { 91type PeerTubePluginOptions = {
97 mode: PlayerMode 92 mode: PlayerMode
98 93
99 autoplay: boolean 94 autoplay: boolean
100 videoViewUrl: string
101 videoDuration: number 95 videoDuration: number
102 96
103 userWatching?: UserWatching 97 videoViewUrl: string
98 authorizationHeader?: string
99
104 subtitle?: string 100 subtitle?: string
105 101
106 videoCaptions: VideoJSCaption[] 102 videoCaptions: VideoJSCaption[]
107 103
104 startTime: number | string
108 stopTime: number | string 105 stopTime: number | string
109 106
110 isLive: boolean 107 isLive: boolean
@@ -230,7 +227,6 @@ export {
230 AutoResolutionUpdateData, 227 AutoResolutionUpdateData,
231 PlaylistPluginOptions, 228 PlaylistPluginOptions,
232 VideoJSCaption, 229 VideoJSCaption,
233 UserWatching,
234 PeerTubePluginOptions, 230 PeerTubePluginOptions,
235 WebtorrentPluginOptions, 231 WebtorrentPluginOptions,
236 P2PMediaLoaderPluginOptions, 232 P2PMediaLoaderPluginOptions,
diff --git a/client/src/sass/include/_nav.scss b/client/src/sass/include/_nav.scss
new file mode 100644
index 000000000..d069ac9ae
--- /dev/null
+++ b/client/src/sass/include/_nav.scss
@@ -0,0 +1,44 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4@mixin peertube-nav-tabs (
5 $border-width: 3px,
6 $border-type: solid,
7 $border-color: #EAEAEA,
8 $nav-link-height: 40px
9) {
10 border-bottom: $border-width $border-type $border-color;
11 margin: 20px 0 0 !important;
12
13 &.hide-nav {
14 display: none !important;
15 }
16
17 a.nav-link {
18 @include disable-default-a-behaviour;
19
20 margin-bottom: -$border-width;
21 height: $nav-link-height !important;
22 padding: 0 30px !important;
23 font-size: 15px;
24
25 border: $border-width $border-type transparent;
26
27 span {
28 border-bottom: 2px solid transparent;
29 }
30
31 &.active {
32 border-color: $border-color;
33 border-bottom-color: transparent;
34
35 span {
36 border-bottom-color: pvar(--mainColor);
37 }
38 }
39
40 &:hover:not(.active) {
41 border-color: transparent;
42 }
43 }
44}