diff options
Diffstat (limited to 'client/src/app/shared/video')
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 @@ | |||
1 | import { debounceTime, first, tap } from 'rxjs/operators' | 1 | import { debounceTime, first, tap, throttleTime } from 'rxjs/operators' |
2 | import { OnDestroy, OnInit } from '@angular/core' | 2 | import { OnDestroy, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' | 4 | import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' |
@@ -14,6 +14,9 @@ import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | |||
14 | import { I18n } from '@ngx-translate/i18n-polyfill' | 14 | import { I18n } from '@ngx-translate/i18n-polyfill' |
15 | import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' | 15 | import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' |
16 | import { ServerConfig } from '@shared/models' | 16 | import { ServerConfig } from '@shared/models' |
17 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | ||
18 | import { UserService, User } from '../users' | ||
19 | import { LocalStorageService } from '../misc/storage.service' | ||
17 | 20 | ||
18 | enum GroupDate { | 21 | enum 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 @@ | |||
1 | import { distinctUntilChanged, filter, map, share, startWith, tap, throttleTime } from 'rxjs/operators' | 1 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
2 | import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 2 | import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
3 | import { fromEvent, Observable, Subscription } from 'rxjs' | 3 | import { 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 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' |
2 | import { Notifier, RedirectService } from '@app/core' | 2 | import { Notifier, RedirectService } from '@app/core' |
3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' | 3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 5 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 6 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 7 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
9 | import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' | 8 | import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' |
9 | import { 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 | }) |
16 | export class VideoBlacklistComponent extends FormReactive implements OnInit { | 16 | export 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' | |||
3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
5 | import { AuthService, Notifier } from '@app/core' | 5 | import { AuthService, Notifier } from '@app/core' |
6 | import { VideoPrivacy, VideoCaption } from '@shared/models' | 6 | import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models' |
7 | import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg' | ||
8 | import { mapValues, pick } from 'lodash-es' | ||
9 | import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | ||
10 | import { BytesPipe } from 'ngx-pipes' | ||
11 | import { VideoService } from '../video.service' | ||
7 | 12 | ||
8 | type DownloadType = 'video' | 'subtitles' | 13 | type DownloadType = 'video' | 'subtitles' |
14 | type 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 @@ | |||
1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { FormReactive } from '../../../shared/forms' | 3 | import { FormReactive } from '../../../shared/forms' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 5 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' | 6 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' |
8 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
9 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
10 | import { VideoAbuseService } from '@app/shared/video-abuse' | 9 | import { VideoAbuseService } from '@app/shared/video-abuse' |
10 | import { 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 | }) |
17 | export class VideoReportComponent extends FormReactive implements OnInit { | 17 | export 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 @@ | |||
1 | import { catchError, map, toArray } from 'rxjs/operators' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor, RestPagination, RestService } from '@app/shared/rest' | ||
5 | import { SortMeta } from 'primeng/api' | ||
6 | import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' | ||
7 | import { concat, Observable } from 'rxjs' | ||
8 | import { environment } from '../../../environments/environment' | ||
9 | |||
10 | @Injectable() | ||
11 | export 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 @@ | |||
1 | import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' |
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | 2 | import { I18n } from '@ngx-translate/i18n-polyfill' |
3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' | 3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' |
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, ConfirmService, Notifier } from '@app/core' |
5 | import { BlocklistService } from '@app/shared/blocklist' | ||
6 | import { Video } from '@app/shared/video/video.model' | 5 | import { Video } from '@app/shared/video/video.model' |
7 | import { VideoService } from '@app/shared/video/video.service' | 6 | import { VideoService } from '@app/shared/video/video.service' |
8 | import { VideoDetails } from '@app/shared/video/video-details.model' | 7 | import { VideoDetails } from '@app/shared/video/video-details.model' |
@@ -14,6 +13,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis | |||
14 | import { VideoBlacklistService } from '@app/shared/video-blacklist' | 13 | import { VideoBlacklistService } from '@app/shared/video-blacklist' |
15 | import { ScreenService } from '@app/shared/misc/screen.service' | 14 | import { ScreenService } from '@app/shared/misc/screen.service' |
16 | import { VideoCaption } from '@shared/models' | 15 | import { VideoCaption } from '@shared/models' |
16 | import { RedundancyService } from '@app/shared/video/redundancy.service' | ||
17 | 17 | ||
18 | export type VideoActionsDisplayType = { | 18 | export 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 | }) |
32 | export class VideoActionsDropdownComponent implements OnChanges { | 33 | export 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' | |||
27 | import { Account } from '@app/shared/account/account.model' | 27 | import { Account } from '@app/shared/account/account.model' |
28 | import { AccountService } from '@app/shared/account/account.service' | 28 | import { AccountService } from '@app/shared/account/account.service' |
29 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 29 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
30 | import { ServerService } from '@app/core' | 30 | import { ServerService, AuthService } from '@app/core' |
31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' | 31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' |
32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
33 | import { I18n } from '@ngx-translate/i18n-polyfill' | 33 | import { I18n } from '@ngx-translate/i18n-polyfill' |
34 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
35 | import { FfprobeData } from 'fluent-ffmpeg' | ||
34 | 36 | ||
35 | export interface VideosProvider { | 37 | export 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' | |||
22 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 22 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
23 | import { I18n } from '@ngx-translate/i18n-polyfill' | 23 | import { I18n } from '@ngx-translate/i18n-polyfill' |
24 | import { ResultList } from '@shared/models' | 24 | import { ResultList } from '@shared/models' |
25 | import { UserService } from '../users' | ||
26 | import { LocalStorageService } from '../misc/storage.service' | ||
25 | 27 | ||
26 | export type SelectionType = { [ id: number ]: boolean } | 28 | export 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() |