Support videos stats in client
This commit is contained in:
parent
b211106695
commit
384ba8b77a
|
@ -41,7 +41,9 @@ export class VideoListComponent extends RestTable implements OnInit {
|
||||||
mute: true,
|
mute: true,
|
||||||
liveInfo: false,
|
liveInfo: false,
|
||||||
removeFiles: true,
|
removeFiles: true,
|
||||||
transcoding: true
|
transcoding: true,
|
||||||
|
studio: true,
|
||||||
|
stats: true
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
|
@ -55,10 +55,12 @@
|
||||||
<div class="action-button">
|
<div class="action-button">
|
||||||
<my-edit-button label [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
|
<my-edit-button label [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
|
||||||
|
|
||||||
<my-action-dropdown [actions]="videoActions" [entry]="{ video: video }"></my-action-dropdown>
|
<my-video-actions-dropdown
|
||||||
|
[video]="video" [displayOptions]="videoDropdownDisplayOptions" [moreActions]="moreVideoActions"
|
||||||
|
[buttonStyled]="true" buttonDirection="horizontal" (videoRemoved)="onVideoRemoved(video)"
|
||||||
|
></my-video-actions-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</my-videos-selection>
|
</my-videos-selection>
|
||||||
|
|
||||||
<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
|
<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
|
||||||
<my-live-stream-information #liveStreamInformationModal></my-live-stream-information>
|
|
||||||
|
|
|
@ -8,7 +8,12 @@ import { immutableAssign } from '@app/helpers'
|
||||||
import { AdvancedInputFilter } from '@app/shared/shared-forms'
|
import { AdvancedInputFilter } from '@app/shared/shared-forms'
|
||||||
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
|
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
|
||||||
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
|
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 { VideoChannel, VideoSortField } from '@shared/models'
|
||||||
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
||||||
|
|
||||||
|
@ -37,8 +42,23 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
|
||||||
state: true,
|
state: true,
|
||||||
blacklistInfo: 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[] = []
|
videos: Video[] = []
|
||||||
getVideosObservableFunction = this.getVideosObservable.bind(this)
|
getVideosObservableFunction = this.getVideosObservable.bind(this)
|
||||||
|
@ -172,60 +192,27 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteVideo (video: Video) {
|
onVideoRemoved (video: Video) {
|
||||||
const res = await this.confirmService.confirm(
|
this.removeVideoFromArray(video.id)
|
||||||
$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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
changeOwnership (video: Video) {
|
changeOwnership (video: Video) {
|
||||||
this.videoChangeOwnershipModal.show(video)
|
this.videoChangeOwnershipModal.show(video)
|
||||||
}
|
}
|
||||||
|
|
||||||
displayLiveInformation (video: Video) {
|
|
||||||
this.liveStreamInformationModal.show(video)
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeVideoFromArray (id: number) {
|
private removeVideoFromArray (id: number) {
|
||||||
this.videos = this.videos.filter(v => v.id !== id)
|
this.videos = this.videos.filter(v => v.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildActions () {
|
private buildActions () {
|
||||||
this.videoActions = [
|
this.moreVideoActions = [
|
||||||
{
|
[
|
||||||
label: $localize`Studio`,
|
{
|
||||||
linkBuilder: ({ video }) => [ '/studio/edit', video.uuid ],
|
label: $localize`Change ownership`,
|
||||||
isDisplayed: ({ video }) => video.isEditableBy(this.authService.getUser(), this.serverService.getHTMLConfig().videoStudio.enabled),
|
handler: ({ video }) => this.changeOwnership(video),
|
||||||
iconName: 'film'
|
iconName: 'ownership-change'
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
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'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './stats.module'
|
|
@ -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 {}
|
|
@ -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 { }
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './video-stats.component'
|
||||||
|
export * from './video-stats.service'
|
|
@ -0,0 +1,38 @@
|
||||||
|
<div class="margin-content">
|
||||||
|
<h1 class="title-page title-page-single" i18n>Stats for {{ video.name }}</h1>
|
||||||
|
|
||||||
|
<div class="overall-stats-embed">
|
||||||
|
<div class="overall-stats">
|
||||||
|
<div *ngFor="let card of overallStatCards" class="card overall-stats-card">
|
||||||
|
<div class="label">{{ card.label }}</div>
|
||||||
|
<div class="value">{{ card.value }}</div>
|
||||||
|
<div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-embed [video]="video"></my-embed>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeserie">
|
||||||
|
<div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
|
||||||
|
|
||||||
|
<ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
|
||||||
|
<a ngbNavLink i18n>
|
||||||
|
<span>{{ availableChart.label }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div [ngStyle]="{ 'min-height': chartHeight }">
|
||||||
|
<p-chart
|
||||||
|
*ngIf="chartOptions[availableChart.id]"
|
||||||
|
[height]="chartHeight" [width]="chartWidth"
|
||||||
|
[type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
|
||||||
|
></p-chart>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div [ngbNavOutlet]="nav"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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);
|
||||||
|
}
|
|
@ -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<ChartIngestData> } = {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall')
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeserieStats (videoId: string, metric: VideoStatsTimeserieMetric) {
|
||||||
|
return this.authHttp.get<VideoStatsTimeserie>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric)
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
|
getRetentionStats (videoId: string) {
|
||||||
|
return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,2 +1 @@
|
||||||
export * from './video-studio-edit.component'
|
export * from './video-studio-edit.component'
|
||||||
export * from './video-studio-edit.resolver'
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { RouterModule, Routes } from '@angular/router'
|
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 = [
|
const videoStudioRoutes: Routes = [
|
||||||
{
|
{
|
||||||
|
@ -15,7 +16,7 @@ const videoStudioRoutes: Routes = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
video: VideoStudioEditResolver
|
video: VideoResolver
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { SharedFormModule } from '@app/shared/shared-forms'
|
import { SharedFormModule } from '@app/shared/shared-forms'
|
||||||
import { SharedMainModule } from '@app/shared/shared-main'
|
import { SharedMainModule } from '@app/shared/shared-main'
|
||||||
import { VideoStudioEditComponent, VideoStudioEditResolver } from './edit'
|
import { VideoStudioEditComponent } from './edit'
|
||||||
import { VideoStudioService } from './shared'
|
import { VideoStudioService } from './shared'
|
||||||
import { VideoStudioRoutingModule } from './video-studio-routing.module'
|
import { VideoStudioRoutingModule } from './video-studio-routing.module'
|
||||||
|
|
||||||
|
@ -20,8 +20,7 @@ import { VideoStudioRoutingModule } from './video-studio-routing.module'
|
||||||
exports: [],
|
exports: [],
|
||||||
|
|
||||||
providers: [
|
providers: [
|
||||||
VideoStudioService,
|
VideoStudioService
|
||||||
VideoStudioEditResolver
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class VideoStudioModule { }
|
export class VideoStudioModule { }
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
@use '_variables' as *;
|
@use '_variables' as *;
|
||||||
@use '_mixins' as *;
|
@use '_mixins' as *;
|
||||||
|
@use '_nav' as *;
|
||||||
|
|
||||||
$border-width: 3px;
|
$border-width: 3px;
|
||||||
$border-type: solid;
|
$border-type: solid;
|
||||||
|
@ -51,39 +52,11 @@ $nav-link-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .video-add-nav {
|
::ng-deep .video-add-nav {
|
||||||
border-bottom: $border-width $border-type $border-color;
|
@include peertube-nav-tabs($border-width, $border-type, $border-color, $nav-link-height);
|
||||||
margin: 20px 0 0 !important;
|
|
||||||
|
|
||||||
&.hide-nav {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.nav-link {
|
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 {
|
&.active {
|
||||||
border-color: $border-color;
|
|
||||||
border-bottom-color: transparent;
|
|
||||||
background-color: pvar(--submenuBackgroundColor) !important;
|
background-color: pvar(--submenuBackgroundColor) !important;
|
||||||
|
|
||||||
span {
|
|
||||||
border-bottom-color: pvar(--mainColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover:not(.active) {
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,8 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||||
report: true,
|
report: true,
|
||||||
duplicate: true,
|
duplicate: true,
|
||||||
mute: true,
|
mute: true,
|
||||||
liveInfo: true
|
liveInfo: true,
|
||||||
|
stats: true
|
||||||
}
|
}
|
||||||
|
|
||||||
userRating: UserVideoRateType
|
userRating: UserVideoRateType
|
||||||
|
|
|
@ -553,9 +553,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
|
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
|
||||||
loggedInOrAnonymousUser: User
|
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 getStartTime = () => {
|
||||||
const byUrl = urlOptions.startTime !== undefined
|
const byUrl = urlOptions.startTime !== undefined
|
||||||
|
@ -615,6 +615,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
|
videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
|
||||||
? this.videoService.getVideoViewUrl(video.uuid)
|
? this.videoService.getVideoViewUrl(video.uuid)
|
||||||
: null,
|
: null,
|
||||||
|
authorizationHeader: this.authService.getRequestHeaderValue(),
|
||||||
|
|
||||||
embedUrl: video.embedUrl,
|
embedUrl: video.embedUrl,
|
||||||
embedTitle: video.name,
|
embedTitle: video.name,
|
||||||
|
|
||||||
|
@ -623,13 +625,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
language: this.localeId,
|
language: this.localeId,
|
||||||
|
|
||||||
userWatching: user && user.videosHistoryEnabled === true
|
|
||||||
? {
|
|
||||||
url: this.videoService.getUserWatchingVideoUrl(video.uuid),
|
|
||||||
authorizationHeader: this.authService.getRequestHeaderValue()
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
serverUrl: environment.apiUrl,
|
serverUrl: environment.apiUrl,
|
||||||
|
|
||||||
videoCaptions: playerCaptions,
|
videoCaptions: playerCaptions,
|
||||||
|
|
|
@ -151,6 +151,12 @@ const routes: Routes = [
|
||||||
canActivateChild: [ MetaGuard ]
|
canActivateChild: [ MetaGuard ]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'stats',
|
||||||
|
loadChildren: () => import('./+stats/stats.module').then(m => m.StatsModule),
|
||||||
|
canActivateChild: [ MetaGuard ]
|
||||||
|
},
|
||||||
|
|
||||||
// Matches /@:actorName
|
// Matches /@:actorName
|
||||||
{
|
{
|
||||||
matcher: (url): UrlMatchResult => {
|
matcher: (url): UrlMatchResult => {
|
||||||
|
|
|
@ -75,7 +75,8 @@ const icons = {
|
||||||
'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default,
|
'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default,
|
||||||
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
||||||
codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.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
|
export type GlobalIconName = keyof typeof icons
|
||||||
|
|
|
@ -22,6 +22,7 @@ export class NumberFormatterPipe implements PipeTransform {
|
||||||
{ max: 1000000, type: 'K' },
|
{ max: 1000000, type: 'K' },
|
||||||
{ max: 1000000000, type: 'M' }
|
{ max: 1000000000, type: 'M' }
|
||||||
]
|
]
|
||||||
|
|
||||||
constructor (@Inject(LOCALE_ID) private localeId: string) {}
|
constructor (@Inject(LOCALE_ID) private localeId: string) {}
|
||||||
|
|
||||||
transform (value: number) {
|
transform (value: number) {
|
||||||
|
|
|
@ -45,7 +45,7 @@ import {
|
||||||
import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins'
|
import { PluginPlaceholderComponent, PluginSelectorDirective } from './plugins'
|
||||||
import { ActorRedirectGuard } from './router'
|
import { ActorRedirectGuard } from './router'
|
||||||
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
|
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 { VideoCaptionService } from './video-caption'
|
||||||
import { VideoChannelService } from './video-channel'
|
import { VideoChannelService } from './video-channel'
|
||||||
|
|
||||||
|
@ -190,6 +190,7 @@ import { VideoChannelService } from './video-channel'
|
||||||
VideoImportService,
|
VideoImportService,
|
||||||
VideoOwnershipService,
|
VideoOwnershipService,
|
||||||
VideoService,
|
VideoService,
|
||||||
|
VideoResolver,
|
||||||
|
|
||||||
VideoCaptionService,
|
VideoCaptionService,
|
||||||
|
|
||||||
|
|
|
@ -5,4 +5,5 @@ export * from './video-edit.model'
|
||||||
export * from './video-import.service'
|
export * from './video-import.service'
|
||||||
export * from './video-ownership.service'
|
export * from './video-ownership.service'
|
||||||
export * from './video.model'
|
export * from './video.model'
|
||||||
|
export * from './video.resolver'
|
||||||
export * from './video.service'
|
export * from './video.service'
|
||||||
|
|
|
@ -58,8 +58,7 @@ export class Video implements VideoServerModel {
|
||||||
url: string
|
url: string
|
||||||
|
|
||||||
views: number
|
views: number
|
||||||
// If live
|
viewers: number
|
||||||
viewers?: number
|
|
||||||
|
|
||||||
likes: number
|
likes: number
|
||||||
dislikes: number
|
dislikes: number
|
||||||
|
@ -234,9 +233,13 @@ export class Video implements VideoServerModel {
|
||||||
this.isUpdatableBy(user)
|
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) {
|
canRemoveFiles (user: AuthUser) {
|
||||||
return this.isLocal &&
|
return this.isLocal &&
|
||||||
user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&
|
user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&
|
||||||
this.state.id !== VideoState.TO_TRANSCODE &&
|
this.state.id !== VideoState.TO_TRANSCODE &&
|
||||||
this.hasHLS() &&
|
this.hasHLS() &&
|
||||||
this.hasWebTorrent()
|
this.hasWebTorrent()
|
||||||
|
@ -244,7 +247,7 @@ export class Video implements VideoServerModel {
|
||||||
|
|
||||||
canRunTranscoding (user: AuthUser) {
|
canRunTranscoding (user: AuthUser) {
|
||||||
return this.isLocal &&
|
return this.isLocal &&
|
||||||
user.hasRight(UserRight.RUN_VIDEO_TRANSCODING) &&
|
user && user.hasRight(UserRight.RUN_VIDEO_TRANSCODING) &&
|
||||||
this.state.id !== VideoState.TO_TRANSCODE
|
this.state.id !== VideoState.TO_TRANSCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
|
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
|
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
|
||||||
import { VideoService } from '@app/shared/shared-main'
|
import { VideoService } from './video.service'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoStudioEditResolver implements Resolve<any> {
|
export class VideoResolver implements Resolve<any> {
|
||||||
constructor (
|
constructor (
|
||||||
private videoService: VideoService
|
private videoService: VideoService
|
||||||
) {
|
) {
|
|
@ -65,10 +65,6 @@ export class VideoService {
|
||||||
return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
|
return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserWatchingVideoUrl (uuid: string) {
|
|
||||||
return `${VideoService.BASE_VIDEO_URL}/${uuid}/watching`
|
|
||||||
}
|
|
||||||
|
|
||||||
getVideo (options: { videoId: string }): Observable<VideoDetails> {
|
getVideo (options: { videoId: string }): Observable<VideoDetails> {
|
||||||
return this.serverService.getServerLocale()
|
return this.serverService.getServerLocale()
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|
|
@ -30,6 +30,7 @@ export type VideoActionsDisplayType = {
|
||||||
removeFiles?: boolean
|
removeFiles?: boolean
|
||||||
transcoding?: boolean
|
transcoding?: boolean
|
||||||
studio?: boolean
|
studio?: boolean
|
||||||
|
stats?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -61,9 +62,11 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
liveInfo: false,
|
liveInfo: false,
|
||||||
removeFiles: false,
|
removeFiles: false,
|
||||||
transcoding: false,
|
transcoding: false,
|
||||||
studio: true
|
studio: true,
|
||||||
|
stats: true
|
||||||
}
|
}
|
||||||
@Input() placement = 'left'
|
@Input() placement = 'left'
|
||||||
|
@Input() moreActions: DropdownAction<{ video: Video }>[][] = []
|
||||||
|
|
||||||
@Input() label: string
|
@Input() label: string
|
||||||
|
|
||||||
|
@ -156,6 +159,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled)
|
return this.video.isEditableBy(this.user, this.serverService.getHTMLConfig().videoStudio.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isVideoStatsAvailable () {
|
||||||
|
return this.video.canSeeStats(this.user)
|
||||||
|
}
|
||||||
|
|
||||||
isVideoRemovable () {
|
isVideoRemovable () {
|
||||||
return this.video.isRemovableBy(this.user)
|
return this.video.isRemovableBy(this.user)
|
||||||
}
|
}
|
||||||
|
@ -342,6 +349,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
iconName: 'film',
|
iconName: 'film',
|
||||||
isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.studio && this.isVideoEditable()
|
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`,
|
label: $localize`Block`,
|
||||||
handler: () => this.showBlockModal(),
|
handler: () => this.showBlockModal(),
|
||||||
|
@ -408,5 +421,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
this.videoActions = this.videoActions.concat(this.moreActions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,20 @@ export class VideoMiniatureComponent implements OnInit {
|
||||||
state: false,
|
state: false,
|
||||||
blacklistInfo: false
|
blacklistInfo: false
|
||||||
}
|
}
|
||||||
|
|
||||||
@Input() displayVideoActions = true
|
@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'
|
@Input() actorImageSize: ActorAvatarSize = '40'
|
||||||
|
|
||||||
|
@ -62,16 +75,6 @@ export class VideoMiniatureComponent implements OnInit {
|
||||||
@Output() videoRemoved = new EventEmitter()
|
@Output() videoRemoved = new EventEmitter()
|
||||||
@Output() videoAccountMuted = 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
|
showActions = false
|
||||||
serverConfig: HTMLServerConfig
|
serverConfig: HTMLServerConfig
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-bar-chart-2"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>
|
After Width: | Height: | Size: 355 B |
|
@ -32,14 +32,18 @@ export class ManagerOptionsBuilder {
|
||||||
peertube: {
|
peertube: {
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
|
autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
|
||||||
videoViewUrl: commonOptions.videoViewUrl,
|
|
||||||
videoDuration: commonOptions.videoDuration,
|
...pick(commonOptions, [
|
||||||
userWatching: commonOptions.userWatching,
|
'videoViewUrl',
|
||||||
subtitle: commonOptions.subtitle,
|
'authorizationHeader',
|
||||||
videoCaptions: commonOptions.videoCaptions,
|
'startTime',
|
||||||
stopTime: commonOptions.stopTime,
|
'videoDuration',
|
||||||
isLive: commonOptions.isLive,
|
'subtitle',
|
||||||
videoUUID: commonOptions.videoUUID
|
'videoCaptions',
|
||||||
|
'stopTime',
|
||||||
|
'isLive',
|
||||||
|
'videoUUID'
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import debug from 'debug'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import { isMobile } from '@root-helpers/web-browser'
|
import { isMobile } from '@root-helpers/web-browser'
|
||||||
import { timeToInt } from '@shared/core-utils'
|
import { timeToInt } from '@shared/core-utils'
|
||||||
|
import { VideoView, VideoViewEvent } from '@shared/models/videos'
|
||||||
import {
|
import {
|
||||||
getStoredLastSubtitle,
|
getStoredLastSubtitle,
|
||||||
getStoredMute,
|
getStoredMute,
|
||||||
|
@ -11,7 +12,7 @@ import {
|
||||||
saveVideoWatchHistory,
|
saveVideoWatchHistory,
|
||||||
saveVolumeInStore
|
saveVolumeInStore
|
||||||
} from '../../peertube-player-local-storage'
|
} from '../../peertube-player-local-storage'
|
||||||
import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types'
|
import { PeerTubePluginOptions, VideoJSCaption } from '../../types'
|
||||||
import { SettingsButton } from '../settings/settings-menu-button'
|
import { SettingsButton } from '../settings/settings-menu-button'
|
||||||
|
|
||||||
const logger = debug('peertube:player:peertube')
|
const logger = debug('peertube:player:peertube')
|
||||||
|
@ -20,18 +21,19 @@ const Plugin = videojs.getPlugin('plugin')
|
||||||
|
|
||||||
class PeerTubePlugin extends Plugin {
|
class PeerTubePlugin extends Plugin {
|
||||||
private readonly videoViewUrl: string
|
private readonly videoViewUrl: string
|
||||||
private readonly videoDuration: number
|
private readonly authorizationHeader: string
|
||||||
|
|
||||||
|
private readonly videoUUID: string
|
||||||
|
private readonly startTime: number
|
||||||
|
|
||||||
private readonly CONSTANTS = {
|
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 videoCaptions: VideoJSCaption[]
|
||||||
private defaultSubtitle: string
|
private defaultSubtitle: string
|
||||||
|
|
||||||
private videoViewInterval: any
|
private videoViewInterval: any
|
||||||
private userWatchingVideoInterval: any
|
|
||||||
|
|
||||||
private isLive: boolean
|
|
||||||
|
|
||||||
private menuOpened = false
|
private menuOpened = false
|
||||||
private mouseInControlBar = false
|
private mouseInControlBar = false
|
||||||
|
@ -42,9 +44,11 @@ class PeerTubePlugin extends Plugin {
|
||||||
super(player)
|
super(player)
|
||||||
|
|
||||||
this.videoViewUrl = options.videoViewUrl
|
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.videoCaptions = options.videoCaptions
|
||||||
this.isLive = options.isLive
|
|
||||||
this.initialInactivityTimeout = this.player.options_.inactivityTimeout
|
this.initialInactivityTimeout = this.player.options_.inactivityTimeout
|
||||||
|
|
||||||
if (options.autoplay) this.player.addClass('vjs-has-autoplay')
|
if (options.autoplay) this.player.addClass('vjs-has-autoplay')
|
||||||
|
@ -101,15 +105,12 @@ class PeerTubePlugin extends Plugin {
|
||||||
this.player.duration(options.videoDuration)
|
this.player.duration(options.videoDuration)
|
||||||
|
|
||||||
this.initializePlayer()
|
this.initializePlayer()
|
||||||
this.runViewAdd()
|
this.runUserViewing()
|
||||||
|
|
||||||
this.runUserWatchVideo(options.userWatching, options.videoUUID)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose () {
|
dispose () {
|
||||||
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
|
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
|
||||||
if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMenuOpened () {
|
onMenuOpened () {
|
||||||
|
@ -142,74 +143,65 @@ class PeerTubePlugin extends Plugin {
|
||||||
this.listenFullScreenChange()
|
this.listenFullScreenChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
private runViewAdd () {
|
private runUserViewing () {
|
||||||
this.clearVideoViewInterval()
|
let lastCurrentTime = this.startTime
|
||||||
|
let lastViewEvent: VideoViewEvent
|
||||||
|
|
||||||
// After 30 seconds (or 3/4 of the video), add a view to the video
|
this.player.one('play', () => {
|
||||||
let minSecondsToView = 30
|
this.notifyUserIsWatching(this.startTime, lastViewEvent)
|
||||||
|
})
|
||||||
|
|
||||||
if (!this.isLive && this.videoDuration < minSecondsToView) {
|
this.player.on('seeked', () => {
|
||||||
minSecondsToView = (this.videoDuration * 3) / 4
|
// 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(() => {
|
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())
|
const currentTime = Math.floor(this.player.currentTime())
|
||||||
|
|
||||||
if (currentTime - lastCurrentTime >= 1) {
|
// No need to update
|
||||||
lastCurrentTime = currentTime
|
if (currentTime === lastCurrentTime) return
|
||||||
|
|
||||||
if (options) {
|
lastCurrentTime = currentTime
|
||||||
this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
|
|
||||||
.catch(err => console.error('Cannot notify user is watching.', err))
|
this.notifyUserIsWatching(currentTime, lastViewEvent)
|
||||||
} else {
|
.catch(err => console.error('Cannot notify user is watching.', err))
|
||||||
saveVideoWatchHistory(videoUUID, currentTime)
|
|
||||||
}
|
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 () {
|
private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
|
||||||
if (this.videoViewInterval !== undefined) {
|
|
||||||
clearInterval(this.videoViewInterval)
|
|
||||||
this.videoViewInterval = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private addViewToVideo () {
|
|
||||||
if (!this.videoViewUrl) return Promise.resolve(undefined)
|
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 headers = new Headers({
|
||||||
const body = new URLSearchParams()
|
'Content-type': 'application/json; charset=UTF-8'
|
||||||
body.append('currentTime', currentTime.toString())
|
})
|
||||||
|
|
||||||
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 () {
|
private listenFullScreenChange () {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { PluginsManager } from '@root-helpers/plugins-manager'
|
import { PluginsManager } from '@root-helpers/plugins-manager'
|
||||||
import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
|
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'
|
export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
|
||||||
|
|
||||||
|
@ -53,6 +53,8 @@ export interface CommonOptions extends CustomizationOptions {
|
||||||
captions: boolean
|
captions: boolean
|
||||||
|
|
||||||
videoViewUrl: string
|
videoViewUrl: string
|
||||||
|
authorizationHeader?: string
|
||||||
|
|
||||||
embedUrl: string
|
embedUrl: string
|
||||||
embedTitle: string
|
embedTitle: string
|
||||||
|
|
||||||
|
@ -68,8 +70,6 @@ export interface CommonOptions extends CustomizationOptions {
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
videoShortUUID: string
|
videoShortUUID: string
|
||||||
|
|
||||||
userWatching?: UserWatching
|
|
||||||
|
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
|
|
||||||
errorNotifier: (message: string) => void
|
errorNotifier: (message: string) => void
|
||||||
|
|
|
@ -88,23 +88,20 @@ type VideoJSCaption = {
|
||||||
src: string
|
src: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserWatching = {
|
|
||||||
url: string
|
|
||||||
authorizationHeader: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PeerTubePluginOptions = {
|
type PeerTubePluginOptions = {
|
||||||
mode: PlayerMode
|
mode: PlayerMode
|
||||||
|
|
||||||
autoplay: boolean
|
autoplay: boolean
|
||||||
videoViewUrl: string
|
|
||||||
videoDuration: number
|
videoDuration: number
|
||||||
|
|
||||||
userWatching?: UserWatching
|
videoViewUrl: string
|
||||||
|
authorizationHeader?: string
|
||||||
|
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
|
||||||
videoCaptions: VideoJSCaption[]
|
videoCaptions: VideoJSCaption[]
|
||||||
|
|
||||||
|
startTime: number | string
|
||||||
stopTime: number | string
|
stopTime: number | string
|
||||||
|
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
|
@ -230,7 +227,6 @@ export {
|
||||||
AutoResolutionUpdateData,
|
AutoResolutionUpdateData,
|
||||||
PlaylistPluginOptions,
|
PlaylistPluginOptions,
|
||||||
VideoJSCaption,
|
VideoJSCaption,
|
||||||
UserWatching,
|
|
||||||
PeerTubePluginOptions,
|
PeerTubePluginOptions,
|
||||||
WebtorrentPluginOptions,
|
WebtorrentPluginOptions,
|
||||||
P2PMediaLoaderPluginOptions,
|
P2PMediaLoaderPluginOptions,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue