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,
|
||||
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.`)
|
||||
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'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
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 () {
|
||||
|
|
|
@ -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