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
+
+ lastViewEvent = 'seek'
+ })
+
+ this.player.one('ended', () => {
+ const currentTime = Math.floor(this.player.duration())
+ lastCurrentTime = currentTime
+
+ this.notifyUserIsWatching(currentTime, lastViewEvent)
+
+ lastViewEvent = undefined
+ })
- 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()
- }
-
- this.addViewToVideo().catch(err => console.error(err))
- }
- }
- }, 1000)
- }
-
- private runUserWatchVideo (options: UserWatching, videoUUID: string) {
- let lastCurrentTime = 0
-
- this.userWatchingVideoInterval = 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)
- }
+ lastCurrentTime = currentTime
+
+ 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_WATCHING_VIDEO_INTERVAL)
+ }, this.CONSTANTS.USER_VIEW_VIDEO_INTERVAL)
}
- private clearVideoViewInterval () {
- if (this.videoViewInterval !== undefined) {
- clearInterval(this.videoViewInterval)
- this.videoViewInterval = undefined
- }
- }
-
- 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;
+ }
+ }
+}