From 384ba8b77a8e4805c099f5ea12b41c2ca5776e26 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 5 Apr 2022 14:03:52 +0200 Subject: Support videos stats in client --- .../+admin/overview/videos/video-list.component.ts | 4 +- .../+my-library/my-videos/my-videos.component.html | 6 +- .../+my-library/my-videos/my-videos.component.ts | 77 +++--- client/src/app/+stats/index.ts | 1 + client/src/app/+stats/stats-routing.module.ts | 25 ++ client/src/app/+stats/stats.module.ts | 27 ++ client/src/app/+stats/video/index.ts | 2 + .../app/+stats/video/video-stats.component.html | 38 +++ .../app/+stats/video/video-stats.component.scss | 54 ++++ .../src/app/+stats/video/video-stats.component.ts | 295 +++++++++++++++++++++ client/src/app/+stats/video/video-stats.service.ts | 34 +++ client/src/app/+video-studio/edit/index.ts | 1 - .../edit/video-studio-edit.resolver.ts | 18 -- .../+video-studio/video-studio-routing.module.ts | 5 +- .../src/app/+video-studio/video-studio.module.ts | 5 +- .../+videos/+video-edit/video-add.component.scss | 31 +-- .../action-buttons/action-buttons.component.ts | 3 +- .../+videos/+video-watch/video-watch.component.ts | 13 +- client/src/app/app-routing.module.ts | 6 + .../shared/shared-icons/global-icon.component.ts | 3 +- .../shared-main/angular/number-formatter.pipe.ts | 1 + .../app/shared/shared-main/shared-main.module.ts | 3 +- client/src/app/shared/shared-main/video/index.ts | 1 + .../app/shared/shared-main/video/video.model.ts | 11 +- .../app/shared/shared-main/video/video.resolver.ts | 17 ++ .../app/shared/shared-main/video/video.service.ts | 4 - .../video-actions-dropdown.component.ts | 17 +- .../video-miniature.component.ts | 23 +- 28 files changed, 593 insertions(+), 132 deletions(-) create mode 100644 client/src/app/+stats/index.ts create mode 100644 client/src/app/+stats/stats-routing.module.ts create mode 100644 client/src/app/+stats/stats.module.ts create mode 100644 client/src/app/+stats/video/index.ts create mode 100644 client/src/app/+stats/video/video-stats.component.html create mode 100644 client/src/app/+stats/video/video-stats.component.scss create mode 100644 client/src/app/+stats/video/video-stats.component.ts create mode 100644 client/src/app/+stats/video/video-stats.service.ts delete mode 100644 client/src/app/+video-studio/edit/video-studio-edit.resolver.ts create mode 100644 client/src/app/shared/shared-main/video/video.resolver.ts (limited to 'client/src/app') 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 { mute: true, liveInfo: false, removeFiles: true, - transcoding: true + transcoding: true, + studio: true, + stats: true } 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 @@
- +
- 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' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' -import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' +import { + MiniatureDisplayOptions, + SelectionType, + VideoActionsDisplayType, + VideosSelectionComponent +} from '@app/shared/shared-video-miniature' import { VideoChannel, VideoSortField } from '@shared/models' import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' @@ -37,8 +42,23 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { state: true, blacklistInfo: true } + videoDropdownDisplayOptions: VideoActionsDisplayType = { + playlist: false, + download: false, + update: false, + blacklist: false, + delete: true, + report: false, + duplicate: false, + mute: false, + liveInfo: false, + removeFiles: false, + transcoding: false, + studio: true, + stats: true + } - videoActions: DropdownAction<{ video: Video }>[] = [] + moreVideoActions: DropdownAction<{ video: Video }>[][] = [] videos: Video[] = [] getVideosObservableFunction = this.getVideosObservable.bind(this) @@ -172,60 +192,27 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { }) } - async deleteVideo (video: Video) { - const res = await this.confirmService.confirm( - $localize`Do you really want to delete ${video.name}?`, - $localize`Delete` - ) - if (res === false) return - - this.videoService.removeVideo(video.id) - .subscribe({ - next: () => { - this.notifier.success($localize`Video ${video.name} deleted.`) - this.removeVideoFromArray(video.id) - }, - - error: err => this.notifier.error(err.message) - }) + onVideoRemoved (video: Video) { + this.removeVideoFromArray(video.id) } changeOwnership (video: Video) { this.videoChangeOwnershipModal.show(video) } - displayLiveInformation (video: Video) { - this.liveStreamInformationModal.show(video) - } - private removeVideoFromArray (id: number) { this.videos = this.videos.filter(v => v.id !== id) } private buildActions () { - this.videoActions = [ - { - label: $localize`Studio`, - linkBuilder: ({ video }) => [ '/studio/edit', video.uuid ], - isDisplayed: ({ video }) => video.isEditableBy(this.authService.getUser(), this.serverService.getHTMLConfig().videoStudio.enabled), - iconName: 'film' - }, - { - label: $localize`Display live information`, - handler: ({ video }) => this.displayLiveInformation(video), - isDisplayed: ({ video }) => video.isLive, - iconName: 'live' - }, - { - label: $localize`Change ownership`, - handler: ({ video }) => this.changeOwnership(video), - iconName: 'ownership-change' - }, - { - label: $localize`Delete`, - handler: ({ video }) => this.deleteVideo(video), - iconName: 'delete' - } + this.moreVideoActions = [ + [ + { + label: $localize`Change ownership`, + handler: ({ video }) => this.changeOwnership(video), + iconName: 'ownership-change' + } + ] ] } } 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 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { VideoResolver } from '@app/shared/shared-main' +import { VideoStatsComponent } from './video' + +const statsRoutes: Routes = [ + { + path: 'videos/:videoId', + component: VideoStatsComponent, + data: { + meta: { + title: $localize`Video stats` + } + }, + resolve: { + video: VideoResolver + } + } +] + +@NgModule({ + imports: [ RouterModule.forChild(statsRoutes) ], + exports: [ RouterModule ] +}) +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 @@ +import { ChartModule } from 'primeng/chart' +import { NgModule } from '@angular/core' +import { SharedGlobalIconModule } from '@app/shared/shared-icons' +import { SharedMainModule } from '@app/shared/shared-main' +import { StatsRoutingModule } from './stats-routing.module' +import { VideoStatsComponent, VideoStatsService } from './video' + +@NgModule({ + imports: [ + StatsRoutingModule, + + SharedMainModule, + SharedGlobalIconModule, + + ChartModule + ], + + declarations: [ + VideoStatsComponent + ], + + exports: [], + providers: [ + VideoStatsService + ] +}) +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 @@ +export * from './video-stats.component' +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 @@ +
+

Stats for {{ video.name }}

+ +
+
+
+
{{ card.label }}
+
{{ card.value }}
+
{{ card.moreInfo }}
+
+
+ + +
+ +
+ + +
+
+
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 @@ +@use '_variables' as *; +@use '_mixins' as *; +@use '_nav' as *; + +.overall-stats-embed { + display: flex; + justify-content: space-between; +} + +.overall-stats { + display: flex; + flex-wrap: wrap; +} + +.overall-stats-card { + display: flex; + justify-content: center; + align-items: center; + height: fit-content; + min-height: 100px; + min-width: 200px; + margin-right: 15px; + background-color: pvar(--submenuBackgroundColor); + + .label, + .more-info { + font-size: 14px; + } + + .label { + color: pvar(--greyForegroundColor); + font-weight: $font-semibold; + opacity: 0.8; + } + + .value { + font-size: 24px; + font-weight: $font-semibold; + } +} + +my-embed { + display: block; + max-width: 500px; + width: 100%; +} + +.tab-content { + margin-top: 15px; +} + +.nav-tabs { + @include peertube-nav-tabs($border-width: 2px); +} 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 @@ +import { ChartConfiguration, ChartData } from 'chart.js' +import { Observable, of } from 'rxjs' +import { Component, OnInit } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { Notifier } from '@app/core' +import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' +import { secondsToTime } from '@shared/core-utils' +import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' +import { VideoStatsService } from './video-stats.service' + +type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' + +type CountryData = { name: string, viewers: number }[] + +type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData +type ChartBuilderResult = { + type: 'line' | 'bar' + data: ChartData<'line' | 'bar'> + displayLegend: boolean +} + +@Component({ + templateUrl: './video-stats.component.html', + styleUrls: [ './video-stats.component.scss' ], + providers: [ NumberFormatterPipe ] +}) +export class VideoStatsComponent implements OnInit { + overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = [] + + chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {} + chartHeight = '300px' + chartWidth: string = null + + availableCharts = [ + { + id: 'viewers', + label: $localize`Viewers` + }, + { + id: 'aggregateWatchTime', + label: $localize`Watch time` + }, + { + id: 'retention', + label: $localize`Retention` + }, + { + id: 'countries', + label: $localize`Countries` + } + ] + + activeGraphId: ActiveGraphId = 'viewers' + + video: VideoDetails + + countries: CountryData = [] + + constructor ( + private route: ActivatedRoute, + private notifier: Notifier, + private statsService: VideoStatsService, + private numberFormatter: NumberFormatterPipe + ) {} + + ngOnInit () { + this.video = this.route.snapshot.data.video + + this.loadOverallStats() + this.loadChart() + } + + hasCountries () { + return this.countries.length !== 0 + } + + onChartChange (newActive: ActiveGraphId) { + this.activeGraphId = newActive + + this.loadChart() + } + + private loadOverallStats () { + this.statsService.getOverallStats(this.video.uuid) + .subscribe({ + next: res => { + this.countries = res.countries.slice(0, 10).map(c => ({ + name: this.countryCodeToName(c.isoCode), + viewers: c.viewers + })) + + this.buildOverallStatCard(res) + }, + + error: err => this.notifier.error(err.message) + }) + } + + private buildOverallStatCard (overallStats: VideoStatsOverall) { + this.overallStatCards = [ + { + label: $localize`Views`, + value: this.numberFormatter.transform(overallStats.views) + }, + { + label: $localize`Comments`, + value: this.numberFormatter.transform(overallStats.comments) + }, + { + label: $localize`Likes`, + value: this.numberFormatter.transform(overallStats.likes) + }, + { + label: $localize`Average watch time`, + value: secondsToTime(overallStats.averageWatchTime) + }, + { + label: $localize`Peak viewers`, + value: this.numberFormatter.transform(overallStats.viewersPeak), + moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}` + } + ] + } + + private loadChart () { + const obsBuilders: { [ id in ActiveGraphId ]: Observable } = { + retention: this.statsService.getRetentionStats(this.video.uuid), + aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'), + viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'), + countries: of(this.countries) + } + + obsBuilders[this.activeGraphId].subscribe({ + next: res => { + this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res) + }, + + error: err => this.notifier.error(err.message) + }) + } + + private buildChartOptions ( + graphId: ActiveGraphId, + rawData: ChartIngestData + ): ChartConfiguration<'line' | 'bar'> { + const dataBuilders: { + [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult + } = { + retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData), + aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData), + viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData), + countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) + } + + const { type, data, displayLegend } = dataBuilders[graphId](rawData) + + return { + type, + data, + + options: { + responsive: true, + + scales: { + y: { + beginAtZero: true, + + max: this.activeGraphId === 'retention' + ? 100 + : undefined, + + ticks: { + callback: value => this.formatTick(graphId, value) + } + } + }, + + plugins: { + legend: { + display: displayLegend + }, + tooltip: { + callbacks: { + label: value => this.formatTick(graphId, value.raw as number | string) + } + } + } + } + } + } + + private buildRetentionChartOptions (rawData: VideoStatsRetention) { + const labels: string[] = [] + const data: number[] = [] + + for (const d of rawData.data) { + labels.push(secondsToTime(d.second)) + data.push(d.retentionPercent) + } + + return { + type: 'line' as 'line', + + displayLegend: false, + + data: { + labels, + datasets: [ + { + data, + borderColor: this.buildChartColor() + } + ] + } + } + } + + private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) { + const labels: string[] = [] + const data: number[] = [] + + for (const d of rawData.data) { + labels.push(new Date(d.date).toLocaleDateString()) + data.push(d.value) + } + + return { + type: 'line' as 'line', + + displayLegend: false, + + data: { + labels, + datasets: [ + { + data, + borderColor: this.buildChartColor() + } + ] + } + } + } + + private buildCountryChartOptions (rawData: CountryData) { + const labels: string[] = [] + const data: number[] = [] + + for (const d of rawData) { + labels.push(d.name) + data.push(d.viewers) + } + + return { + type: 'bar' as 'bar', + + displayLegend: true, + + options: { + indexAxis: 'y' + }, + + data: { + labels, + datasets: [ + { + label: $localize`Viewers`, + backgroundColor: this.buildChartColor(), + maxBarThickness: 20, + data + } + ] + } + } + } + + private buildChartColor () { + return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') + } + + private formatTick (graphId: ActiveGraphId, value: number | string) { + if (graphId === 'retention') return value + ' %' + if (graphId === 'aggregateWatchTime') return secondsToTime(+value) + + return value.toLocaleString() + } + + private countryCodeToName (code: string) { + const intl: any = Intl + if (!intl.DisplayNames) return code + + const regionNames = new intl.DisplayNames([], { type: 'region' }) + + return regionNames.of(code) + } +} 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 @@ +import { catchError } from 'rxjs' +import { environment } from 'src/environments/environment' +import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { RestExtractor } from '@app/core' +import { VideoService } from '@app/shared/shared-main' +import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' + +@Injectable({ + providedIn: 'root' +}) +export class VideoStatsService { + static BASE_VIDEO_STATS_URL = environment.apiUrl + '/api/v1/videos/' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor + ) { } + + getOverallStats (videoId: string) { + return this.authHttp.get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall') + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + getTimeserieStats (videoId: string, metric: VideoStatsTimeserieMetric) { + return this.authHttp.get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + + getRetentionStats (videoId: string) { + return this.authHttp.get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention') + .pipe(catchError(err => this.restExtractor.handleError(err))) + } +} 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 @@ export * from './video-studio-edit.component' -export * from './video-studio-edit.resolver' diff --git a/client/src/app/+video-studio/edit/video-studio-edit.resolver.ts b/client/src/app/+video-studio/edit/video-studio-edit.resolver.ts deleted file mode 100644 index c658be50b..000000000 --- a/client/src/app/+video-studio/edit/video-studio-edit.resolver.ts +++ /dev/null @@ -1,18 +0,0 @@ - -import { Injectable } from '@angular/core' -import { ActivatedRouteSnapshot, Resolve } from '@angular/router' -import { VideoService } from '@app/shared/shared-main' - -@Injectable() -export class VideoStudioEditResolver implements Resolve { - constructor ( - private videoService: VideoService - ) { - } - - resolve (route: ActivatedRouteSnapshot) { - const videoId: string = route.params['videoId'] - - return this.videoService.getVideo({ videoId }) - } -} 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 @@ import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' -import { VideoStudioEditComponent, VideoStudioEditResolver } from './edit' +import { VideoResolver } from '@app/shared/shared-main' +import { VideoStudioEditComponent } from './edit' const videoStudioRoutes: Routes = [ { @@ -15,7 +16,7 @@ const videoStudioRoutes: Routes = [ } }, resolve: { - video: VideoStudioEditResolver + video: VideoResolver } } ] 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 @@ import { NgModule } from '@angular/core' import { SharedFormModule } from '@app/shared/shared-forms' import { SharedMainModule } from '@app/shared/shared-main' -import { VideoStudioEditComponent, VideoStudioEditResolver } from './edit' +import { VideoStudioEditComponent } from './edit' import { VideoStudioService } from './shared' import { VideoStudioRoutingModule } from './video-studio-routing.module' @@ -20,8 +20,7 @@ import { VideoStudioRoutingModule } from './video-studio-routing.module' exports: [], providers: [ - VideoStudioService, - VideoStudioEditResolver + VideoStudioService ] }) 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 @@ @use '_variables' as *; @use '_mixins' as *; +@use '_nav' as *; $border-width: 3px; $border-type: solid; @@ -51,39 +52,11 @@ $nav-link-height: 40px; } ::ng-deep .video-add-nav { - border-bottom: $border-width $border-type $border-color; - margin: 20px 0 0 !important; - - &.hide-nav { - display: none !important; - } + @include peertube-nav-tabs($border-width, $border-type, $border-color, $nav-link-height); a.nav-link { - @include disable-default-a-behaviour; - - margin-bottom: -$border-width; - height: $nav-link-height !important; - padding: 0 30px !important; - font-size: 15px; - - border: $border-width $border-type transparent; - - span { - border-bottom: 2px solid transparent; - } - &.active { - border-color: $border-color; - border-bottom-color: transparent; background-color: pvar(--submenuBackgroundColor) !important; - - span { - border-bottom-color: pvar(--mainColor); - } - } - - &:hover:not(.active) { - border-color: transparent; } } } 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 { report: true, duplicate: true, mute: true, - liveInfo: true + liveInfo: true, + stats: true } 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 { videoCaptions: VideoCaption[] urlOptions: CustomizationOptions & { playerMode: PlayerMode } loggedInOrAnonymousUser: User - user?: AuthUser + user?: AuthUser // Keep for plugins }) { - const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params + const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser } = params const getStartTime = () => { const byUrl = urlOptions.startTime !== undefined @@ -615,6 +615,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(video.uuid) : null, + authorizationHeader: this.authService.getRequestHeaderValue(), + embedUrl: video.embedUrl, embedTitle: video.name, @@ -623,13 +625,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy { language: this.localeId, - userWatching: user && user.videosHistoryEnabled === true - ? { - url: this.videoService.getUserWatchingVideoUrl(video.uuid), - authorizationHeader: this.authService.getRequestHeaderValue() - } - : undefined, - serverUrl: environment.apiUrl, 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 = [ canActivateChild: [ MetaGuard ] }, + { + path: 'stats', + loadChildren: () => import('./+stats/stats.module').then(m => m.StatsModule), + canActivateChild: [ MetaGuard ] + }, + // Matches /@:actorName { 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 = { 'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default, 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, - award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default + award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default, + stats: require('!!raw-loader?!../../../assets/images/feather/stats.svg').default } 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 { { max: 1000000, type: 'K' }, { max: 1000000000, type: 'M' } ] + constructor (@Inject(LOCALE_ID) private localeId: string) {} 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 { import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins' import { ActorRedirectGuard } from './router' import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' -import { EmbedComponent, RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' +import { EmbedComponent, RedundancyService, VideoImportService, VideoOwnershipService, VideoResolver, VideoService } from './video' import { VideoCaptionService } from './video-caption' import { VideoChannelService } from './video-channel' @@ -190,6 +190,7 @@ import { VideoChannelService } from './video-channel' VideoImportService, VideoOwnershipService, VideoService, + VideoResolver, VideoCaptionService, 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' export * from './video-import.service' export * from './video-ownership.service' export * from './video.model' +export * from './video.resolver' 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 { url: string views: number - // If live - viewers?: number + viewers: number likes: number dislikes: number @@ -234,9 +233,13 @@ export class Video implements VideoServerModel { this.isUpdatableBy(user) } + canSeeStats (user: AuthUser) { + return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS)) + } + canRemoveFiles (user: AuthUser) { return this.isLocal && - user.hasRight(UserRight.MANAGE_VIDEO_FILES) && + user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) && this.state.id !== VideoState.TO_TRANSCODE && this.hasHLS() && this.hasWebTorrent() @@ -244,7 +247,7 @@ export class Video implements VideoServerModel { canRunTranscoding (user: AuthUser) { return this.isLocal && - user.hasRight(UserRight.RUN_VIDEO_TRANSCODING) && + user && user.hasRight(UserRight.RUN_VIDEO_TRANSCODING) && this.state.id !== VideoState.TO_TRANSCODE } diff --git a/client/src/app/shared/shared-main/video/video.resolver.ts b/client/src/app/shared/shared-main/video/video.resolver.ts new file mode 100644 index 000000000..65b7230ce --- /dev/null +++ b/client/src/app/shared/shared-main/video/video.resolver.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core' +import { ActivatedRouteSnapshot, Resolve } from '@angular/router' +import { VideoService } from './video.service' + +@Injectable() +export class VideoResolver implements Resolve { + constructor ( + private videoService: VideoService + ) { + } + + resolve (route: ActivatedRouteSnapshot) { + const videoId: string = route.params['videoId'] + + return this.videoService.getVideo({ videoId }) + } +} 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 { return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` } - getUserWatchingVideoUrl (uuid: string) { - return `${VideoService.BASE_VIDEO_URL}/${uuid}/watching` - } - getVideo (options: { videoId: string }): Observable { return this.serverService.getServerLocale() .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 = { removeFiles?: boolean transcoding?: boolean studio?: boolean + stats?: boolean } @Component({ @@ -61,9 +62,11 @@ export class VideoActionsDropdownComponent implements OnChanges { liveInfo: false, removeFiles: false, transcoding: false, - studio: true + studio: true, + stats: true } @Input() placement = 'left' + @Input() moreActions: DropdownAction<{ video: Video }>[][] = [] @Input() label: string @@ -156,6 +159,10 @@ export class VideoActionsDropdownComponent implements OnChanges { return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled) } + isVideoStatsAvailable () { + return this.video.canSeeStats(this.user) + } + isVideoRemovable () { return this.video.isRemovableBy(this.user) } @@ -342,6 +349,12 @@ export class VideoActionsDropdownComponent implements OnChanges { iconName: 'film', isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.studio && this.isVideoEditable() }, + { + label: $localize`Stats`, + linkBuilder: ({ video }) => [ '/stats/videos', video.uuid ], + iconName: 'stats', + isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.stats && this.isVideoStatsAvailable() + }, { label: $localize`Block`, handler: () => this.showBlockModal(), @@ -408,5 +421,7 @@ export class VideoActionsDropdownComponent implements OnChanges { } ] ] + + this.videoActions = this.videoActions.concat(this.moreActions) } } 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 { state: false, blacklistInfo: false } + @Input() displayVideoActions = true + @Input() videoActionsDisplayOptions: VideoActionsDisplayType = { + playlist: true, + download: false, + update: true, + blacklist: true, + delete: true, + report: true, + duplicate: true, + mute: true, + studio: false, + stats: false + } @Input() actorImageSize: ActorAvatarSize = '40' @@ -62,16 +75,6 @@ export class VideoMiniatureComponent implements OnInit { @Output() videoRemoved = new EventEmitter() @Output() videoAccountMuted = new EventEmitter() - videoActionsDisplayOptions: VideoActionsDisplayType = { - playlist: true, - download: false, - update: true, - blacklist: true, - delete: true, - report: true, - duplicate: true, - mute: true - } showActions = false serverConfig: HTMLServerConfig -- cgit v1.2.3