From 384ba8b77a8e4805c099f5ea12b41c2ca5776e26 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 5 Apr 2022 14:03:52 +0200 Subject: [PATCH] Support videos stats in client --- .../overview/videos/video-list.component.ts | 4 +- .../my-videos/my-videos.component.html | 6 +- .../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 + .../+stats/video/video-stats.component.html | 38 +++ .../+stats/video/video-stats.component.scss | 54 ++++ .../app/+stats/video/video-stats.component.ts | 295 ++++++++++++++++++ .../app/+stats/video/video-stats.service.ts | 34 ++ client/src/app/+video-studio/edit/index.ts | 1 - .../video-studio-routing.module.ts | 5 +- .../app/+video-studio/video-studio.module.ts | 5 +- .../+video-edit/video-add.component.scss | 31 +- .../action-buttons.component.ts | 3 +- .../+video-watch/video-watch.component.ts | 13 +- client/src/app/app-routing.module.ts | 6 + .../shared-icons/global-icon.component.ts | 3 +- .../angular/number-formatter.pipe.ts | 1 + .../shared/shared-main/shared-main.module.ts | 3 +- .../src/app/shared/shared-main/video/index.ts | 1 + .../shared/shared-main/video/video.model.ts | 11 +- .../shared-main/video/video.resolver.ts} | 5 +- .../shared/shared-main/video/video.service.ts | 4 - .../video-actions-dropdown.component.ts | 17 +- .../video-miniature.component.ts | 23 +- client/src/assets/images/feather/stats.svg | 1 + .../manager-options-builder.ts | 20 +- .../player/shared/peertube/peertube-plugin.ts | 118 ++++--- .../assets/player/types/manager-options.ts | 6 +- .../player/types/peertube-videojs-typings.ts | 12 +- client/src/sass/include/_nav.scss | 44 +++ 33 files changed, 697 insertions(+), 199 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 rename client/src/app/{+video-studio/edit/video-studio-edit.resolver.ts => shared/shared-main/video/video.resolver.ts} (74%) create mode 100644 client/src/assets/images/feather/stats.svg create mode 100644 client/src/sass/include/_nav.scss 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/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/+video-studio/edit/video-studio-edit.resolver.ts b/client/src/app/shared/shared-main/video/video.resolver.ts similarity index 74% rename from client/src/app/+video-studio/edit/video-studio-edit.resolver.ts rename to 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 @@ - import { Injectable } from '@angular/core' import { ActivatedRouteSnapshot, Resolve } from '@angular/router' -import { VideoService } from '@app/shared/shared-main' +import { VideoService } from './video.service' @Injectable() -export class VideoStudioEditResolver implements Resolve { +export class VideoResolver implements Resolve { constructor ( private videoService: VideoService ) { 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 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 @@ + \ 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 { peertube: { mode: this.mode, autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent - videoViewUrl: commonOptions.videoViewUrl, - videoDuration: commonOptions.videoDuration, - userWatching: commonOptions.userWatching, - subtitle: commonOptions.subtitle, - videoCaptions: commonOptions.videoCaptions, - stopTime: commonOptions.stopTime, - isLive: commonOptions.isLive, - videoUUID: commonOptions.videoUUID + + ...pick(commonOptions, [ + 'videoViewUrl', + 'authorizationHeader', + 'startTime', + 'videoDuration', + 'subtitle', + 'videoCaptions', + 'stopTime', + 'isLive', + 'videoUUID' + ]) } } 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' import videojs from 'video.js' import { isMobile } from '@root-helpers/web-browser' import { timeToInt } from '@shared/core-utils' +import { VideoView, VideoViewEvent } from '@shared/models/videos' import { getStoredLastSubtitle, getStoredMute, @@ -11,7 +12,7 @@ import { saveVideoWatchHistory, saveVolumeInStore } from '../../peertube-player-local-storage' -import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types' +import { PeerTubePluginOptions, VideoJSCaption } from '../../types' import { SettingsButton } from '../settings/settings-menu-button' const logger = debug('peertube:player:peertube') @@ -20,18 +21,19 @@ const Plugin = videojs.getPlugin('plugin') class PeerTubePlugin extends Plugin { private readonly videoViewUrl: string - private readonly videoDuration: number + private readonly authorizationHeader: string + + private readonly videoUUID: string + private readonly startTime: number + private readonly CONSTANTS = { - USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video + USER_VIEW_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video } private videoCaptions: VideoJSCaption[] private defaultSubtitle: string private videoViewInterval: any - private userWatchingVideoInterval: any - - private isLive: boolean private menuOpened = false private mouseInControlBar = false @@ -42,9 +44,11 @@ class PeerTubePlugin extends Plugin { super(player) this.videoViewUrl = options.videoViewUrl - this.videoDuration = options.videoDuration + this.authorizationHeader = options.authorizationHeader + this.videoUUID = options.videoUUID + this.startTime = timeToInt(options.startTime) + this.videoCaptions = options.videoCaptions - this.isLive = options.isLive this.initialInactivityTimeout = this.player.options_.inactivityTimeout if (options.autoplay) this.player.addClass('vjs-has-autoplay') @@ -101,15 +105,12 @@ class PeerTubePlugin extends Plugin { this.player.duration(options.videoDuration) this.initializePlayer() - this.runViewAdd() - - this.runUserWatchVideo(options.userWatching, options.videoUUID) + this.runUserViewing() }) } dispose () { if (this.videoViewInterval) clearInterval(this.videoViewInterval) - if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) } onMenuOpened () { @@ -142,74 +143,65 @@ class PeerTubePlugin extends Plugin { this.listenFullScreenChange() } - private runViewAdd () { - this.clearVideoViewInterval() + private runUserViewing () { + let lastCurrentTime = this.startTime + let lastViewEvent: VideoViewEvent - // After 30 seconds (or 3/4 of the video), add a view to the video - let minSecondsToView = 30 + this.player.one('play', () => { + this.notifyUserIsWatching(this.startTime, lastViewEvent) + }) - if (!this.isLive && this.videoDuration < minSecondsToView) { - minSecondsToView = (this.videoDuration * 3) / 4 - } + this.player.on('seeked', () => { + // Don't take into account small seek events + if (Math.abs(this.player.currentTime() - lastCurrentTime) < 3) return - let secondsViewed = 0 - this.videoViewInterval = setInterval(() => { - if (this.player && !this.player.paused()) { - secondsViewed += 1 - - if (secondsViewed > minSecondsToView) { - // Restart the loop if this is a live - if (this.isLive) { - secondsViewed = 0 - } else { - this.clearVideoViewInterval() - } + lastViewEvent = 'seek' + }) - this.addViewToVideo().catch(err => console.error(err)) - } - } - }, 1000) - } + this.player.one('ended', () => { + const currentTime = Math.floor(this.player.duration()) + lastCurrentTime = currentTime - private runUserWatchVideo (options: UserWatching, videoUUID: string) { - let lastCurrentTime = 0 + this.notifyUserIsWatching(currentTime, lastViewEvent) - this.userWatchingVideoInterval = setInterval(() => { + lastViewEvent = undefined + }) + + this.videoViewInterval = setInterval(() => { const currentTime = Math.floor(this.player.currentTime()) - if (currentTime - lastCurrentTime >= 1) { - lastCurrentTime = currentTime + // No need to update + if (currentTime === lastCurrentTime) return - if (options) { - this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader) - .catch(err => console.error('Cannot notify user is watching.', err)) - } else { - saveVideoWatchHistory(videoUUID, currentTime) - } - } - }, this.CONSTANTS.USER_WATCHING_VIDEO_INTERVAL) - } + lastCurrentTime = currentTime - private clearVideoViewInterval () { - if (this.videoViewInterval !== undefined) { - clearInterval(this.videoViewInterval) - this.videoViewInterval = undefined - } + this.notifyUserIsWatching(currentTime, lastViewEvent) + .catch(err => console.error('Cannot notify user is watching.', err)) + + lastViewEvent = undefined + + // Server won't save history, so save the video position in local storage + if (!this.authorizationHeader) { + saveVideoWatchHistory(this.videoUUID, currentTime) + } + }, this.CONSTANTS.USER_VIEW_VIDEO_INTERVAL) } - private addViewToVideo () { + private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { if (!this.videoViewUrl) return Promise.resolve(undefined) - return fetch(this.videoViewUrl, { method: 'POST' }) - } + const body: VideoView = { + currentTime, + viewEvent + } - private notifyUserIsWatching (currentTime: number, url: string, authorizationHeader: string) { - const body = new URLSearchParams() - body.append('currentTime', currentTime.toString()) + const headers = new Headers({ + 'Content-type': 'application/json; charset=UTF-8' + }) - const headers = new Headers({ Authorization: authorizationHeader }) + if (this.authorizationHeader) headers.set('Authorization', this.authorizationHeader) - return fetch(url, { method: 'PUT', body, headers }) + return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) } 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 @@ import { PluginsManager } from '@root-helpers/plugins-manager' import { LiveVideoLatencyMode, VideoFile } from '@shared/models' -import { PlaylistPluginOptions, UserWatching, VideoJSCaption } from './peertube-videojs-typings' +import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings' export type PlayerMode = 'webtorrent' | 'p2p-media-loader' @@ -53,6 +53,8 @@ export interface CommonOptions extends CustomizationOptions { captions: boolean videoViewUrl: string + authorizationHeader?: string + embedUrl: string embedTitle: string @@ -68,8 +70,6 @@ export interface CommonOptions extends CustomizationOptions { videoUUID: string videoShortUUID: string - userWatching?: UserWatching - serverUrl: string 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 = { src: string } -type UserWatching = { - url: string - authorizationHeader: string -} - type PeerTubePluginOptions = { mode: PlayerMode autoplay: boolean - videoViewUrl: string videoDuration: number - userWatching?: UserWatching + videoViewUrl: string + authorizationHeader?: string + subtitle?: string videoCaptions: VideoJSCaption[] + startTime: number | string stopTime: number | string isLive: boolean @@ -230,7 +227,6 @@ export { AutoResolutionUpdateData, PlaylistPluginOptions, VideoJSCaption, - UserWatching, PeerTubePluginOptions, WebtorrentPluginOptions, 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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +@mixin peertube-nav-tabs ( + $border-width: 3px, + $border-type: solid, + $border-color: #EAEAEA, + $nav-link-height: 40px +) { + border-bottom: $border-width $border-type $border-color; + margin: 20px 0 0 !important; + + &.hide-nav { + display: none !important; + } + + 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; + + span { + border-bottom-color: pvar(--mainColor); + } + } + + &:hover:not(.active) { + border-color: transparent; + } + } +} -- 2.41.0