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 | |
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')
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' | |||
8 | import { AdvancedInputFilter } from '@app/shared/shared-forms' | 8 | import { AdvancedInputFilter } from '@app/shared/shared-forms' |
9 | import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' | 9 | import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' |
10 | import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' | 10 | import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' |
11 | import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' | 11 | import { |
12 | MiniatureDisplayOptions, | ||
13 | SelectionType, | ||
14 | VideoActionsDisplayType, | ||
15 | VideosSelectionComponent | ||
16 | } from '@app/shared/shared-video-miniature' | ||
12 | import { VideoChannel, VideoSortField } from '@shared/models' | 17 | import { VideoChannel, VideoSortField } from '@shared/models' |
13 | import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' | 18 | import { 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 @@ | |||
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 | } | ||
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 @@ | |||
1 | export * from './video-studio-edit.component' | export * from './video-studio-edit.component' | |
2 | export * 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 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouterModule, Routes } from '@angular/router' |
3 | import { VideoStudioEditComponent, VideoStudioEditResolver } from './edit' | 3 | import { VideoResolver } from '@app/shared/shared-main' |
4 | import { VideoStudioEditComponent } from './edit' | ||
4 | 5 | ||
5 | const videoStudioRoutes: Routes = [ | 6 | const 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 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { SharedFormModule } from '@app/shared/shared-forms' | 2 | import { SharedFormModule } from '@app/shared/shared-forms' |
3 | import { SharedMainModule } from '@app/shared/shared-main' | 3 | import { SharedMainModule } from '@app/shared/shared-main' |
4 | import { VideoStudioEditComponent, VideoStudioEditResolver } from './edit' | 4 | import { VideoStudioEditComponent } from './edit' |
5 | import { VideoStudioService } from './shared' | 5 | import { VideoStudioService } from './shared' |
6 | import { VideoStudioRoutingModule } from './video-studio-routing.module' | 6 | import { 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 | }) |
27 | export class VideoStudioModule { } | 26 | export 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 | ||
81 | export type GlobalIconName = keyof typeof icons | 82 | export 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 { | |||
45 | import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins' | 45 | import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins' |
46 | import { ActorRedirectGuard } from './router' | 46 | import { ActorRedirectGuard } from './router' |
47 | import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' | 47 | import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' |
48 | import { EmbedComponent, RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' | 48 | import { EmbedComponent, RedundancyService, VideoImportService, VideoOwnershipService, VideoResolver, VideoService } from './video' |
49 | import { VideoCaptionService } from './video-caption' | 49 | import { VideoCaptionService } from './video-caption' |
50 | import { VideoChannelService } from './video-channel' | 50 | import { 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' | |||
5 | export * from './video-import.service' | 5 | export * from './video-import.service' |
6 | export * from './video-ownership.service' | 6 | export * from './video-ownership.service' |
7 | export * from './video.model' | 7 | export * from './video.model' |
8 | export * from './video.resolver' | ||
8 | export * from './video.service' | 9 | export * 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 | |||
2 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
3 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' | 2 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' |
4 | import { VideoService } from '@app/shared/shared-main' | 3 | import { VideoService } from './video.service' |
5 | 4 | ||
6 | @Injectable() | 5 | @Injectable() |
7 | export class VideoStudioEditResolver implements Resolve<any> { | 6 | export 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' | |||
2 | import videojs from 'video.js' | 2 | import videojs from 'video.js' |
3 | import { isMobile } from '@root-helpers/web-browser' | 3 | import { isMobile } from '@root-helpers/web-browser' |
4 | import { timeToInt } from '@shared/core-utils' | 4 | import { timeToInt } from '@shared/core-utils' |
5 | import { VideoView, VideoViewEvent } from '@shared/models/videos' | ||
5 | import { | 6 | import { |
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' |
14 | import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types' | 15 | import { PeerTubePluginOptions, VideoJSCaption } from '../../types' |
15 | import { SettingsButton } from '../settings/settings-menu-button' | 16 | import { SettingsButton } from '../settings/settings-menu-button' |
16 | 17 | ||
17 | const logger = debug('peertube:player:peertube') | 18 | const logger = debug('peertube:player:peertube') |
@@ -20,18 +21,19 @@ const Plugin = videojs.getPlugin('plugin') | |||
20 | 21 | ||
21 | class PeerTubePlugin extends Plugin { | 22 | class 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 @@ | |||
1 | import { PluginsManager } from '@root-helpers/plugins-manager' | 1 | import { PluginsManager } from '@root-helpers/plugins-manager' |
2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' | 2 | import { LiveVideoLatencyMode, VideoFile } from '@shared/models' |
3 | import { PlaylistPluginOptions, UserWatching, VideoJSCaption } from './peertube-videojs-typings' | 3 | import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings' |
4 | 4 | ||
5 | export type PlayerMode = 'webtorrent' | 'p2p-media-loader' | 5 | export 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 | ||
91 | type UserWatching = { | ||
92 | url: string | ||
93 | authorizationHeader: string | ||
94 | } | ||
95 | |||
96 | type PeerTubePluginOptions = { | 91 | type 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 | } | ||