aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/video
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared/video')
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss17
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts26
-rw-r--r--client/src/app/shared/video/infinite-scroller.directive.ts4
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.html21
-rw-r--r--client/src/app/shared/video/modals/video-blacklist.component.ts6
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html60
-rw-r--r--client/src/app/shared/video/modals/video-download.component.scss35
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts111
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html16
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts6
-rw-r--r--client/src/app/shared/video/redundancy.service.ts73
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.ts45
-rw-r--r--client/src/app/shared/video/video-miniature.component.html14
-rw-r--r--client/src/app/shared/video/video-miniature.component.scss8
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts5
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html5
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss13
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts2
-rw-r--r--client/src/app/shared/video/video.model.ts19
-rw-r--r--client/src/app/shared/video/video.service.ts32
-rw-r--r--client/src/app/shared/video/videos-selection.component.ts4
21 files changed, 456 insertions, 66 deletions
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 3c7a4b1fc..44b629542 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -15,6 +15,11 @@
15 top: 1px; 15 top: 1px;
16 margin-left: 5px; 16 margin-left: 5px;
17 width: max-content; 17 width: max-content;
18 opacity: 0;
19 transition: ease-in .2s opacity;
20 }
21 &:hover my-feed {
22 opacity: 1;
18 } 23 }
19 } 24 }
20 25
@@ -50,3 +55,15 @@
50 @include adapt-margin-content-width; 55 @include adapt-margin-content-width;
51} 56}
52 57
58@media screen and (max-width: $mobile-view) {
59 .videos-header {
60 flex-direction: column;
61 align-items: center;
62 height: auto;
63
64 .title-page {
65 margin-bottom: 10px;
66 margin-right: 0px;
67 }
68 }
69}
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index c2fe6f754..b146d7014 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -1,4 +1,4 @@
1import { debounceTime, first, tap } from 'rxjs/operators' 1import { debounceTime, first, tap, throttleTime } from 'rxjs/operators'
2import { OnDestroy, OnInit } from '@angular/core' 2import { OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' 4import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs'
@@ -14,6 +14,9 @@ import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
14import { I18n } from '@ngx-translate/i18n-polyfill' 14import { I18n } from '@ngx-translate/i18n-polyfill'
15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' 15import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
16import { ServerConfig } from '@shared/models' 16import { ServerConfig } from '@shared/models'
17import { GlobalIconName } from '@app/shared/images/global-icon.component'
18import { UserService, User } from '../users'
19import { LocalStorageService } from '../misc/storage.service'
17 20
18enum GroupDate { 21enum GroupDate {
19 UNKNOWN = 0, 22 UNKNOWN = 0,
@@ -61,7 +64,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
61 64
62 actions: { 65 actions: {
63 routerLink: string 66 routerLink: string
64 iconName: string 67 iconName: GlobalIconName
65 label: string 68 label: string
66 }[] = [] 69 }[] = []
67 70
@@ -71,9 +74,11 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
71 74
72 protected abstract notifier: Notifier 75 protected abstract notifier: Notifier
73 protected abstract authService: AuthService 76 protected abstract authService: AuthService
77 protected abstract userService: UserService
74 protected abstract route: ActivatedRoute 78 protected abstract route: ActivatedRoute
75 protected abstract serverService: ServerService 79 protected abstract serverService: ServerService
76 protected abstract screenService: ScreenService 80 protected abstract screenService: ScreenService
81 protected abstract storageService: LocalStorageService
77 protected abstract router: Router 82 protected abstract router: Router
78 protected abstract i18n: I18n 83 protected abstract i18n: I18n
79 abstract titlePage: string 84 abstract titlePage: string
@@ -123,6 +128,16 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
123 if (this.loadOnInit === true) { 128 if (this.loadOnInit === true) {
124 loadUserObservable.subscribe(() => this.loadMoreVideos()) 129 loadUserObservable.subscribe(() => this.loadMoreVideos())
125 } 130 }
131
132 this.storageService.watch([
133 User.KEYS.NSFW_POLICY,
134 User.KEYS.VIDEO_LANGUAGES
135 ]).pipe(throttleTime(200)).subscribe(
136 () => {
137 this.loadUserVideoLanguagesIfNeeded()
138 if (this.hasDoneFirstQuery) this.reloadVideos()
139 }
140 )
126 } 141 }
127 142
128 ngOnDestroy () { 143 ngOnDestroy () {
@@ -278,7 +293,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor
278 } 293 }
279 294
280 private loadUserVideoLanguagesIfNeeded () { 295 private loadUserVideoLanguagesIfNeeded () {
281 if (!this.authService.isLoggedIn() || !this.useUserVideoLanguagePreferences) { 296 if (!this.useUserVideoLanguagePreferences) {
297 return of(true)
298 }
299
300 if (!this.authService.isLoggedIn()) {
301 this.languageOneOf = this.userService.getAnonymousUser().videoLanguages
282 return of(true) 302 return of(true)
283 } 303 }
284 304
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
index 9f613c5fa..f09c3d1fc 100644
--- a/client/src/app/shared/video/infinite-scroller.directive.ts
+++ b/client/src/app/shared/video/infinite-scroller.directive.ts
@@ -1,4 +1,4 @@
1import { distinctUntilChanged, filter, map, share, startWith, tap, throttleTime } from 'rxjs/operators' 1import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' 2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
3import { fromEvent, Observable, Subscription } from 'rxjs' 3import { fromEvent, Observable, Subscription } from 'rxjs'
4 4
@@ -53,7 +53,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterConten
53 const scrollableElement = this.onItself ? this.container : window 53 const scrollableElement = this.onItself ? this.container : window
54 const scrollObservable = fromEvent(scrollableElement, 'scroll') 54 const scrollObservable = fromEvent(scrollableElement, 'scroll')
55 .pipe( 55 .pipe(
56 startWith(null as string), // FIXME: typings 56 startWith(true),
57 throttleTime(200, undefined, throttleOptions), 57 throttleTime(200, undefined, throttleOptions),
58 map(() => this.getScrollInfo()), 58 map(() => this.getScrollInfo()),
59 distinctUntilChanged((o1, o2) => o1.current === o2.current), 59 distinctUntilChanged((o1, o2) => o1.current === o2.current),
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.html b/client/src/app/shared/video/modals/video-blacklist.component.html
index 1a87bdcd4..8f06a6b02 100644
--- a/client/src/app/shared/video/modals/video-blacklist.component.html
+++ b/client/src/app/shared/video/modals/video-blacklist.component.html
@@ -8,8 +8,10 @@
8 8
9 <form novalidate [formGroup]="form" (ngSubmit)="blacklist()"> 9 <form novalidate [formGroup]="form" (ngSubmit)="blacklist()">
10 <div class="form-group"> 10 <div class="form-group">
11 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 11 <textarea
12 </textarea> 12 i18n-placeholder placeholder="Reason..." formControlName="reason"
13 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
14 ></textarea>
13 <div *ngIf="formErrors.reason" class="form-error"> 15 <div *ngIf="formErrors.reason" class="form-error">
14 {{ formErrors.reason }} 16 {{ formErrors.reason }}
15 </div> 17 </div>
@@ -18,14 +20,19 @@
18 <div class="form-group" *ngIf="video.isLocal"> 20 <div class="form-group" *ngIf="video.isLocal">
19 <my-peertube-checkbox 21 <my-peertube-checkbox
20 inputName="unfederate" formControlName="unfederate" 22 inputName="unfederate" formControlName="unfederate"
21 i18n-labelText labelText="Unfederate the video (ask for its deletion from the remote instances)" 23 i18n-labelText labelText="Unfederate the video"
22 ></my-peertube-checkbox> 24 >
25 <ng-container ngProjectAs="description">
26 <span i18n>This will ask remote instances to delete it</span>
27 </ng-container>
28 </my-peertube-checkbox>
23 </div> 29 </div>
24 30
25 <div class="form-group inputs"> 31 <div class="form-group inputs">
26 <span i18n class="action-button action-button-cancel" (click)="hide()"> 32 <input
27 Cancel 33 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
28 </span> 34 (click)="hide()" (key.enter)="hide()"
35 >
29 36
30 <input 37 <input
31 type="submit" i18n-value value="Submit" class="action-button-submit" 38 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts
index f0c70a365..6ef9c250b 100644
--- a/client/src/app/shared/video/modals/video-blacklist.component.ts
+++ b/client/src/app/shared/video/modals/video-blacklist.component.ts
@@ -1,12 +1,12 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, RedirectService } from '@app/core' 2import { Notifier, RedirectService } from '@app/core'
3import { VideoBlacklistService } from '../../../shared/video-blacklist' 3import { VideoBlacklistService } from '../../../shared/video-blacklist'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' 8import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms'
9import { Video } from '@app/shared/video/video.model'
10 10
11@Component({ 11@Component({
12 selector: 'my-video-blacklist', 12 selector: 'my-video-blacklist',
@@ -14,7 +14,7 @@ import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms
14 styleUrls: [ './video-blacklist.component.scss' ] 14 styleUrls: [ './video-blacklist.component.scss' ]
15}) 15})
16export class VideoBlacklistComponent extends FormReactive implements OnInit { 16export class VideoBlacklistComponent extends FormReactive implements OnInit {
17 @Input() video: VideoDetails = null 17 @Input() video: Video = null
18 18
19 @ViewChild('modal', { static: true }) modal: NgbModal 19 @ViewChild('modal', { static: true }) modal: NgbModal
20 20
@@ -46,7 +46,7 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit {
46 } 46 }
47 47
48 show () { 48 show () {
49 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 49 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
50 } 50 }
51 51
52 hide () { 52 hide () {
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
index 8cca985b1..c65e371ee 100644
--- a/client/src/app/shared/video/modals/video-download.component.html
+++ b/client/src/app/shared/video/modals/video-download.component.html
@@ -1,7 +1,7 @@
1<ng-template #modal let-hide="close"> 1<ng-template #modal let-hide="close">
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 class="modal-title">Download 3 <h4 class="modal-title">
4 <span *ngIf="!videoCaptions" i18n>video</span> 4 <ng-container i18n>Download</ng-container>
5 5
6 <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block"> 6 <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
7 <span id="dropdownDownloadType" ngbDropdownToggle> 7 <span id="dropdownDownloadType" ngbDropdownToggle>
@@ -20,22 +20,67 @@
20 <div class="form-group"> 20 <div class="form-group">
21 <div class="input-group input-group-sm"> 21 <div class="input-group input-group-sm">
22 <div class="input-group-prepend peertube-select-container"> 22 <div class="input-group-prepend peertube-select-container">
23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId"> 23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option> 24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
25 </select> 25 </select>
26
26 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId"> 27 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
27 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option> 28 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
28 </select> 29 </select>
29 </div> 30 </div>
31
30 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> 32 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
31 <div class="input-group-append"> 33 <div class="input-group-append">
32 <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> 34 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
33 <span class="glyphicon glyphicon-copy"></span> 35 <span class="glyphicon glyphicon-copy"></span>
34 </button> 36 </button>
35 </div> 37 </div>
36 </div> 38 </div>
37 </div> 39 </div>
38 40
41 <ng-container *ngIf="type === 'video' && videoFile?.metadata">
42 <div ngbNav #nav="ngbNav" class="nav-tabs">
43
44 <ng-container ngbNavItem>
45 <a ngbNavLink i18n>Format</a>
46 <ng-template ngbNavContent>
47 <div class="file-metadata">
48 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
49 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
50 <span class="metadata-attribute-value">{{ item.value.value }}</span>
51 </div>
52 </div>
53 </ng-template>
54 </ng-container>
55
56 <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
57 <a ngbNavLink i18n>Video stream</a>
58 <ng-template ngbNavContent>
59 <div class="file-metadata">
60 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
61 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
62 <span class="metadata-attribute-value">{{ item.value.value }}</span>
63 </div>
64 </div>
65 </ng-template>
66 </ng-container>
67
68 <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
69 <a ngbNavLink i18n>Audio stream</a>
70 <ng-template ngbNavContent>
71 <div class="file-metadata">
72 <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
73 <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
74 <span class="metadata-attribute-value">{{ item.value.value }}</span>
75 </div>
76 </div>
77 </ng-template>
78 </ng-container>
79 </div>
80
81 <div [ngbNavOutlet]="nav"></div>
82 </ng-container>
83
39 <div class="download-type" *ngIf="type === 'video'"> 84 <div class="download-type" *ngIf="type === 'video'">
40 <div class="peertube-radio-container"> 85 <div class="peertube-radio-container">
41 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct"> 86 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
@@ -50,9 +95,10 @@
50 </div> 95 </div>
51 96
52 <div class="modal-footer inputs"> 97 <div class="modal-footer inputs">
53 <span i18n class="action-button action-button-cancel" (click)="hide()"> 98 <input
54 Cancel 99 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
55 </span> 100 (click)="hide()" (key.enter)="hide()"
101 >
56 102
57 <input 103 <input
58 type="submit" i18n-value value="Download" class="action-button-submit" 104 type="submit" i18n-value value="Download" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
index 09dd91aa9..f28bc34ed 100644
--- a/client/src/app/shared/video/modals/video-download.component.scss
+++ b/client/src/app/shared/video/modals/video-download.component.scss
@@ -27,3 +27,38 @@
27 margin-right: 30px; 27 margin-right: 30px;
28 } 28 }
29} 29}
30
31.file-metadata {
32 padding: 1rem;
33}
34
35.file-metadata .metadata-attribute {
36 font-size: 13px;
37 display: block;
38 margin-bottom: 12px;
39
40 .metadata-attribute-label {
41 min-width: 142px;
42 padding-right: 5px;
43 display: inline-block;
44 color: $grey-foreground-color;
45 font-weight: $font-bold;
46 }
47
48 a.metadata-attribute-value {
49 @include disable-default-a-behaviour;
50 color: var(--mainForegroundColor);
51
52 &:hover {
53 opacity: 0.9;
54 }
55 }
56
57 &.metadata-attribute-tags {
58 .metadata-attribute-value:not(:nth-child(2)) {
59 &::before {
60 content: ', '
61 }
62 }
63 }
64}
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts
index c1ceca263..d77187821 100644
--- a/client/src/app/shared/video/modals/video-download.component.ts
+++ b/client/src/app/shared/video/modals/video-download.component.ts
@@ -3,9 +3,15 @@ import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { AuthService, Notifier } from '@app/core' 5import { AuthService, Notifier } from '@app/core'
6import { VideoPrivacy, VideoCaption } from '@shared/models' 6import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models'
7import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
8import { mapValues, pick } from 'lodash-es'
9import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
10import { BytesPipe } from 'ngx-pipes'
11import { VideoService } from '../video.service'
7 12
8type DownloadType = 'video' | 'subtitles' 13type DownloadType = 'video' | 'subtitles'
14type FileMetadata = { [key: string]: { label: string, value: string }}
9 15
10@Component({ 16@Component({
11 selector: 'my-video-download', 17 selector: 'my-video-download',
@@ -20,17 +26,28 @@ export class VideoDownloadComponent {
20 subtitleLanguageId: string 26 subtitleLanguageId: string
21 27
22 video: VideoDetails 28 video: VideoDetails
29 videoFile: VideoFile
30 videoFileMetadataFormat: FileMetadata
31 videoFileMetadataVideoStream: FileMetadata | undefined
32 videoFileMetadataAudioStream: FileMetadata | undefined
23 videoCaptions: VideoCaption[] 33 videoCaptions: VideoCaption[]
24 activeModal: NgbActiveModal 34 activeModal: NgbActiveModal
25 35
26 type: DownloadType = 'video' 36 type: DownloadType = 'video'
27 37
38 private bytesPipe: BytesPipe
39 private numbersPipe: NumberFormatterPipe
40
28 constructor ( 41 constructor (
29 private notifier: Notifier, 42 private notifier: Notifier,
30 private modalService: NgbModal, 43 private modalService: NgbModal,
44 private videoService: VideoService,
31 private auth: AuthService, 45 private auth: AuthService,
32 private i18n: I18n 46 private i18n: I18n
33 ) { } 47 ) {
48 this.bytesPipe = new BytesPipe()
49 this.numbersPipe = new NumberFormatterPipe()
50 }
34 51
35 get typeText () { 52 get typeText () {
36 return this.type === 'video' 53 return this.type === 'video'
@@ -48,9 +65,10 @@ export class VideoDownloadComponent {
48 this.video = video 65 this.video = video
49 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined 66 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
50 67
51 this.activeModal = this.modalService.open(this.modal) 68 this.activeModal = this.modalService.open(this.modal, { centered: true })
52 69
53 this.resolutionId = this.getVideoFiles()[0].resolution.id 70 this.resolutionId = this.getVideoFiles()[0].resolution.id
71 this.onResolutionIdChange()
54 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id 72 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
55 } 73 }
56 74
@@ -67,10 +85,27 @@ export class VideoDownloadComponent {
67 getLink () { 85 getLink () {
68 return this.type === 'subtitles' && this.videoCaptions 86 return this.type === 'subtitles' && this.videoCaptions
69 ? this.getSubtitlesLink() 87 ? this.getSubtitlesLink()
70 : this.getVideoLink() 88 : this.getVideoFileLink()
71 } 89 }
72 90
73 getVideoLink () { 91 async onResolutionIdChange () {
92 this.videoFile = this.getVideoFile()
93 if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
94
95 await this.hydrateMetadataFromMetadataUrl(this.videoFile)
96
97 this.videoFileMetadataFormat = this.videoFile
98 ? this.getMetadataFormat(this.videoFile.metadata.format)
99 : undefined
100 this.videoFileMetadataVideoStream = this.videoFile
101 ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
102 : undefined
103 this.videoFileMetadataAudioStream = this.videoFile
104 ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
105 : undefined
106 }
107
108 getVideoFile () {
74 // HTML select send us a string, so convert it to a number 109 // HTML select send us a string, so convert it to a number
75 this.resolutionId = parseInt(this.resolutionId.toString(), 10) 110 this.resolutionId = parseInt(this.resolutionId.toString(), 10)
76 111
@@ -79,6 +114,12 @@ export class VideoDownloadComponent {
79 console.error('Could not find file with resolution %d.', this.resolutionId) 114 console.error('Could not find file with resolution %d.', this.resolutionId)
80 return 115 return
81 } 116 }
117 return file
118 }
119
120 getVideoFileLink () {
121 const file = this.videoFile
122 if (!file) return
82 123
83 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL 124 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
84 ? '?access_token=' + this.auth.getAccessToken() 125 ? '?access_token=' + this.auth.getAccessToken()
@@ -104,4 +145,64 @@ export class VideoDownloadComponent {
104 switchToType (type: DownloadType) { 145 switchToType (type: DownloadType) {
105 this.type = type 146 this.type = type
106 } 147 }
148
149 getMetadataFormat (format: FfprobeFormat) {
150 const keyToTranslateFunction = {
151 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
152 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
153 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
154 'bit_rate': (value: number) => ({
155 label: this.i18n('Bitrate'),
156 value: `${this.numbersPipe.transform(value)}bps`
157 })
158 }
159
160 // flattening format
161 const sanitizedFormat = Object.assign(format, format.tags)
162 delete sanitizedFormat.tags
163
164 return mapValues(
165 pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
166 (val, key) => keyToTranslateFunction[key](val)
167 )
168 }
169
170 getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
171 const stream = streams.find(s => s.codec_type === type)
172 if (!stream) return undefined
173
174 let keyToTranslateFunction = {
175 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
176 'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
177 'bit_rate': (value: number) => ({
178 label: this.i18n('Bitrate'),
179 value: `${this.numbersPipe.transform(value)}bps`
180 })
181 }
182
183 if (type === 'video') {
184 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
185 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
186 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
187 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
188 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
189 })
190 } else {
191 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
192 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
193 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
194 })
195 }
196
197 return mapValues(
198 pick(stream, Object.keys(keyToTranslateFunction)),
199 (val, key) => keyToTranslateFunction[key](val)
200 )
201 }
202
203 private hydrateMetadataFromMetadataUrl (file: VideoFile) {
204 const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
205 observable.subscribe(res => file.metadata = res)
206 return observable.toPromise()
207 }
107} 208}
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html
index b9434da26..e336b6660 100644
--- a/client/src/app/shared/video/modals/video-report.component.html
+++ b/client/src/app/shared/video/modals/video-report.component.html
@@ -7,23 +7,25 @@
7 <div class="modal-body"> 7 <div class="modal-body">
8 8
9 <div i18n class="information"> 9 <div i18n class="information">
10 Your report will be sent to moderators of {{ currentHost }}. 10 Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
11 <ng-container *ngIf="isRemoteVideo()"> It will be forwarded to origin instance {{ originHost }} too.</ng-container>
12 </div> 11 </div>
13 12
14 <form novalidate [formGroup]="form" (ngSubmit)="report()"> 13 <form novalidate [formGroup]="form" (ngSubmit)="report()">
15 <div class="form-group"> 14 <div class="form-group">
16 <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> 15 <textarea
17 </textarea> 16 i18n-placeholder placeholder="Reason..." formControlName="reason"
17 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
18 ></textarea>
18 <div *ngIf="formErrors.reason" class="form-error"> 19 <div *ngIf="formErrors.reason" class="form-error">
19 {{ formErrors.reason }} 20 {{ formErrors.reason }}
20 </div> 21 </div>
21 </div> 22 </div>
22 23
23 <div class="form-group inputs"> 24 <div class="form-group inputs">
24 <span i18n class="action-button action-button-cancel" (click)="hide()"> 25 <input
25 Cancel 26 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
26 </span> 27 (click)="hide()" (key.enter)="hide()"
28 >
27 29
28 <input 30 <input
29 type="submit" i18n-value value="Submit" class="action-button-submit" 31 type="submit" i18n-value value="Submit" class="action-button-submit"
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
index 1d368ff17..988fa03d4 100644
--- a/client/src/app/shared/video/modals/video-report.component.ts
+++ b/client/src/app/shared/video/modals/video-report.component.ts
@@ -1,13 +1,13 @@
1import { Component, Input, OnInit, ViewChild } from '@angular/core' 1import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive } from '../../../shared/forms' 3import { FormReactive } from '../../../shared/forms'
4import { VideoDetails } from '../../../shared/video/video-details.model'
5import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
6import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
7import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' 6import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
8import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
9import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
10import { VideoAbuseService } from '@app/shared/video-abuse' 9import { VideoAbuseService } from '@app/shared/video-abuse'
10import { Video } from '@app/shared/video/video.model'
11 11
12@Component({ 12@Component({
13 selector: 'my-video-report', 13 selector: 'my-video-report',
@@ -15,7 +15,7 @@ import { VideoAbuseService } from '@app/shared/video-abuse'
15 styleUrls: [ './video-report.component.scss' ] 15 styleUrls: [ './video-report.component.scss' ]
16}) 16})
17export class VideoReportComponent extends FormReactive implements OnInit { 17export class VideoReportComponent extends FormReactive implements OnInit {
18 @Input() video: VideoDetails = null 18 @Input() video: Video = null
19 19
20 @ViewChild('modal', { static: true }) modal: NgbModal 20 @ViewChild('modal', { static: true }) modal: NgbModal
21 21
@@ -53,7 +53,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
53 } 53 }
54 54
55 show () { 55 show () {
56 this.openedModal = this.modalService.open(this.modal, { keyboard: false }) 56 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
57 } 57 }
58 58
59 hide () { 59 hide () {
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
new file mode 100644
index 000000000..fb918d73b
--- /dev/null
+++ b/client/src/app/shared/video/redundancy.service.ts
@@ -0,0 +1,73 @@
1import { catchError, map, toArray } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
5import { SortMeta } from 'primeng/api'
6import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
7import { concat, Observable } from 'rxjs'
8import { environment } from '../../../environments/environment'
9
10@Injectable()
11export class RedundancyService {
12 static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) { }
19
20 updateRedundancy (host: string, redundancyAllowed: boolean) {
21 const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
22
23 const body = { redundancyAllowed }
24
25 return this.authHttp.put(url, body)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 listVideoRedundancies (options: {
33 pagination: RestPagination,
34 sort: SortMeta,
35 target?: VideoRedundanciesTarget
36 }): Observable<ResultList<VideoRedundancy>> {
37 const { pagination, sort, target } = options
38
39 let params = new HttpParams()
40 params = this.restService.addRestGetParams(params, pagination, sort)
41
42 if (target) params = params.append('target', target)
43
44 return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
45 .pipe(
46 catchError(res => this.restExtractor.handleError(res))
47 )
48 }
49
50 addVideoRedundancy (video: Video) {
51 return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
52 .pipe(
53 catchError(res => this.restExtractor.handleError(res))
54 )
55 }
56
57 removeVideoRedundancies (redundancy: VideoRedundancy) {
58 const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
59 .concat(redundancy.redundancies.files.map(r => r.id))
60 .map(id => this.removeRedundancy(id))
61
62 return concat(...observables)
63 .pipe(toArray())
64 }
65
66 private removeRedundancy (redundancyId: number) {
67 return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
68 .pipe(
69 map(this.restExtractor.extractDataBool),
70 catchError(res => this.restExtractor.handleError(res))
71 )
72 }
73}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
index afdeab18d..4e5fc6476 100644
--- a/client/src/app/shared/video/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/video/video-actions-dropdown.component.ts
@@ -1,8 +1,7 @@
1import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill' 2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' 3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' 4import { AuthService, ConfirmService, Notifier } from '@app/core'
5import { BlocklistService } from '@app/shared/blocklist'
6import { Video } from '@app/shared/video/video.model' 5import { Video } from '@app/shared/video/video.model'
7import { VideoService } from '@app/shared/video/video.service' 6import { VideoService } from '@app/shared/video/video.service'
8import { VideoDetails } from '@app/shared/video/video-details.model' 7import { VideoDetails } from '@app/shared/video/video-details.model'
@@ -14,6 +13,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis
14import { VideoBlacklistService } from '@app/shared/video-blacklist' 13import { VideoBlacklistService } from '@app/shared/video-blacklist'
15import { ScreenService } from '@app/shared/misc/screen.service' 14import { ScreenService } from '@app/shared/misc/screen.service'
16import { VideoCaption } from '@shared/models' 15import { VideoCaption } from '@shared/models'
16import { RedundancyService } from '@app/shared/video/redundancy.service'
17 17
18export type VideoActionsDisplayType = { 18export type VideoActionsDisplayType = {
19 playlist?: boolean 19 playlist?: boolean
@@ -22,6 +22,7 @@ export type VideoActionsDisplayType = {
22 blacklist?: boolean 22 blacklist?: boolean
23 delete?: boolean 23 delete?: boolean
24 report?: boolean 24 report?: boolean
25 duplicate?: boolean
25} 26}
26 27
27@Component({ 28@Component({
@@ -30,12 +31,12 @@ export type VideoActionsDisplayType = {
30 styleUrls: [ './video-actions-dropdown.component.scss' ] 31 styleUrls: [ './video-actions-dropdown.component.scss' ]
31}) 32})
32export class VideoActionsDropdownComponent implements OnChanges { 33export class VideoActionsDropdownComponent implements OnChanges {
33 @ViewChild('playlistDropdown', { static: false }) playlistDropdown: NgbDropdown 34 @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
34 @ViewChild('playlistAdd', { static: false }) playlistAdd: VideoAddToPlaylistComponent 35 @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
35 36
36 @ViewChild('videoDownloadModal', { static: false }) videoDownloadModal: VideoDownloadComponent 37 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
37 @ViewChild('videoReportModal', { static: false }) videoReportModal: VideoReportComponent 38 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
38 @ViewChild('videoBlacklistModal', { static: false }) videoBlacklistModal: VideoBlacklistComponent 39 @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent
39 40
40 @Input() video: Video | VideoDetails 41 @Input() video: Video | VideoDetails
41 @Input() videoCaptions: VideoCaption[] = [] 42 @Input() videoCaptions: VideoCaption[] = []
@@ -46,7 +47,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
46 update: true, 47 update: true,
47 blacklist: true, 48 blacklist: true,
48 delete: true, 49 delete: true,
49 report: true 50 report: true,
51 duplicate: true
50 } 52 }
51 @Input() placement = 'left' 53 @Input() placement = 'left'
52 54
@@ -70,10 +72,9 @@ export class VideoActionsDropdownComponent implements OnChanges {
70 private notifier: Notifier, 72 private notifier: Notifier,
71 private confirmService: ConfirmService, 73 private confirmService: ConfirmService,
72 private videoBlacklistService: VideoBlacklistService, 74 private videoBlacklistService: VideoBlacklistService,
73 private serverService: ServerService,
74 private screenService: ScreenService, 75 private screenService: ScreenService,
75 private videoService: VideoService, 76 private videoService: VideoService,
76 private blocklistService: BlocklistService, 77 private redundancyService: RedundancyService,
77 private i18n: I18n 78 private i18n: I18n
78 ) { } 79 ) { }
79 80
@@ -144,6 +145,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
144 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled 145 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
145 } 146 }
146 147
148 canVideoBeDuplicated () {
149 return this.video.canBeDuplicatedBy(this.user)
150 }
151
147 /* Action handlers */ 152 /* Action handlers */
148 153
149 async unblacklistVideo () { 154 async unblacklistVideo () {
@@ -186,6 +191,18 @@ export class VideoActionsDropdownComponent implements OnChanges {
186 ) 191 )
187 } 192 }
188 193
194 duplicateVideo () {
195 this.redundancyService.addVideoRedundancy(this.video)
196 .subscribe(
197 () => {
198 const message = this.i18n('This video will be duplicated by your instance.')
199 this.notifier.success(message)
200 },
201
202 err => this.notifier.error(err.message)
203 )
204 }
205
189 onVideoBlacklisted () { 206 onVideoBlacklisted () {
190 this.videoBlacklisted.emit() 207 this.videoBlacklisted.emit()
191 } 208 }
@@ -234,6 +251,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
234 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable() 251 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable()
235 }, 252 },
236 { 253 {
254 label: this.i18n('Mirror'),
255 handler: () => this.duplicateVideo(),
256 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
257 iconName: 'cloud-download'
258 },
259 {
237 label: this.i18n('Delete'), 260 label: this.i18n('Delete'),
238 handler: () => this.removeVideo(), 261 handler: () => this.removeVideo(),
239 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), 262 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index 46c49c15b..8e948ce42 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -2,7 +2,10 @@
2 <my-video-thumbnail 2 <my-video-thumbnail
3 [video]="video" [nsfw]="isVideoBlur" 3 [video]="video" [nsfw]="isVideoBlur"
4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" 4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
5 ></my-video-thumbnail> 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
8 </my-video-thumbnail>
6 9
7 <div class="video-bottom"> 10 <div class="video-bottom">
8 <div class="video-miniature-information"> 11 <div class="video-miniature-information">
@@ -19,11 +22,6 @@
19 <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container> 22 <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container>
20 <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container> 23 <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
21 </span> 24 </span>
22
23 <ng-container *ngIf="displayOptions.privacyLabel">
24 <span *ngIf="isUnlistedVideo()" class="badge badge-warning ml-1" i18n>Unlisted</span>
25 <span *ngIf="isPrivateVideo()" class="badge badge-danger ml-1" i18n>Private</span>
26 </ng-container>
27 </span> 25 </span>
28 26
29 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> 27 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
@@ -50,9 +48,9 @@
50 </div> 48 </div>
51 49
52 <div class="video-actions"> 50 <div class="video-actions">
53 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown --> 51 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
54 <my-video-actions-dropdown 52 <my-video-actions-dropdown
55 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left" 53 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
56 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()" 54 (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()"
57 ></my-video-actions-dropdown> 55 ></my-video-actions-dropdown>
58 </div> 56 </div>
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
index b63fd2989..f27800a24 100644
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ b/client/src/app/shared/video/video-miniature.component.scss
@@ -85,8 +85,12 @@ $more-margin-right: 15px;
85 } 85 }
86 86
87 @media screen and (max-width: $small-view) { 87 @media screen and (max-width: $small-view) {
88 .video-miniature-information .video-miniature-name { 88 .video-miniature-information {
89 margin-top: 0; 89 margin: 0 10px;
90
91 .video-miniature-name {
92 margin-top: 0;
93 }
90 } 94 }
91 95
92 .video-actions { 96 .video-actions {
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index 598a7a983..72b652448 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -64,7 +64,8 @@ export class VideoMiniatureComponent implements OnInit {
64 update: true, 64 update: true,
65 blacklist: true, 65 blacklist: true,
66 delete: true, 66 delete: true,
67 report: true 67 report: true,
68 duplicate: true
68 } 69 }
69 showActions = false 70 showActions = false
70 serverConfig: ServerConfig 71 serverConfig: ServerConfig
@@ -199,7 +200,7 @@ export class VideoMiniatureComponent implements OnInit {
199 } 200 }
200 201
201 isWatchLaterPlaylistDisplayed () { 202 isWatchLaterPlaylistDisplayed () {
202 return this.inWatchLaterPlaylist !== undefined 203 return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
203 } 204 }
204 205
205 private setUpBy () { 206 private setUpBy () {
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index b63085b81..fe5510c56 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -1,5 +1,5 @@
1<a 1<a
2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [title]="video.name" 2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
3 class="video-thumbnail" 3 class="video-thumbnail"
4> 4>
5 <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> 5 <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
@@ -18,6 +18,9 @@
18 </ng-container> 18 </ng-container>
19 </div> 19 </div>
20 20
21 <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div>
22 <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div>
23
21 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div> 24 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
22 25
23 <div class="play-overlay"> 26 <div class="play-overlay">
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
index 573a64987..5fca916f0 100644
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ b/client/src/app/shared/video/video-thumbnail.component.scss
@@ -19,13 +19,24 @@
19 } 19 }
20 20
21 .video-thumbnail-watch-later-overlay, 21 .video-thumbnail-watch-later-overlay,
22 .video-thumbnail-label-overlay,
22 .video-thumbnail-duration-overlay { 23 .video-thumbnail-duration-overlay {
23 @include static-thumbnail-overlay; 24 @include static-thumbnail-overlay;
24 25
25 border-radius: 3px; 26 border-radius: 3px;
26 font-size: 12px; 27 font-size: 12px;
27 font-weight: $font-bold; 28 font-weight: $font-bold;
28 z-index: 1; 29 z-index: z(miniature);
30 }
31
32 .video-thumbnail-label-overlay {
33 position: absolute;
34 padding: 0 5px;
35 left: 5px;
36 top: 5px;
37
38 &.warning { background-color: orange; }
39 &.danger { background-color: red; }
29 } 40 }
30 41
31 .video-thumbnail-duration-overlay { 42 .video-thumbnail-duration-overlay {
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
index 2420ec715..111b4c8bb 100644
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ b/client/src/app/shared/video/video-thumbnail.component.ts
@@ -12,7 +12,7 @@ export class VideoThumbnailComponent {
12 @Input() video: Video 12 @Input() video: Video
13 @Input() nsfw = false 13 @Input() nsfw = false
14 @Input() routerLink: any[] 14 @Input() routerLink: any[]
15 @Input() queryParams: any[] 15 @Input() queryParams: { [ p: string ]: any }
16 16
17 @Input() displayWatchLaterPlaylist: boolean 17 @Input() displayWatchLaterPlaylist: boolean
18 @Input() inWatchLaterPlaylist: boolean 18 @Input() inWatchLaterPlaylist: boolean
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index fb98d5382..546518cca 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -42,6 +42,9 @@ export class Video implements VideoServerModel {
42 dislikes: number 42 dislikes: number
43 nsfw: boolean 43 nsfw: boolean
44 44
45 originInstanceUrl: string
46 originInstanceHost: string
47
45 waitTranscoding?: boolean 48 waitTranscoding?: boolean
46 state?: VideoConstant<VideoState> 49 state?: VideoConstant<VideoState>
47 scheduledUpdate?: VideoScheduleUpdate 50 scheduledUpdate?: VideoScheduleUpdate
@@ -86,22 +89,31 @@ export class Video implements VideoServerModel {
86 this.waitTranscoding = hash.waitTranscoding 89 this.waitTranscoding = hash.waitTranscoding
87 this.state = hash.state 90 this.state = hash.state
88 this.description = hash.description 91 this.description = hash.description
92
89 this.duration = hash.duration 93 this.duration = hash.duration
90 this.durationLabel = durationToString(hash.duration) 94 this.durationLabel = durationToString(hash.duration)
95
91 this.id = hash.id 96 this.id = hash.id
92 this.uuid = hash.uuid 97 this.uuid = hash.uuid
98
93 this.isLocal = hash.isLocal 99 this.isLocal = hash.isLocal
94 this.name = hash.name 100 this.name = hash.name
101
95 this.thumbnailPath = hash.thumbnailPath 102 this.thumbnailPath = hash.thumbnailPath
96 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 103 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
104
97 this.previewPath = hash.previewPath 105 this.previewPath = hash.previewPath
98 this.previewUrl = absoluteAPIUrl + hash.previewPath 106 this.previewUrl = absoluteAPIUrl + hash.previewPath
107
99 this.embedPath = hash.embedPath 108 this.embedPath = hash.embedPath
100 this.embedUrl = absoluteAPIUrl + hash.embedPath 109 this.embedUrl = absoluteAPIUrl + hash.embedPath
110
101 this.views = hash.views 111 this.views = hash.views
102 this.likes = hash.likes 112 this.likes = hash.likes
103 this.dislikes = hash.dislikes 113 this.dislikes = hash.dislikes
114
104 this.nsfw = hash.nsfw 115 this.nsfw = hash.nsfw
116
105 this.account = hash.account 117 this.account = hash.account
106 this.channel = hash.channel 118 this.channel = hash.channel
107 119
@@ -124,6 +136,9 @@ export class Video implements VideoServerModel {
124 this.blacklistedReason = hash.blacklistedReason 136 this.blacklistedReason = hash.blacklistedReason
125 137
126 this.userHistory = hash.userHistory 138 this.userHistory = hash.userHistory
139
140 this.originInstanceHost = this.account.host
141 this.originInstanceUrl = 'https://' + this.originInstanceHost
127 } 142 }
128 143
129 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 144 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
@@ -152,4 +167,8 @@ export class Video implements VideoServerModel {
152 isUpdatableBy (user: AuthUser) { 167 isUpdatableBy (user: AuthUser) {
153 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) 168 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
154 } 169 }
170
171 canBeDuplicatedBy (user: AuthUser) {
172 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
173 }
155} 174}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 996202154..3aaf14990 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -27,10 +27,12 @@ import { objectToFormData } from '@app/shared/misc/utils'
27import { Account } from '@app/shared/account/account.model' 27import { Account } from '@app/shared/account/account.model'
28import { AccountService } from '@app/shared/account/account.service' 28import { AccountService } from '@app/shared/account/account.service'
29import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 29import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
30import { ServerService } from '@app/core' 30import { ServerService, AuthService } from '@app/core'
31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' 31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill' 33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
35import { FfprobeData } from 'fluent-ffmpeg'
34 36
35export interface VideosProvider { 37export interface VideosProvider {
36 getVideos (parameters: { 38 getVideos (parameters: {
@@ -49,6 +51,8 @@ export class VideoService implements VideosProvider {
49 51
50 constructor ( 52 constructor (
51 private authHttp: HttpClient, 53 private authHttp: HttpClient,
54 private authService: AuthService,
55 private userService: UserService,
52 private restExtractor: RestExtractor, 56 private restExtractor: RestExtractor,
53 private restService: RestService, 57 private restService: RestService,
54 private serverService: ServerService, 58 private serverService: ServerService,
@@ -199,9 +203,10 @@ export class VideoService implements VideosProvider {
199 filter?: VideoFilter, 203 filter?: VideoFilter,
200 categoryOneOf?: number, 204 categoryOneOf?: number,
201 languageOneOf?: string[], 205 languageOneOf?: string[],
202 skipCount?: boolean 206 skipCount?: boolean,
207 nsfw?: boolean
203 }): Observable<ResultList<Video>> { 208 }): Observable<ResultList<Video>> {
204 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount } = parameters 209 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfw } = parameters
205 210
206 const pagination = this.restService.componentPaginationToRestPagination(videoPagination) 211 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
207 212
@@ -212,6 +217,15 @@ export class VideoService implements VideosProvider {
212 if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '') 217 if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '')
213 if (skipCount) params = params.set('skipCount', skipCount + '') 218 if (skipCount) params = params.set('skipCount', skipCount + '')
214 219
220 if (nsfw) {
221 params = params.set('nsfw', nsfw + '')
222 } else {
223 const nsfwPolicy = this.authService.isLoggedIn()
224 ? this.authService.getUser().nsfwPolicy
225 : this.userService.getAnonymousUser().nsfwPolicy
226 if (this.nsfwPolicyToFilter(nsfwPolicy)) params.set('nsfw', 'false')
227 }
228
215 if (languageOneOf) { 229 if (languageOneOf) {
216 for (const l of languageOneOf) { 230 for (const l of languageOneOf) {
217 params = params.append('languageOneOf[]', l) 231 params = params.append('languageOneOf[]', l)
@@ -278,6 +292,14 @@ export class VideoService implements VideosProvider {
278 return this.buildBaseFeedUrls(params) 292 return this.buildBaseFeedUrls(params)
279 } 293 }
280 294
295 getVideoFileMetadata (metadataUrl: string) {
296 return this.authHttp
297 .get<FfprobeData>(metadataUrl)
298 .pipe(
299 catchError(err => this.restExtractor.handleError(err))
300 )
301 }
302
281 removeVideo (id: number) { 303 removeVideo (id: number) {
282 return this.authHttp 304 return this.authHttp
283 .delete(VideoService.BASE_VIDEO_URL + id) 305 .delete(VideoService.BASE_VIDEO_URL + id)
@@ -368,4 +390,8 @@ export class VideoService implements VideosProvider {
368 catchError(err => this.restExtractor.handleError(err)) 390 catchError(err => this.restExtractor.handleError(err))
369 ) 391 )
370 } 392 }
393
394 private nsfwPolicyToFilter (policy: NSFWPolicyType) {
395 return policy === 'do_not_list'
396 }
371} 397}
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
index 064420056..17e5beb24 100644
--- a/client/src/app/shared/video/videos-selection.component.ts
+++ b/client/src/app/shared/video/videos-selection.component.ts
@@ -22,6 +22,8 @@ import { VideoSortField } from '@app/shared/video/sort-field.type'
22import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 22import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
23import { I18n } from '@ngx-translate/i18n-polyfill' 23import { I18n } from '@ngx-translate/i18n-polyfill'
24import { ResultList } from '@shared/models' 24import { ResultList } from '@shared/models'
25import { UserService } from '../users'
26import { LocalStorageService } from '../misc/storage.service'
25 27
26export type SelectionType = { [ id: number ]: boolean } 28export type SelectionType = { [ id: number ]: boolean }
27 29
@@ -51,7 +53,9 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
51 protected route: ActivatedRoute, 53 protected route: ActivatedRoute,
52 protected notifier: Notifier, 54 protected notifier: Notifier,
53 protected authService: AuthService, 55 protected authService: AuthService,
56 protected userService: UserService,
54 protected screenService: ScreenService, 57 protected screenService: ScreenService,
58 protected storageService: LocalStorageService,
55 protected serverService: ServerService 59 protected serverService: ServerService
56 ) { 60 ) {
57 super() 61 super()