Support videos stats in client

pull/4903/head
Chocobozzz 2022-04-05 14:03:52 +02:00 committed by Chocobozzz
parent b211106695
commit 384ba8b77a
33 changed files with 699 additions and 201 deletions

View File

@ -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

View File

@ -55,10 +55,12 @@
<div class="action-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>
</ng-template>
</my-videos-selection>
<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
<my-live-stream-information #liveStreamInformationModal></my-live-stream-information>

View File

@ -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'
}
]
]
}
}

View File

@ -0,0 +1 @@
export * from './stats.module'

View File

@ -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 {}

View File

@ -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 { }

View File

@ -0,0 +1,2 @@
export * from './video-stats.component'
export * from './video-stats.service'

View File

@ -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>

View File

@ -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);
}

View File

@ -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)
}
}

View File

@ -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)))
}
}

View File

@ -1,2 +1 @@
export * from './video-studio-edit.component'
export * from './video-studio-edit.resolver'

View File

@ -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
}
}
]

View File

@ -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 { }

View File

@ -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;
}
}
}

View File

@ -41,7 +41,8 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
report: true,
duplicate: true,
mute: true,
liveInfo: true
liveInfo: true,
stats: true
}
userRating: UserVideoRateType

View File

@ -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,

View File

@ -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 => {

View File

@ -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

View File

@ -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) {

View File

@ -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,

View File

@ -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'

View File

@ -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
}

View File

@ -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<any> {
export class VideoResolver implements Resolve<any> {
constructor (
private videoService: VideoService
) {

View File

@ -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<VideoDetails> {
return this.serverService.getServerLocale()
.pipe(

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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'
])
}
}

View File

@ -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 () {

View File

@ -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

View File

@ -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,

View File

@ -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;
}
}
}