mirror of https://github.com/Chocobozzz/PeerTube
				
				
				
			Support videos stats in client
							parent
							
								
									b211106695
								
							
						
					
					
						commit
						384ba8b77a
					
				| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.`)
 | 
			
		||||
  onVideoRemoved (video: Video) {
 | 
			
		||||
    this.removeVideoFromArray(video.id)
 | 
			
		||||
          },
 | 
			
		||||
 | 
			
		||||
          error: err => this.notifier.error(err.message)
 | 
			
		||||
        })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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'
 | 
			
		||||
      },
 | 
			
		||||
    this.moreVideoActions = [
 | 
			
		||||
      [
 | 
			
		||||
        {
 | 
			
		||||
          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.resolver'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 { }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,7 +41,8 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
 | 
			
		|||
    report: true,
 | 
			
		||||
    duplicate: true,
 | 
			
		||||
    mute: true,
 | 
			
		||||
    liveInfo: true
 | 
			
		||||
    liveInfo: true,
 | 
			
		||||
    stats: true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  userRating: UserVideoRateType
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
  ) {
 | 
			
		||||
| 
						 | 
				
			
			@ -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(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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: {
 | 
			
		||||
        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'
 | 
			
		||||
        ])
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@ import debug from 'debug'
 | 
			
		|||
import videojs from 'video.js'
 | 
			
		||||
import { isMobile } from '@root-helpers/web-browser'
 | 
			
		||||
import { timeToInt } from '@shared/core-utils'
 | 
			
		||||
import { VideoView, VideoViewEvent } from '@shared/models/videos'
 | 
			
		||||
import {
 | 
			
		||||
  getStoredLastSubtitle,
 | 
			
		||||
  getStoredMute,
 | 
			
		||||
| 
						 | 
				
			
			@ -11,7 +12,7 @@ import {
 | 
			
		|||
  saveVideoWatchHistory,
 | 
			
		||||
  saveVolumeInStore
 | 
			
		||||
} from '../../peertube-player-local-storage'
 | 
			
		||||
import { PeerTubePluginOptions, UserWatching, VideoJSCaption } from '../../types'
 | 
			
		||||
import { PeerTubePluginOptions, VideoJSCaption } from '../../types'
 | 
			
		||||
import { SettingsButton } from '../settings/settings-menu-button'
 | 
			
		||||
 | 
			
		||||
const logger = debug('peertube:player:peertube')
 | 
			
		||||
| 
						 | 
				
			
			@ -20,18 +21,19 @@ const Plugin = videojs.getPlugin('plugin')
 | 
			
		|||
 | 
			
		||||
class PeerTubePlugin extends Plugin {
 | 
			
		||||
  private readonly videoViewUrl: string
 | 
			
		||||
  private readonly videoDuration: number
 | 
			
		||||
  private readonly authorizationHeader: string
 | 
			
		||||
 | 
			
		||||
  private readonly videoUUID: string
 | 
			
		||||
  private readonly startTime: number
 | 
			
		||||
 | 
			
		||||
  private readonly CONSTANTS = {
 | 
			
		||||
    USER_WATCHING_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
 | 
			
		||||
    USER_VIEW_VIDEO_INTERVAL: 5000 // Every 5 seconds, notify the user is watching the video
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private videoCaptions: VideoJSCaption[]
 | 
			
		||||
  private defaultSubtitle: string
 | 
			
		||||
 | 
			
		||||
  private videoViewInterval: any
 | 
			
		||||
  private userWatchingVideoInterval: any
 | 
			
		||||
 | 
			
		||||
  private isLive: boolean
 | 
			
		||||
 | 
			
		||||
  private menuOpened = false
 | 
			
		||||
  private mouseInControlBar = false
 | 
			
		||||
| 
						 | 
				
			
			@ -42,9 +44,11 @@ class PeerTubePlugin extends Plugin {
 | 
			
		|||
    super(player)
 | 
			
		||||
 | 
			
		||||
    this.videoViewUrl = options.videoViewUrl
 | 
			
		||||
    this.videoDuration = options.videoDuration
 | 
			
		||||
    this.authorizationHeader = options.authorizationHeader
 | 
			
		||||
    this.videoUUID = options.videoUUID
 | 
			
		||||
    this.startTime = timeToInt(options.startTime)
 | 
			
		||||
 | 
			
		||||
    this.videoCaptions = options.videoCaptions
 | 
			
		||||
    this.isLive = options.isLive
 | 
			
		||||
    this.initialInactivityTimeout = this.player.options_.inactivityTimeout
 | 
			
		||||
 | 
			
		||||
    if (options.autoplay) this.player.addClass('vjs-has-autoplay')
 | 
			
		||||
| 
						 | 
				
			
			@ -101,15 +105,12 @@ class PeerTubePlugin extends Plugin {
 | 
			
		|||
      this.player.duration(options.videoDuration)
 | 
			
		||||
 | 
			
		||||
      this.initializePlayer()
 | 
			
		||||
      this.runViewAdd()
 | 
			
		||||
 | 
			
		||||
      this.runUserWatchVideo(options.userWatching, options.videoUUID)
 | 
			
		||||
      this.runUserViewing()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  dispose () {
 | 
			
		||||
    if (this.videoViewInterval) clearInterval(this.videoViewInterval)
 | 
			
		||||
    if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onMenuOpened () {
 | 
			
		||||
| 
						 | 
				
			
			@ -142,74 +143,65 @@ class PeerTubePlugin extends Plugin {
 | 
			
		|||
    this.listenFullScreenChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private runViewAdd () {
 | 
			
		||||
    this.clearVideoViewInterval()
 | 
			
		||||
  private runUserViewing () {
 | 
			
		||||
    let lastCurrentTime = this.startTime
 | 
			
		||||
    let lastViewEvent: VideoViewEvent
 | 
			
		||||
 | 
			
		||||
    // After 30 seconds (or 3/4 of the video), add a view to the video
 | 
			
		||||
    let minSecondsToView = 30
 | 
			
		||||
    this.player.one('play', () => {
 | 
			
		||||
      this.notifyUserIsWatching(this.startTime, lastViewEvent)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    if (!this.isLive && this.videoDuration < minSecondsToView) {
 | 
			
		||||
      minSecondsToView = (this.videoDuration * 3) / 4
 | 
			
		||||
    }
 | 
			
		||||
    this.player.on('seeked', () => {
 | 
			
		||||
      // Don't take into account small seek events
 | 
			
		||||
      if (Math.abs(this.player.currentTime() - lastCurrentTime) < 3) return
 | 
			
		||||
 | 
			
		||||
    let secondsViewed = 0
 | 
			
		||||
    this.videoViewInterval = setInterval(() => {
 | 
			
		||||
      if (this.player && !this.player.paused()) {
 | 
			
		||||
        secondsViewed += 1
 | 
			
		||||
      lastViewEvent = 'seek'
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
        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) {
 | 
			
		||||
    this.player.one('ended', () => {
 | 
			
		||||
      const currentTime = Math.floor(this.player.duration())
 | 
			
		||||
      lastCurrentTime = currentTime
 | 
			
		||||
 | 
			
		||||
        if (options) {
 | 
			
		||||
          this.notifyUserIsWatching(currentTime, options.url, options.authorizationHeader)
 | 
			
		||||
      this.notifyUserIsWatching(currentTime, lastViewEvent)
 | 
			
		||||
 | 
			
		||||
      lastViewEvent = undefined
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    this.videoViewInterval = setInterval(() => {
 | 
			
		||||
      const currentTime = Math.floor(this.player.currentTime())
 | 
			
		||||
 | 
			
		||||
      // No need to update
 | 
			
		||||
      if (currentTime === lastCurrentTime) return
 | 
			
		||||
 | 
			
		||||
      lastCurrentTime = currentTime
 | 
			
		||||
 | 
			
		||||
      this.notifyUserIsWatching(currentTime, lastViewEvent)
 | 
			
		||||
        .catch(err => console.error('Cannot notify user is watching.', err))
 | 
			
		||||
        } else {
 | 
			
		||||
          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 () {
 | 
			
		||||
    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 () {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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