aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/video
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-06-23 14:10:17 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-06-23 16:00:49 +0200
commit67ed6552b831df66713bac9e672738796128d33f (patch)
tree59c97d41e0b49d75a90aa3de987968ab9b1ff447 /client/src/app/shared/video
parent0c4bacbff53bc732f5a2677d62a6ead7752e2405 (diff)
downloadPeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.gz
PeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.zst
PeerTube-67ed6552b831df66713bac9e672738796128d33f.zip
Reorganize client shared modules
Diffstat (limited to 'client/src/app/shared/video')
-rw-r--r--client/src/app/shared/video/abstract-video-list.html49
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss75
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts308
-rw-r--r--client/src/app/shared/video/feed.component.html15
-rw-r--r--client/src/app/shared/video/feed.component.scss20
-rw-r--r--client/src/app/shared/video/feed.component.ts11
-rw-r--r--client/src/app/shared/video/infinite-scroller.directive.ts96
-rw-r--r--client/src/app/shared/video/modals/video-block.component.html45
-rw-r--r--client/src/app/shared/video/modals/video-block.component.scss6
-rw-r--r--client/src/app/shared/video/modals/video-block.component.ts75
-rw-r--r--client/src/app/shared/video/modals/video-download.component.html108
-rw-r--r--client/src/app/shared/video/modals/video-download.component.scss64
-rw-r--r--client/src/app/shared/video/modals/video-download.component.ts208
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html97
-rw-r--r--client/src/app/shared/video/modals/video-report.component.scss27
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts163
-rw-r--r--client/src/app/shared/video/recommendation-info.model.ts4
-rw-r--r--client/src/app/shared/video/redundancy.service.ts73
-rw-r--r--client/src/app/shared/video/sort-field.type.ts10
-rw-r--r--client/src/app/shared/video/syndication.model.ts7
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.html21
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.scss12
-rw-r--r--client/src/app/shared/video/video-actions-dropdown.component.ts276
-rw-r--r--client/src/app/shared/video/video-details.model.ts64
-rw-r--r--client/src/app/shared/video/video-edit.model.ts123
-rw-r--r--client/src/app/shared/video/video-miniature.component.html66
-rw-r--r--client/src/app/shared/video/video-miniature.component.scss200
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts285
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.html33
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.scss74
-rw-r--r--client/src/app/shared/video/video-thumbnail.component.ts63
-rw-r--r--client/src/app/shared/video/video.model.ts182
-rw-r--r--client/src/app/shared/video/video.service.ts409
-rw-r--r--client/src/app/shared/video/videos-selection.component.html30
-rw-r--r--client/src/app/shared/video/videos-selection.component.scss57
-rw-r--r--client/src/app/shared/video/videos-selection.component.ts124
36 files changed, 0 insertions, 3480 deletions
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html
deleted file mode 100644
index 1e919ee72..000000000
--- a/client/src/app/shared/video/abstract-video-list.html
+++ /dev/null
@@ -1,49 +0,0 @@
1<div class="margin-content">
2 <div class="videos-header">
3 <h1 *ngIf="titlePage" class="title-page title-page-single">
4 <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
5 {{ titlePage }}
6 </div>
7 <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
8 </h1>
9
10 <div class="action-block" *ngIf="actions.length > 0">
11 <a [routerLink]="action.routerLink" routerLinkActive="active" *ngFor="let action of actions">
12 <button class="btn">
13 <my-global-icon [iconName]="action.iconName" aria-hidden="true"></my-global-icon>
14 <span>{{ action.label }}</span>
15 </button>
16 </a>
17 </div>
18
19 <div class="moderation-block" *ngIf="displayModerationBlock">
20 <my-peertube-checkbox
21 (change)="toggleModerationDisplay()"
22 inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos"
23 >
24 </my-peertube-checkbox>
25 </div>
26 </div>
27
28 <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
29 <div
30 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
31 class="videos"
32 >
33 <ng-container *ngFor="let video of videos; trackBy: videoById;">
34 <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
35 {{ getCurrentGroupedDateLabel(video) }}
36 </h2>
37
38 <div class="video-wrapper">
39 <my-video-miniature
40 [fitWidth]="true"
41 [video]="video" [user]="userMiniature" [ownerDisplayType]="ownerDisplayType"
42 [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
43 (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
44 >
45 </my-video-miniature>
46 </div>
47 </ng-container>
48 </div>
49</div>
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
deleted file mode 100644
index 7f23098aa..000000000
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ /dev/null
@@ -1,75 +0,0 @@
1@import '_bootstrap-variables';
2@import '_variables';
3@import '_mixins';
4@import '_miniature';
5
6.videos-header {
7 display: flex;
8 justify-content: space-between;
9 align-items: baseline;
10
11 .title-page.title-page-single {
12 display: flex;
13
14 my-feed {
15 display: inline-block;
16 top: 1px;
17 margin-left: 5px;
18 width: max-content;
19 opacity: 0;
20 transition: ease-in .2s opacity;
21 }
22 &:hover my-feed {
23 opacity: 1;
24 }
25 }
26
27 .action-block {
28 a button {
29 @include peertube-button;
30 @include grey-button;
31 @include button-with-icon(18px, 3px, -1px);
32 }
33 }
34
35 .moderation-block {
36 display: flex;
37 flex-grow: 1;
38 justify-content: flex-end;
39 align-items: center;
40 }
41}
42
43.date-title {
44 font-size: 16px;
45 font-weight: $font-semibold;
46 margin-bottom: 20px;
47 margin-top: -10px;
48
49 // make the element span a full grid row within .videos grid
50 grid-column: 1 / -1;
51
52 &:not(:first-child) {
53 margin-top: .5rem;
54 padding-top: 20px;
55 border-top: 1px solid $separator-border-color;
56 }
57}
58
59.margin-content {
60 @include fluid-videos-miniature-layout;
61}
62
63@media screen and (max-width: $mobile-view) {
64 .videos-header {
65 flex-direction: column;
66 align-items: center;
67 height: auto;
68 margin-bottom: 10px;
69
70 .title-page {
71 margin-bottom: 10px;
72 margin-right: 0px;
73 }
74 }
75}
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
deleted file mode 100644
index 0bc339ff6..000000000
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ /dev/null
@@ -1,308 +0,0 @@
1import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs'
2import { debounceTime, tap, throttleTime, switchMap } from 'rxjs/operators'
3import { OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router'
5import { Notifier, ServerService } from '@app/core'
6import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
7import { GlobalIconName } from '@app/shared/images/global-icon.component'
8import { ScreenService } from '@app/shared/misc/screen.service'
9import { Syndication } from '@app/shared/video/syndication.model'
10import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
11import { I18n } from '@ngx-translate/i18n-polyfill'
12import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
13import { ServerConfig } from '@shared/models'
14import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
15import { AuthService } from '../../core/auth'
16import { LocalStorageService } from '../misc/storage.service'
17import { ComponentPaginationLight } from '../rest/component-pagination.model'
18import { User, UserService } from '../users'
19import { VideoSortField } from './sort-field.type'
20import { Video } from './video.model'
21
22enum GroupDate {
23 UNKNOWN = 0,
24 TODAY = 1,
25 YESTERDAY = 2,
26 LAST_WEEK = 3,
27 LAST_MONTH = 4,
28 OLDER = 5
29}
30
31export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook {
32 pagination: ComponentPaginationLight = {
33 currentPage: 1,
34 itemsPerPage: 25
35 }
36 sort: VideoSortField = '-publishedAt'
37
38 categoryOneOf?: number[]
39 languageOneOf?: string[]
40 nsfwPolicy?: NSFWPolicyType
41 defaultSort: VideoSortField = '-publishedAt'
42
43 syndicationItems: Syndication[] = []
44
45 loadOnInit = true
46 useUserVideoPreferences = false
47 ownerDisplayType: OwnerDisplayType = 'account'
48 displayModerationBlock = false
49 titleTooltip: string
50 displayVideoActions = true
51 groupByDate = false
52
53 videos: Video[] = []
54 hasDoneFirstQuery = false
55 disabled = false
56
57 displayOptions: MiniatureDisplayOptions = {
58 date: true,
59 views: true,
60 by: true,
61 avatar: false,
62 privacyLabel: true,
63 privacyText: false,
64 state: false,
65 blacklistInfo: false
66 }
67
68 actions: {
69 routerLink: string
70 iconName: GlobalIconName
71 label: string
72 }[] = []
73
74 onDataSubject = new Subject<any[]>()
75
76 userMiniature: User
77
78 protected serverConfig: ServerConfig
79
80 protected abstract notifier: Notifier
81 protected abstract authService: AuthService
82 protected abstract userService: UserService
83 protected abstract route: ActivatedRoute
84 protected abstract serverService: ServerService
85 protected abstract screenService: ScreenService
86 protected abstract storageService: LocalStorageService
87 protected abstract router: Router
88 protected abstract i18n: I18n
89 abstract titlePage: string
90
91 private resizeSubscription: Subscription
92 private angularState: number
93
94 private groupedDateLabels: { [id in GroupDate]: string }
95 private groupedDates: { [id: number]: GroupDate } = {}
96
97 private lastQueryLength: number
98
99 abstract getVideosObservable (page: number): Observable<{ data: Video[] }>
100
101 abstract generateSyndicationList (): void
102
103 ngOnInit () {
104 this.serverConfig = this.serverService.getTmpConfig()
105 this.serverService.getConfig()
106 .subscribe(config => this.serverConfig = config)
107
108 this.groupedDateLabels = {
109 [GroupDate.UNKNOWN]: null,
110 [GroupDate.TODAY]: this.i18n('Today'),
111 [GroupDate.YESTERDAY]: this.i18n('Yesterday'),
112 [GroupDate.LAST_WEEK]: this.i18n('Last week'),
113 [GroupDate.LAST_MONTH]: this.i18n('Last month'),
114 [GroupDate.OLDER]: this.i18n('Older')
115 }
116
117 // Subscribe to route changes
118 const routeParams = this.route.snapshot.queryParams
119 this.loadRouteParams(routeParams)
120
121 this.resizeSubscription = fromEvent(window, 'resize')
122 .pipe(debounceTime(500))
123 .subscribe(() => this.calcPageSizes())
124
125 this.calcPageSizes()
126
127 const loadUserObservable = this.loadUserAndSettings()
128
129 if (this.loadOnInit === true) {
130 loadUserObservable.subscribe(() => this.loadMoreVideos())
131 }
132
133 this.userService.listenAnonymousUpdate()
134 .pipe(switchMap(() => this.loadUserAndSettings()))
135 .subscribe(() => {
136 if (this.hasDoneFirstQuery) this.reloadVideos()
137 })
138
139 // Display avatar in mobile view
140 if (this.screenService.isInMobileView()) {
141 this.displayOptions.avatar = true
142 }
143 }
144
145 ngOnDestroy () {
146 if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
147 }
148
149 disableForReuse () {
150 this.disabled = true
151 }
152
153 enabledForReuse () {
154 this.disabled = false
155 }
156
157 videoById (index: number, video: Video) {
158 return video.id
159 }
160
161 onNearOfBottom () {
162 if (this.disabled) return
163
164 // No more results
165 if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
166
167 this.pagination.currentPage += 1
168
169 this.setScrollRouteParams()
170
171 this.loadMoreVideos()
172 }
173
174 loadMoreVideos (reset = false) {
175 this.getVideosObservable(this.pagination.currentPage).subscribe(
176 ({ data }) => {
177 this.hasDoneFirstQuery = true
178 this.lastQueryLength = data.length
179
180 if (reset) this.videos = []
181 this.videos = this.videos.concat(data)
182
183 if (this.groupByDate) this.buildGroupedDateLabels()
184
185 this.onMoreVideos()
186
187 this.onDataSubject.next(data)
188 },
189
190 error => {
191 const message = this.i18n('Cannot load more videos. Try again later.')
192
193 console.error(message, { error })
194 this.notifier.error(message)
195 }
196 )
197 }
198
199 reloadVideos () {
200 this.pagination.currentPage = 1
201 this.loadMoreVideos(true)
202 }
203
204 toggleModerationDisplay () {
205 throw new Error('toggleModerationDisplay is not implemented')
206 }
207
208 removeVideoFromArray (video: Video) {
209 this.videos = this.videos.filter(v => v.id !== video.id)
210 }
211
212 buildGroupedDateLabels () {
213 let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
214
215 for (const video of this.videos) {
216 const publishedDate = video.publishedAt
217
218 if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) {
219 if (currentGroupedDate === GroupDate.TODAY) continue
220
221 currentGroupedDate = GroupDate.TODAY
222 this.groupedDates[ video.id ] = currentGroupedDate
223 continue
224 }
225
226 if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) {
227 if (currentGroupedDate === GroupDate.YESTERDAY) continue
228
229 currentGroupedDate = GroupDate.YESTERDAY
230 this.groupedDates[ video.id ] = currentGroupedDate
231 continue
232 }
233
234 if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) {
235 if (currentGroupedDate === GroupDate.LAST_WEEK) continue
236
237 currentGroupedDate = GroupDate.LAST_WEEK
238 this.groupedDates[ video.id ] = currentGroupedDate
239 continue
240 }
241
242 if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) {
243 if (currentGroupedDate === GroupDate.LAST_MONTH) continue
244
245 currentGroupedDate = GroupDate.LAST_MONTH
246 this.groupedDates[ video.id ] = currentGroupedDate
247 continue
248 }
249
250 if (currentGroupedDate <= GroupDate.OLDER) {
251 if (currentGroupedDate === GroupDate.OLDER) continue
252
253 currentGroupedDate = GroupDate.OLDER
254 this.groupedDates[ video.id ] = currentGroupedDate
255 }
256 }
257 }
258
259 getCurrentGroupedDateLabel (video: Video) {
260 if (this.groupByDate === false) return undefined
261
262 return this.groupedDateLabels[this.groupedDates[video.id]]
263 }
264
265 // On videos hook for children that want to do something
266 protected onMoreVideos () { /* empty */ }
267
268 protected loadRouteParams (routeParams: { [ key: string ]: any }) {
269 this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort
270 this.categoryOneOf = routeParams[ 'categoryOneOf' ]
271 this.angularState = routeParams[ 'a-state' ]
272 }
273
274 private calcPageSizes () {
275 if (this.screenService.isInMobileView()) {
276 this.pagination.itemsPerPage = 5
277 }
278 }
279
280 private setScrollRouteParams () {
281 // Already set
282 if (this.angularState) return
283
284 this.angularState = 42
285
286 const queryParams = {
287 'a-state': this.angularState,
288 categoryOneOf: this.categoryOneOf
289 }
290
291 let path = this.router.url
292 if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute
293
294 this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
295 }
296
297 private loadUserAndSettings () {
298 return this.userService.getAnonymousOrLoggedUser()
299 .pipe(tap(user => {
300 this.userMiniature = user
301
302 if (!this.useUserVideoPreferences) return
303
304 this.languageOneOf = user.videoLanguages
305 this.nsfwPolicy = user.nsfwPolicy
306 }))
307 }
308}
diff --git a/client/src/app/shared/video/feed.component.html b/client/src/app/shared/video/feed.component.html
deleted file mode 100644
index ac0b1f454..000000000
--- a/client/src/app/shared/video/feed.component.html
+++ /dev/null
@@ -1,15 +0,0 @@
1<div class="video-feed"
2 [ngbTooltip]="'Feeds available'"
3 placement="right auto"
4 container="body"
5>
6 <my-global-icon
7 *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
8 class="icon-syndication" role="button" iconName="syndication"
9 >
10 </my-global-icon>
11
12 <ng-template #feedsList>
13 <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
14 </ng-template>
15</div>
diff --git a/client/src/app/shared/video/feed.component.scss b/client/src/app/shared/video/feed.component.scss
deleted file mode 100644
index 34dd0e937..000000000
--- a/client/src/app/shared/video/feed.component.scss
+++ /dev/null
@@ -1,20 +0,0 @@
1@import '_variables';
2@import '_mixins';
3
4.video-feed {
5 width: min-content;
6
7 a {
8 color: black;
9 display: block;
10 }
11
12 my-global-icon {
13 cursor: pointer;
14 width: 12px;
15 position: relative;
16 top: -2px;
17
18 @include apply-svg-color(pvar(--mainForegroundColor))
19 }
20}
diff --git a/client/src/app/shared/video/feed.component.ts b/client/src/app/shared/video/feed.component.ts
deleted file mode 100644
index 12507458f..000000000
--- a/client/src/app/shared/video/feed.component.ts
+++ /dev/null
@@ -1,11 +0,0 @@
1import { Component, Input } from '@angular/core'
2import { Syndication } from '@app/shared/video/syndication.model'
3
4@Component({
5 selector: 'my-feed',
6 styleUrls: [ './feed.component.scss' ],
7 templateUrl: './feed.component.html'
8})
9export class FeedComponent {
10 @Input() syndicationItems: Syndication[]
11}
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts
deleted file mode 100644
index f09c3d1fc..000000000
--- a/client/src/app/shared/video/infinite-scroller.directive.ts
+++ /dev/null
@@ -1,96 +0,0 @@
1import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
3import { fromEvent, Observable, Subscription } from 'rxjs'
4
5@Directive({
6 selector: '[myInfiniteScroller]'
7})
8export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterContentChecked {
9 @Input() percentLimit = 70
10 @Input() autoInit = false
11 @Input() onItself = false
12 @Input() dataObservable: Observable<any[]>
13
14 @Output() nearOfBottom = new EventEmitter<void>()
15
16 private decimalLimit = 0
17 private lastCurrentBottom = -1
18 private scrollDownSub: Subscription
19 private container: HTMLElement
20
21 private checkScroll = false
22
23 constructor (private el: ElementRef) {
24 this.decimalLimit = this.percentLimit / 100
25 }
26
27 ngAfterContentChecked () {
28 if (this.checkScroll) {
29 this.checkScroll = false
30
31 console.log('Checking if the initial state has a scroll.')
32
33 if (this.hasScroll() === false) this.nearOfBottom.emit()
34 }
35 }
36
37 ngOnInit () {
38 if (this.autoInit === true) return this.initialize()
39 }
40
41 ngOnDestroy () {
42 if (this.scrollDownSub) this.scrollDownSub.unsubscribe()
43 }
44
45 initialize () {
46 this.container = this.onItself
47 ? this.el.nativeElement
48 : document.documentElement
49
50 // Emit the last value
51 const throttleOptions = { leading: true, trailing: true }
52
53 const scrollableElement = this.onItself ? this.container : window
54 const scrollObservable = fromEvent(scrollableElement, 'scroll')
55 .pipe(
56 startWith(true),
57 throttleTime(200, undefined, throttleOptions),
58 map(() => this.getScrollInfo()),
59 distinctUntilChanged((o1, o2) => o1.current === o2.current),
60 share()
61 )
62
63 // Scroll Down
64 this.scrollDownSub = scrollObservable
65 .pipe(
66 filter(({ current }) => this.isScrollingDown(current)),
67 filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
68 )
69 .subscribe(() => this.nearOfBottom.emit())
70
71 if (this.dataObservable) {
72 this.dataObservable
73 .pipe(filter(d => d.length !== 0))
74 .subscribe(() => this.checkScroll = true)
75 }
76 }
77
78 private getScrollInfo () {
79 return { current: this.container.scrollTop, maximumScroll: this.getMaximumScroll() }
80 }
81
82 private getMaximumScroll () {
83 return this.container.scrollHeight - window.innerHeight
84 }
85
86 private hasScroll () {
87 return this.getMaximumScroll() > 0
88 }
89
90 private isScrollingDown (current: number) {
91 const result = this.lastCurrentBottom < current
92
93 this.lastCurrentBottom = current
94 return result
95 }
96}
diff --git a/client/src/app/shared/video/modals/video-block.component.html b/client/src/app/shared/video/modals/video-block.component.html
deleted file mode 100644
index 5e73d66c5..000000000
--- a/client/src/app/shared/video/modals/video-block.component.html
+++ /dev/null
@@ -1,45 +0,0 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8
9 <form novalidate [formGroup]="form" (ngSubmit)="block()">
10 <div class="form-group">
11 <textarea
12 i18n-placeholder placeholder="Please describe the reason..." formControlName="reason"
13 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
14 ></textarea>
15 <div *ngIf="formErrors.reason" class="form-error">
16 {{ formErrors.reason }}
17 </div>
18 </div>
19
20 <div class="form-group" *ngIf="video.isLocal">
21 <my-peertube-checkbox
22 inputName="unfederate" formControlName="unfederate"
23 i18n-labelText labelText="Unfederate the video"
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>
29 </div>
30
31 <div class="form-group inputs">
32 <input
33 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
34 (click)="hide()" (key.enter)="hide()"
35 >
36
37 <input
38 type="submit" i18n-value value="Submit" class="action-button-submit"
39 [disabled]="!form.valid"
40 >
41 </div>
42 </form>
43
44 </div>
45</ng-template>
diff --git a/client/src/app/shared/video/modals/video-block.component.scss b/client/src/app/shared/video/modals/video-block.component.scss
deleted file mode 100644
index afcdb9a16..000000000
--- a/client/src/app/shared/video/modals/video-block.component.scss
+++ /dev/null
@@ -1,6 +0,0 @@
1@import 'variables';
2@import 'mixins';
3
4textarea {
5 @include peertube-textarea(100%, 100px);
6}
diff --git a/client/src/app/shared/video/modals/video-block.component.ts b/client/src/app/shared/video/modals/video-block.component.ts
deleted file mode 100644
index 1a25e0578..000000000
--- a/client/src/app/shared/video/modals/video-block.component.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, RedirectService } from '@app/core'
3import { VideoBlockService } from '../../video-block'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8import { FormReactive, VideoBlockValidatorsService } from '@app/shared/forms'
9import { Video } from '@app/shared/video/video.model'
10
11@Component({
12 selector: 'my-video-block',
13 templateUrl: './video-block.component.html',
14 styleUrls: [ './video-block.component.scss' ]
15})
16export class VideoBlockComponent extends FormReactive implements OnInit {
17 @Input() video: Video = null
18
19 @ViewChild('modal', { static: true }) modal: NgbModal
20
21 @Output() videoBlocked = new EventEmitter()
22
23 error: string = null
24
25 private openedModal: NgbModalRef
26
27 constructor (
28 protected formValidatorService: FormValidatorService,
29 private modalService: NgbModal,
30 private videoBlockValidatorsService: VideoBlockValidatorsService,
31 private videoBlocklistService: VideoBlockService,
32 private notifier: Notifier,
33 private i18n: I18n
34 ) {
35 super()
36 }
37
38 ngOnInit () {
39 const defaultValues = { unfederate: 'true' }
40
41 this.buildForm({
42 reason: this.videoBlockValidatorsService.VIDEO_BLOCK_REASON,
43 unfederate: null
44 }, defaultValues)
45 }
46
47 show () {
48 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
49 }
50
51 hide () {
52 this.openedModal.close()
53 this.openedModal = null
54 }
55
56 block () {
57 const reason = this.form.value[ 'reason' ] || undefined
58 const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined
59
60 this.videoBlocklistService.blockVideo(this.video.id, reason, unfederate)
61 .subscribe(
62 () => {
63 this.notifier.success(this.i18n('Video blocked.'))
64 this.hide()
65
66 this.video.blacklisted = true
67 this.video.blockedReason = reason
68
69 this.videoBlocked.emit()
70 },
71
72 err => this.notifier.error(err.message)
73 )
74 }
75}
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html
deleted file mode 100644
index c65e371ee..000000000
--- a/client/src/app/shared/video/modals/video-download.component.html
+++ /dev/null
@@ -1,108 +0,0 @@
1<ng-template #modal let-hide="close">
2 <div class="modal-header">
3 <h4 class="modal-title">
4 <ng-container i18n>Download</ng-container>
5
6 <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
7 <span id="dropdownDownloadType" ngbDropdownToggle>
8 {{ type }}
9 </span>
10 <div ngbDropdownMenu aria-labelledby="dropdownDownloadType">
11 <button *ngIf="type === 'video'" (click)="switchToType('subtitles')" ngbDropdownItem i18n>subtitles</button>
12 <button *ngIf="type === 'subtitles'" (click)="switchToType('video')" ngbDropdownItem i18n>video</button>
13 </div>
14 </div>
15 </h4>
16 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
17 </div>
18
19 <div class="modal-body">
20 <div class="form-group">
21 <div class="input-group input-group-sm">
22 <div class="input-group-prepend peertube-select-container">
23 <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
24 <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
25 </select>
26
27 <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
28 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
29 </select>
30 </div>
31
32 <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
33 <div class="input-group-append">
34 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
35 <span class="glyphicon glyphicon-copy"></span>
36 </button>
37 </div>
38 </div>
39 </div>
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
84 <div class="download-type" *ngIf="type === 'video'">
85 <div class="peertube-radio-container">
86 <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
87 <label i18n for="download-direct">Direct download</label>
88 </div>
89
90 <div class="peertube-radio-container">
91 <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
92 <label i18n for="download-torrent">Torrent (.torrent file)</label>
93 </div>
94 </div>
95 </div>
96
97 <div class="modal-footer inputs">
98 <input
99 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
100 (click)="hide()" (key.enter)="hide()"
101 >
102
103 <input
104 type="submit" i18n-value value="Download" class="action-button-submit"
105 (click)="download()"
106 >
107 </div>
108</ng-template>
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss
deleted file mode 100644
index b09078bea..000000000
--- a/client/src/app/shared/video/modals/video-download.component.scss
+++ /dev/null
@@ -1,64 +0,0 @@
1@import 'variables';
2@import 'mixins';
3
4.peertube-select-container {
5 @include peertube-select-container(100px);
6
7 border-top-right-radius: 0;
8 border-bottom-right-radius: 0;
9 border-right: none;
10
11 select {
12 height: inherit;
13 }
14}
15
16#dropdownDownloadType {
17 cursor: pointer;
18}
19
20.download-type {
21 margin-top: 30px;
22
23 .peertube-radio-container {
24 @include peertube-radio-container;
25
26 display: inline-block;
27 margin-right: 30px;
28 }
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: pvar(--greyForegroundColor);
45 font-weight: $font-bold;
46 }
47
48 a.metadata-attribute-value {
49 @include disable-default-a-behaviour;
50 color: pvar(--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
deleted file mode 100644
index d77187821..000000000
--- a/client/src/app/shared/video/modals/video-download.component.ts
+++ /dev/null
@@ -1,208 +0,0 @@
1import { Component, ElementRef, ViewChild } from '@angular/core'
2import { VideoDetails } from '../../../shared/video/video-details.model'
3import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { AuthService, Notifier } from '@app/core'
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'
12
13type DownloadType = 'video' | 'subtitles'
14type FileMetadata = { [key: string]: { label: string, value: string }}
15
16@Component({
17 selector: 'my-video-download',
18 templateUrl: './video-download.component.html',
19 styleUrls: [ './video-download.component.scss' ]
20})
21export class VideoDownloadComponent {
22 @ViewChild('modal', { static: true }) modal: ElementRef
23
24 downloadType: 'direct' | 'torrent' = 'torrent'
25 resolutionId: number | string = -1
26 subtitleLanguageId: string
27
28 video: VideoDetails
29 videoFile: VideoFile
30 videoFileMetadataFormat: FileMetadata
31 videoFileMetadataVideoStream: FileMetadata | undefined
32 videoFileMetadataAudioStream: FileMetadata | undefined
33 videoCaptions: VideoCaption[]
34 activeModal: NgbActiveModal
35
36 type: DownloadType = 'video'
37
38 private bytesPipe: BytesPipe
39 private numbersPipe: NumberFormatterPipe
40
41 constructor (
42 private notifier: Notifier,
43 private modalService: NgbModal,
44 private videoService: VideoService,
45 private auth: AuthService,
46 private i18n: I18n
47 ) {
48 this.bytesPipe = new BytesPipe()
49 this.numbersPipe = new NumberFormatterPipe()
50 }
51
52 get typeText () {
53 return this.type === 'video'
54 ? this.i18n('video')
55 : this.i18n('subtitles')
56 }
57
58 getVideoFiles () {
59 if (!this.video) return []
60
61 return this.video.getFiles()
62 }
63
64 show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
65 this.video = video
66 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
67
68 this.activeModal = this.modalService.open(this.modal, { centered: true })
69
70 this.resolutionId = this.getVideoFiles()[0].resolution.id
71 this.onResolutionIdChange()
72 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
73 }
74
75 onClose () {
76 this.video = undefined
77 this.videoCaptions = undefined
78 }
79
80 download () {
81 window.location.assign(this.getLink())
82 this.activeModal.close()
83 }
84
85 getLink () {
86 return this.type === 'subtitles' && this.videoCaptions
87 ? this.getSubtitlesLink()
88 : this.getVideoFileLink()
89 }
90
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 () {
109 // HTML select send us a string, so convert it to a number
110 this.resolutionId = parseInt(this.resolutionId.toString(), 10)
111
112 const file = this.getVideoFiles().find(f => f.resolution.id === this.resolutionId)
113 if (!file) {
114 console.error('Could not find file with resolution %d.', this.resolutionId)
115 return
116 }
117 return file
118 }
119
120 getVideoFileLink () {
121 const file = this.videoFile
122 if (!file) return
123
124 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
125 ? '?access_token=' + this.auth.getAccessToken()
126 : ''
127
128 switch (this.downloadType) {
129 case 'direct':
130 return file.fileDownloadUrl + suffix
131
132 case 'torrent':
133 return file.torrentDownloadUrl + suffix
134 }
135 }
136
137 getSubtitlesLink () {
138 return window.location.origin + this.videoCaptions.find(caption => caption.language.id === this.subtitleLanguageId).captionPath
139 }
140
141 activateCopiedMessage () {
142 this.notifier.success(this.i18n('Copied'))
143 }
144
145 switchToType (type: DownloadType) {
146 this.type = type
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 }
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
deleted file mode 100644
index d6beb6d2a..000000000
--- a/client/src/app/shared/video/modals/video-report.component.html
+++ /dev/null
@@ -1,97 +0,0 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
5 </div>
6
7 <div class="modal-body">
8 <form novalidate [formGroup]="form" (ngSubmit)="report()">
9
10 <div class="row">
11 <div class="col-5 form-group">
12
13 <label i18n for="reportPredefinedReasons">What is the issue?</label>
14
15 <div class="ml-2 mt-2 d-flex flex-column">
16 <ng-container formGroupName="predefinedReasons">
17 <div class="form-group" *ngFor="let reason of predefinedReasons">
18 <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
19 <ng-template *ngIf="reason.help" ptTemplate="help">
20 <div [innerHTML]="reason.help"></div>
21 </ng-template>
22 <ng-container *ngIf="reason.description" ngProjectAs="description">
23 <div [innerHTML]="reason.description"></div>
24 </ng-container>
25 </my-peertube-checkbox>
26 </div>
27 </ng-container>
28 </div>
29
30 </div>
31
32 <div class="col-7">
33 <div class="row justify-content-center">
34 <div class="col-12 col-lg-9 mb-2">
35 <div class="screenratio">
36 <div [innerHTML]="embedHtml"></div>
37 </div>
38 </div>
39 </div>
40
41 <div class="mb-1 start-at" formGroupName="timestamp">
42 <my-peertube-checkbox
43 formControlName="hasStart"
44 i18n-labelText labelText="Start at"
45 ></my-peertube-checkbox>
46
47 <my-timestamp-input
48 [timestamp]="timestamp.startAt"
49 [maxTimestamp]="video.duration"
50 formControlName="startAt"
51 inputName="startAt"
52 >
53 </my-timestamp-input>
54 </div>
55
56 <div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart">
57 <my-peertube-checkbox
58 formControlName="hasEnd"
59 i18n-labelText labelText="Stop at"
60 ></my-peertube-checkbox>
61
62 <my-timestamp-input
63 [timestamp]="timestamp.endAt"
64 [maxTimestamp]="video.duration"
65 formControlName="endAt"
66 inputName="endAt"
67 >
68 </my-timestamp-input>
69 </div>
70
71 <div i18n class="information">
72 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>.
73 </div>
74
75 <div class="form-group">
76 <textarea
77 i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
78 [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
79 ></textarea>
80 <div *ngIf="formErrors.reason" class="form-error">
81 {{ formErrors.reason }}
82 </div>
83 </div>
84 </div>
85 </div>
86
87 <div class="form-group inputs">
88 <input
89 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
90 (click)="hide()" (key.enter)="hide()"
91 >
92 <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
93 </div>
94
95 </form>
96 </div>
97</ng-template>
diff --git a/client/src/app/shared/video/modals/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss
deleted file mode 100644
index b2606cbd8..000000000
--- a/client/src/app/shared/video/modals/video-report.component.scss
+++ /dev/null
@@ -1,27 +0,0 @@
1@import 'variables';
2@import 'mixins';
3
4.information {
5 margin-bottom: 20px;
6}
7
8textarea {
9 @include peertube-textarea(100%, 100px);
10}
11
12.start-at,
13.stop-at {
14 width: 300px;
15 display: flex;
16 align-items: center;
17
18 my-timestamp-input {
19 margin-left: 10px;
20 }
21}
22
23.screenratio {
24 @include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
25 left: 0;
26 };
27}
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts
deleted file mode 100644
index c2d441bba..000000000
--- a/client/src/app/shared/video/modals/video-report.component.ts
+++ /dev/null
@@ -1,163 +0,0 @@
1import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core'
3import { FormReactive } from '../../../shared/forms'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
6import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { VideoAbuseService } from '@app/shared/video-abuse'
10import { Video } from '@app/shared/video/video.model'
11import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
12import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
13import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
14import { mapValues, pickBy } from 'lodash-es'
15
16@Component({
17 selector: 'my-video-report',
18 templateUrl: './video-report.component.html',
19 styleUrls: [ './video-report.component.scss' ]
20})
21export class VideoReportComponent extends FormReactive implements OnInit {
22 @Input() video: Video = null
23
24 @ViewChild('modal', { static: true }) modal: NgbModal
25
26 error: string = null
27 predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
28 embedHtml: SafeHtml
29
30 private openedModal: NgbModalRef
31
32 constructor (
33 protected formValidatorService: FormValidatorService,
34 private modalService: NgbModal,
35 private videoAbuseValidatorsService: VideoAbuseValidatorsService,
36 private videoAbuseService: VideoAbuseService,
37 private notifier: Notifier,
38 private sanitizer: DomSanitizer,
39 private i18n: I18n
40 ) {
41 super()
42 }
43
44 get currentHost () {
45 return window.location.host
46 }
47
48 get originHost () {
49 if (this.isRemoteVideo()) {
50 return this.video.account.host
51 }
52
53 return ''
54 }
55
56 get timestamp () {
57 return this.form.get('timestamp').value
58 }
59
60 getVideoEmbed () {
61 return this.sanitizer.bypassSecurityTrustHtml(
62 buildVideoEmbed(
63 buildVideoLink({
64 baseUrl: this.video.embedUrl,
65 title: false,
66 warningTitle: false
67 })
68 )
69 )
70 }
71
72 ngOnInit () {
73 this.buildForm({
74 reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
75 predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
76 timestamp: {
77 hasStart: null,
78 startAt: null,
79 hasEnd: null,
80 endAt: null
81 }
82 })
83
84 this.predefinedReasons = [
85 {
86 id: 'violentOrRepulsive',
87 label: this.i18n('Violent or repulsive'),
88 help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
89 },
90 {
91 id: 'hatefulOrAbusive',
92 label: this.i18n('Hateful or abusive'),
93 help: this.i18n('Contains abusive, racist or sexist language or iconography.')
94 },
95 {
96 id: 'spamOrMisleading',
97 label: this.i18n('Spam, ad or false news'),
98 help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
99 },
100 {
101 id: 'privacy',
102 label: this.i18n('Privacy breach or doxxing'),
103 help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
104 },
105 {
106 id: 'rights',
107 label: this.i18n('Intellectual property violation'),
108 help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
109 },
110 {
111 id: 'serverRules',
112 label: this.i18n('Breaks server rules'),
113 description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
114 },
115 {
116 id: 'thumbnails',
117 label: this.i18n('Thumbnails'),
118 help: this.i18n('The above can only be seen in thumbnails.')
119 },
120 {
121 id: 'captions',
122 label: this.i18n('Captions'),
123 help: this.i18n('The above can only be seen in captions (please describe which).')
124 }
125 ]
126
127 this.embedHtml = this.getVideoEmbed()
128 }
129
130 show () {
131 this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
132 }
133
134 hide () {
135 this.openedModal.close()
136 this.openedModal = null
137 }
138
139 report () {
140 const reason = this.form.get('reason').value
141 const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
142 const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
143
144 this.videoAbuseService.reportVideo({
145 id: this.video.id,
146 reason,
147 predefinedReasons,
148 startAt: hasStart && startAt ? startAt : undefined,
149 endAt: hasEnd && endAt ? endAt : undefined
150 }).subscribe(
151 () => {
152 this.notifier.success(this.i18n('Video reported.'))
153 this.hide()
154 },
155
156 err => this.notifier.error(err.message)
157 )
158 }
159
160 isRemoteVideo () {
161 return !this.video.isLocal
162 }
163}
diff --git a/client/src/app/shared/video/recommendation-info.model.ts b/client/src/app/shared/video/recommendation-info.model.ts
deleted file mode 100644
index 0233563bb..000000000
--- a/client/src/app/shared/video/recommendation-info.model.ts
+++ /dev/null
@@ -1,4 +0,0 @@
1export interface RecommendationInfo {
2 uuid: string
3 tags?: string[]
4}
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts
deleted file mode 100644
index fb918d73b..000000000
--- a/client/src/app/shared/video/redundancy.service.ts
+++ /dev/null
@@ -1,73 +0,0 @@
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/sort-field.type.ts b/client/src/app/shared/video/sort-field.type.ts
deleted file mode 100644
index 65b24d946..000000000
--- a/client/src/app/shared/video/sort-field.type.ts
+++ /dev/null
@@ -1,10 +0,0 @@
1export type VideoSortField = 'name' | '-name'
2 | 'duration' | '-duration'
3 | 'publishedAt' | '-publishedAt'
4 | 'createdAt' | '-createdAt'
5 | 'views' | '-views'
6 | 'likes' | '-likes'
7 | 'trending' | '-trending'
8
9export type CommentSortField = 'createdAt' | '-createdAt'
10 | 'totalReplies' | '-totalReplies'
diff --git a/client/src/app/shared/video/syndication.model.ts b/client/src/app/shared/video/syndication.model.ts
deleted file mode 100644
index c59ab01e8..000000000
--- a/client/src/app/shared/video/syndication.model.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
2
3export interface Syndication {
4 format: FeedFormat,
5 label: string,
6 url: string
7}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.html b/client/src/app/shared/video/video-actions-dropdown.component.html
deleted file mode 100644
index 3c8271b65..000000000
--- a/client/src/app/shared/video/video-actions-dropdown.component.html
+++ /dev/null
@@ -1,21 +0,0 @@
1<ng-container *ngIf="videoActions.length !== 0">
2
3 <div class="playlist-dropdown" ngbDropdown #playlistDropdown="ngbDropdown" role="button" autoClose="outside" [placement]="getPlaylistDropdownPlacement()"
4 *ngIf="isUserLoggedIn() && displayOptions.playlist" (openChange)="playlistAdd.openChange($event)"
5 >
6 <span class="anchor" ngbDropdownAnchor></span>
7
8 <div ngbDropdownMenu>
9 <my-video-add-to-playlist #playlistAdd [video]="video" [lazyLoad]="true"></my-video-add-to-playlist>
10 </div>
11 </div>
12
13 <my-action-dropdown
14 [actions]="videoActions" [label]="label" [entry]="{ video: video }" (click)="loadDropdownInformation()"
15 [buttonSize]="buttonSize" [placement]="placement" [buttonDirection]="buttonDirection" [buttonStyled]="buttonStyled"
16 ></my-action-dropdown>
17
18 <my-video-download #videoDownloadModal></my-video-download>
19 <my-video-report #videoReportModal [video]="video"></my-video-report>
20 <my-video-block #videoBlockModal [video]="video" (videoBlocked)="onVideoBlocked()"></my-video-block>
21</ng-container>
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.scss b/client/src/app/shared/video/video-actions-dropdown.component.scss
deleted file mode 100644
index 67d7ee86a..000000000
--- a/client/src/app/shared/video/video-actions-dropdown.component.scss
+++ /dev/null
@@ -1,12 +0,0 @@
1.playlist-dropdown {
2 position: absolute;
3
4 .anchor {
5 display: block;
6 opacity: 0;
7 }
8}
9
10::ng-deep .icon-playlist-add {
11 left: 2px;
12}
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts
deleted file mode 100644
index 1f5763610..000000000
--- a/client/src/app/shared/video/video-actions-dropdown.component.ts
+++ /dev/null
@@ -1,276 +0,0 @@
1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component'
4import { AuthService, ConfirmService, Notifier } from '@app/core'
5import { Video } from '@app/shared/video/video.model'
6import { VideoService } from '@app/shared/video/video.service'
7import { VideoDetails } from '@app/shared/video/video-details.model'
8import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
9import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
10import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
11import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
12import { VideoBlockComponent } from '@app/shared/video/modals/video-block.component'
13import { VideoBlockService } from '@app/shared/video-block'
14import { ScreenService } from '@app/shared/misc/screen.service'
15import { VideoCaption } from '@shared/models'
16import { RedundancyService } from '@app/shared/video/redundancy.service'
17
18export type VideoActionsDisplayType = {
19 playlist?: boolean
20 download?: boolean
21 update?: boolean
22 blacklist?: boolean
23 delete?: boolean
24 report?: boolean
25 duplicate?: boolean
26}
27
28@Component({
29 selector: 'my-video-actions-dropdown',
30 templateUrl: './video-actions-dropdown.component.html',
31 styleUrls: [ './video-actions-dropdown.component.scss' ]
32})
33export class VideoActionsDropdownComponent implements OnChanges {
34 @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
35 @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
36
37 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
38 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
39 @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
40
41 @Input() video: Video | VideoDetails
42 @Input() videoCaptions: VideoCaption[] = []
43
44 @Input() displayOptions: VideoActionsDisplayType = {
45 playlist: false,
46 download: true,
47 update: true,
48 blacklist: true,
49 delete: true,
50 report: true,
51 duplicate: true
52 }
53 @Input() placement = 'left'
54
55 @Input() label: string
56
57 @Input() buttonStyled = false
58 @Input() buttonSize: DropdownButtonSize = 'normal'
59 @Input() buttonDirection: DropdownDirection = 'vertical'
60
61 @Output() videoRemoved = new EventEmitter()
62 @Output() videoUnblocked = new EventEmitter()
63 @Output() videoBlocked = new EventEmitter()
64 @Output() modalOpened = new EventEmitter()
65
66 videoActions: DropdownAction<{ video: Video }>[][] = []
67
68 private loaded = false
69
70 constructor (
71 private authService: AuthService,
72 private notifier: Notifier,
73 private confirmService: ConfirmService,
74 private videoBlocklistService: VideoBlockService,
75 private screenService: ScreenService,
76 private videoService: VideoService,
77 private redundancyService: RedundancyService,
78 private i18n: I18n
79 ) { }
80
81 get user () {
82 return this.authService.getUser()
83 }
84
85 ngOnChanges () {
86 if (this.loaded) {
87 this.loaded = false
88 this.playlistAdd.reload()
89 }
90
91 this.buildActions()
92 }
93
94 isUserLoggedIn () {
95 return this.authService.isLoggedIn()
96 }
97
98 loadDropdownInformation () {
99 if (!this.isUserLoggedIn() || this.loaded === true) return
100
101 this.loaded = true
102
103 if (this.displayOptions.playlist) this.playlistAdd.load()
104 }
105
106 /* Show modals */
107
108 showDownloadModal () {
109 this.modalOpened.emit()
110
111 this.videoDownloadModal.show(this.video as VideoDetails, this.videoCaptions)
112 }
113
114 showReportModal () {
115 this.modalOpened.emit()
116
117 this.videoReportModal.show()
118 }
119
120 showBlockModal () {
121 this.modalOpened.emit()
122
123 this.videoBlockModal.show()
124 }
125
126 /* Actions checker */
127
128 isVideoUpdatable () {
129 return this.video.isUpdatableBy(this.user)
130 }
131
132 isVideoRemovable () {
133 return this.video.isRemovableBy(this.user)
134 }
135
136 isVideoBlockable () {
137 return this.video.isBlockableBy(this.user)
138 }
139
140 isVideoUnblockable () {
141 return this.video.isUnblockableBy(this.user)
142 }
143
144 isVideoDownloadable () {
145 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
146 }
147
148 canVideoBeDuplicated () {
149 return this.video.canBeDuplicatedBy(this.user)
150 }
151
152 /* Action handlers */
153
154 async unblockVideo () {
155 const confirmMessage = this.i18n(
156 'Do you really want to unblock this video? It will be available again in the videos list.'
157 )
158
159 const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblock'))
160 if (res === false) return
161
162 this.videoBlocklistService.unblockVideo(this.video.id).subscribe(
163 () => {
164 this.notifier.success(this.i18n('Video {{name}} unblocked.', { name: this.video.name }))
165
166 this.video.blacklisted = false
167 this.video.blockedReason = null
168
169 this.videoUnblocked.emit()
170 },
171
172 err => this.notifier.error(err.message)
173 )
174 }
175
176 async removeVideo () {
177 this.modalOpened.emit()
178
179 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
180 if (res === false) return
181
182 this.videoService.removeVideo(this.video.id)
183 .subscribe(
184 () => {
185 this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name }))
186
187 this.videoRemoved.emit()
188 },
189
190 error => this.notifier.error(error.message)
191 )
192 }
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
206 onVideoBlocked () {
207 this.videoBlocked.emit()
208 }
209
210 getPlaylistDropdownPlacement () {
211 if (this.screenService.isInSmallView()) {
212 return 'bottom-right'
213 }
214
215 return 'bottom-left bottom-right'
216 }
217
218 private buildActions () {
219 this.videoActions = [
220 [
221 {
222 label: this.i18n('Save to playlist'),
223 handler: () => this.playlistDropdown.toggle(),
224 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.playlist,
225 iconName: 'playlist-add'
226 }
227 ],
228 [
229 {
230 label: this.i18n('Download'),
231 handler: () => this.showDownloadModal(),
232 isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(),
233 iconName: 'download'
234 },
235 {
236 label: this.i18n('Update'),
237 linkBuilder: ({ video }) => [ '/videos/update', video.uuid ],
238 iconName: 'edit',
239 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
240 },
241 {
242 label: this.i18n('Block'),
243 handler: () => this.showBlockModal(),
244 iconName: 'no',
245 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoBlockable()
246 },
247 {
248 label: this.i18n('Unblock'),
249 handler: () => this.unblockVideo(),
250 iconName: 'undo',
251 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblockable()
252 },
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 {
260 label: this.i18n('Delete'),
261 handler: () => this.removeVideo(),
262 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
263 iconName: 'delete'
264 }
265 ],
266 [
267 {
268 label: this.i18n('Report'),
269 handler: () => this.showReportModal(),
270 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.report,
271 iconName: 'alert'
272 }
273 ]
274 ]
275 }
276}
diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts
deleted file mode 100644
index 14347a109..000000000
--- a/client/src/app/shared/video/video-details.model.ts
+++ /dev/null
@@ -1,64 +0,0 @@
1import { VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFile, VideoState } from '../../../../../shared'
2import { Video } from '../../shared/video/video.model'
3import { Account } from '@app/shared/account/account.model'
4import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
5import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model'
6import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type'
7
8export class VideoDetails extends Video implements VideoDetailsServerModel {
9 descriptionPath: string
10 support: string
11 channel: VideoChannel
12 tags: string[]
13 files: VideoFile[]
14 account: Account
15 commentsEnabled: boolean
16 downloadEnabled: boolean
17
18 waitTranscoding: boolean
19 state: VideoConstant<VideoState>
20
21 likesPercent: number
22 dislikesPercent: number
23
24 trackerUrls: string[]
25
26 streamingPlaylists: VideoStreamingPlaylist[]
27
28 constructor (hash: VideoDetailsServerModel, translations = {}) {
29 super(hash, translations)
30
31 this.descriptionPath = hash.descriptionPath
32 this.files = hash.files
33 this.channel = new VideoChannel(hash.channel)
34 this.account = new Account(hash.account)
35 this.tags = hash.tags
36 this.support = hash.support
37 this.commentsEnabled = hash.commentsEnabled
38 this.downloadEnabled = hash.downloadEnabled
39
40 this.trackerUrls = hash.trackerUrls
41 this.streamingPlaylists = hash.streamingPlaylists
42
43 this.buildLikeAndDislikePercents()
44 }
45
46 buildLikeAndDislikePercents () {
47 this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
48 this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
49 }
50
51 getHlsPlaylist () {
52 return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
53 }
54
55 hasHlsPlaylist () {
56 return !!this.getHlsPlaylist()
57 }
58
59 getFiles () {
60 if (this.files.length === 0) return this.getHlsPlaylist().files
61
62 return this.files
63 }
64}
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts
deleted file mode 100644
index 67d8e7711..000000000
--- a/client/src/app/shared/video/video-edit.model.ts
+++ /dev/null
@@ -1,123 +0,0 @@
1import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum'
2import { VideoUpdate } from '../../../../../shared/models/videos'
3import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
4import { Video } from '../../../../../shared/models/videos/video.model'
5
6export class VideoEdit implements VideoUpdate {
7 static readonly SPECIAL_SCHEDULED_PRIVACY = -1
8
9 category: number
10 licence: number
11 language: string
12 description: string
13 name: string
14 tags: string[]
15 nsfw: boolean
16 commentsEnabled: boolean
17 downloadEnabled: boolean
18 waitTranscoding: boolean
19 channelId: number
20 privacy: VideoPrivacy
21 support: string
22 thumbnailfile?: any
23 previewfile?: any
24 thumbnailUrl: string
25 previewUrl: string
26 uuid?: string
27 id?: number
28 scheduleUpdate?: VideoScheduleUpdate
29 originallyPublishedAt?: Date | string
30
31 constructor (
32 video?: Video & {
33 tags: string[],
34 commentsEnabled: boolean,
35 downloadEnabled: boolean,
36 support: string,
37 thumbnailUrl: string,
38 previewUrl: string
39 }) {
40 if (video) {
41 this.id = video.id
42 this.uuid = video.uuid
43 this.category = video.category.id
44 this.licence = video.licence.id
45 this.language = video.language.id
46 this.description = video.description
47 this.name = video.name
48 this.tags = video.tags
49 this.nsfw = video.nsfw
50 this.commentsEnabled = video.commentsEnabled
51 this.downloadEnabled = video.downloadEnabled
52 this.waitTranscoding = video.waitTranscoding
53 this.channelId = video.channel.id
54 this.privacy = video.privacy.id
55 this.support = video.support
56 this.thumbnailUrl = video.thumbnailUrl
57 this.previewUrl = video.previewUrl
58
59 this.scheduleUpdate = video.scheduledUpdate
60 this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null
61 }
62 }
63
64 patch (values: { [ id: string ]: string }) {
65 Object.keys(values).forEach((key) => {
66 this[ key ] = values[ key ]
67 })
68
69 // If schedule publication, the video is private and will be changed to public privacy
70 if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) {
71 const updateAt = new Date(values['schedulePublicationAt'])
72 updateAt.setSeconds(0)
73
74 this.privacy = VideoPrivacy.PRIVATE
75 this.scheduleUpdate = {
76 updateAt: updateAt.toISOString(),
77 privacy: VideoPrivacy.PUBLIC
78 }
79 } else {
80 this.scheduleUpdate = null
81 }
82
83 // Convert originallyPublishedAt to string so that function objectToFormData() works correctly
84 if (this.originallyPublishedAt) {
85 const originallyPublishedAt = new Date(values['originallyPublishedAt'])
86 this.originallyPublishedAt = originallyPublishedAt.toISOString()
87 }
88
89 // Use the same file than the preview for the thumbnail
90 if (this.previewfile) {
91 this.thumbnailfile = this.previewfile
92 }
93 }
94
95 toFormPatch () {
96 const json = {
97 category: this.category,
98 licence: this.licence,
99 language: this.language,
100 description: this.description,
101 support: this.support,
102 name: this.name,
103 tags: this.tags,
104 nsfw: this.nsfw,
105 commentsEnabled: this.commentsEnabled,
106 downloadEnabled: this.downloadEnabled,
107 waitTranscoding: this.waitTranscoding,
108 channelId: this.channelId,
109 privacy: this.privacy,
110 originallyPublishedAt: this.originallyPublishedAt
111 }
112
113 // Special case if we scheduled an update
114 if (this.scheduleUpdate) {
115 Object.assign(json, {
116 privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY,
117 schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString())
118 })
119 }
120
121 return json
122 }
123}
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
deleted file mode 100644
index 82afc866f..000000000
--- a/client/src/app/shared/video/video-miniature.component.html
+++ /dev/null
@@ -1,66 +0,0 @@
1<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
2 <my-video-thumbnail
3 [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
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>
9
10 <div class="video-bottom">
11 <div class="video-miniature-information">
12 <div class="d-inline-flex video-miniature-meta">
13 <a *ngIf="displayOptions.avatar" class="avatar" [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
14 <img [src]="getAvatarUrl()" alt="" />
15 </a>
16
17 <div class="w-100 d-flex flex-column">
18 <a
19 tabindex="-1"
20 class="video-miniature-name"
21 [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
22 >{{ video.name }}</a>
23
24 <span class="video-miniature-created-at-views">
25 <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
26
27 <span class="views">
28 <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container>
29 <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
30 </span>
31 </span>
32
33 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
34 {{ video.byAccount }}
35 </a>
36 <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
37 {{ video.byVideoChannel }}
38 </a>
39
40 <div class="video-info-privacy">
41 <ng-container *ngIf="displayOptions.privacyText">{{ video.privacy.label }}</ng-container>
42 <ng-container *ngIf="displayOptions.privacyText && displayOptions.state && getStateLabel(video)"> - </ng-container>
43 <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
44 </div>
45 </div>
46 </div>
47
48 <div *ngIf="displayOptions.blacklistInfo && video.blacklisted" class="video-info-blocked">
49 <span class="blocked-label" i18n>Blocked</span>
50 <span class="blocked-reason" *ngIf="video.blockedReason">{{ video.blockedReason }}</span>
51 </div>
52
53 <div i18n *ngIf="displayOptions.nsfw && video.nsfw" class="video-info-nsfw">
54 Sensitive
55 </div>
56 </div>
57
58 <div class="video-actions">
59 <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
60 <my-video-actions-dropdown
61 *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
62 (videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()"
63 ></my-video-actions-dropdown>
64 </div>
65 </div>
66</div>
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
deleted file mode 100644
index 38cac5b6e..000000000
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ /dev/null
@@ -1,200 +0,0 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5$more-button-width: 40px;
6$more-margin-right: 15px;
7
8.video-miniature {
9 display: inline-flex;
10 flex-direction: column;
11 padding-bottom: $video-miniature-margin-bottom;
12 vertical-align: top;
13
14 .video-bottom {
15 display: flex;
16
17 .video-miniature-information {
18 width: $video-miniature-width - $more-button-width - $more-margin-right;
19 line-height: normal;
20
21 .avatar {
22 margin: 10px 10px 0 0;
23
24 img {
25 @include avatar(40px);
26 }
27 }
28
29 .video-miniature-name {
30 @include miniature-name;
31 width: calc(100% - #{$more-button-width});
32 }
33
34 .video-miniature-meta {
35 width: calc(100% + #{$more-button-width});
36 overflow: hidden;
37 }
38
39 .video-miniature-created-at-views {
40 display: block;
41 font-size: 13px;
42 }
43
44 .video-miniature-account,
45 .video-miniature-channel {
46 @include disable-default-a-behaviour;
47 @include ellipsis;
48
49 display: block;
50 font-size: 13px;
51 color: pvar(--greyForegroundColor);
52
53 &:hover {
54 color: $grey-foreground-hover-color;
55 }
56 }
57
58 .video-info-privacy,
59 .video-info-blocked .blocked-label,
60 .video-info-nsfw {
61 font-weight: $font-semibold;
62 }
63
64 .video-info-blocked {
65 color: red;
66
67 .blocked-reason::before {
68 content: ' - ';
69 }
70 }
71
72 .video-info-nsfw {
73 color: red;
74 }
75 }
76
77 .video-actions {
78 margin-top: 3px;
79 width: $more-button-width;
80 height: 30px;
81
82 ::ng-deep .dropdown-root:not(.show) {
83 opacity: 0;
84 }
85
86 ::ng-deep .playlist-dropdown.show + my-action-dropdown .dropdown-root {
87 opacity: 1;
88 }
89
90 ::ng-deep .more-icon {
91 opacity: .6;
92
93 &:hover {
94 opacity: 1;
95 }
96 }
97 }
98
99 @media screen and (max-width: $small-view) {
100 .video-miniature-information {
101 margin: 0 10px;
102 }
103
104 .video-actions {
105 margin: 0;
106 top: -3px;
107
108 ::ng-deep .dropdown-root {
109 opacity: 1 !important;
110 }
111 }
112 }
113 }
114
115 &:hover ::ng-deep .video-thumbnail .video-thumbnail-actions-overlay,
116 &:hover .video-bottom .video-actions ::ng-deep .dropdown-root {
117 opacity: 1;
118 }
119
120 &.fit-width {
121 width: 100%;
122
123 .video-bottom {
124 width: 100% !important;
125
126 .video-miniature-information {
127 width: calc(100% - #{$more-button-width}) !important;
128 }
129 }
130
131 my-video-thumbnail {
132 @include large-screen-ratio($selector: '::ng-deep .video-thumbnail');
133 }
134 }
135
136 &.display-as-row {
137 flex-direction: row;
138 padding-bottom: 0;
139 height: auto;
140 display: flex;
141 flex-grow: 1;
142
143 my-video-thumbnail {
144 margin-right: 10px;
145 }
146
147 .video-bottom {
148 .video-miniature-information {
149 @media screen and (min-width: $small-view) {
150 width: auto;
151 min-width: 500px;
152 }
153
154 .video-miniature-name {
155 @include ellipsis-multiline(1.3em, 2);
156
157 margin-top: 2px;
158 margin-bottom: 5px;
159 }
160
161 .video-miniature-created-at-views,
162 .video-miniature-account,
163 .video-miniature-channel {
164 font-size: 95%;
165 width: fit-content;
166 }
167
168 .video-miniature-created-at-views + .video-miniature-channel {
169 margin-top: 5px;
170 }
171
172 .video-info-privacy {
173 margin-top: 5px;
174 }
175
176 .video-info-blocked {
177 margin-top: 3px;
178 }
179 }
180
181 .video-actions {
182 margin: 0;
183 top: -3px;
184 }
185 }
186
187 @media screen and (max-width: $small-view) {
188 flex-direction: column;
189 height: auto;
190
191 my-video-thumbnail {
192 margin-right: 0;
193 }
194
195 .video-miniature-information {
196 min-width: initial;
197 }
198 }
199 }
200}
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
deleted file mode 100644
index a08c3fc8d..000000000
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ /dev/null
@@ -1,285 +0,0 @@
1import { switchMap } from 'rxjs/operators'
2import {
3 ChangeDetectionStrategy,
4 ChangeDetectorRef,
5 Component,
6 EventEmitter,
7 Inject,
8 Input,
9 LOCALE_ID,
10 OnInit,
11 Output
12} from '@angular/core'
13import { AuthService, ServerService } from '@app/core'
14import { ScreenService } from '@app/shared/misc/screen.service'
15import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
16import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
17import { I18n } from '@ngx-translate/i18n-polyfill'
18import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
19import { User } from '../users'
20import { Video } from './video.model'
21
22export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
23export type MiniatureDisplayOptions = {
24 date?: boolean
25 views?: boolean
26 by?: boolean
27 avatar?: boolean
28 privacyLabel?: boolean
29 privacyText?: boolean
30 state?: boolean
31 blacklistInfo?: boolean
32 nsfw?: boolean
33}
34
35@Component({
36 selector: 'my-video-miniature',
37 styleUrls: [ './video-miniature.component.scss' ],
38 templateUrl: './video-miniature.component.html',
39 changeDetection: ChangeDetectionStrategy.OnPush
40})
41export class VideoMiniatureComponent implements OnInit {
42 @Input() user: User
43 @Input() video: Video
44
45 @Input() ownerDisplayType: OwnerDisplayType = 'account'
46 @Input() displayOptions: MiniatureDisplayOptions = {
47 date: true,
48 views: true,
49 by: true,
50 avatar: false,
51 privacyLabel: false,
52 privacyText: false,
53 state: false,
54 blacklistInfo: false
55 }
56 @Input() displayAsRow = false
57 @Input() displayVideoActions = true
58 @Input() fitWidth = false
59
60 @Input() useLazyLoadUrl = false
61
62 @Output() videoBlocked = new EventEmitter()
63 @Output() videoUnblocked = new EventEmitter()
64 @Output() videoRemoved = new EventEmitter()
65
66 videoActionsDisplayOptions: VideoActionsDisplayType = {
67 playlist: true,
68 download: false,
69 update: true,
70 blacklist: true,
71 delete: true,
72 report: true,
73 duplicate: true
74 }
75 showActions = false
76 serverConfig: ServerConfig
77
78 addToWatchLaterText: string
79 addedToWatchLaterText: string
80 inWatchLaterPlaylist: boolean
81 channelLinkTitle = ''
82
83 watchLaterPlaylist: {
84 id: number
85 playlistElementId?: number
86 }
87
88 videoLink: any[] = []
89
90 private ownerDisplayTypeChosen: 'account' | 'videoChannel'
91
92 constructor (
93 private screenService: ScreenService,
94 private serverService: ServerService,
95 private i18n: I18n,
96 private authService: AuthService,
97 private videoPlaylistService: VideoPlaylistService,
98 private cd: ChangeDetectorRef,
99 @Inject(LOCALE_ID) private localeId: string
100 ) {}
101
102 get isVideoBlur () {
103 return this.video.isVideoNSFWForUser(this.user, this.serverConfig)
104 }
105
106 ngOnInit () {
107 this.serverConfig = this.serverService.getTmpConfig()
108 this.serverService.getConfig()
109 .subscribe(config => {
110 this.serverConfig = config
111 this.buildVideoLink()
112 })
113
114 this.setUpBy()
115
116 this.channelLinkTitle = this.i18n(
117 '{{name}} (channel page)',
118 { name: this.video.channel.name, handle: this.video.byVideoChannel }
119 )
120
121 // We rely on mouseenter to lazy load actions
122 if (this.screenService.isInTouchScreen()) {
123 this.loadActions()
124 }
125 }
126
127 buildVideoLink () {
128 if (this.useLazyLoadUrl && this.video.url) {
129 const remoteUriConfig = this.serverConfig.search.remoteUri
130
131 // Redirect on the external instance if not allowed to fetch remote data
132 const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
133 const fromPath = window.location.pathname + window.location.search
134
135 this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
136 return
137 }
138
139 this.videoLink = [ '/videos/watch', this.video.uuid ]
140 }
141
142 displayOwnerAccount () {
143 return this.ownerDisplayTypeChosen === 'account'
144 }
145
146 displayOwnerVideoChannel () {
147 return this.ownerDisplayTypeChosen === 'videoChannel'
148 }
149
150 isUnlistedVideo () {
151 return this.video.privacy.id === VideoPrivacy.UNLISTED
152 }
153
154 isPrivateVideo () {
155 return this.video.privacy.id === VideoPrivacy.PRIVATE
156 }
157
158 getStateLabel (video: Video) {
159 if (!video.state) return ''
160
161 if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
162 return this.i18n('Published')
163 }
164
165 if (video.scheduledUpdate) {
166 const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
167 return this.i18n('Publication scheduled on ') + updateAt
168 }
169
170 if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
171 return this.i18n('Waiting transcoding')
172 }
173
174 if (video.state.id === VideoState.TO_TRANSCODE) {
175 return this.i18n('To transcode')
176 }
177
178 if (video.state.id === VideoState.TO_IMPORT) {
179 return this.i18n('To import')
180 }
181
182 return ''
183 }
184
185 getAvatarUrl () {
186 if (this.ownerDisplayTypeChosen === 'account') {
187 return this.video.accountAvatarUrl
188 }
189
190 return this.video.videoChannelAvatarUrl
191 }
192
193 loadActions () {
194 if (this.displayVideoActions) this.showActions = true
195
196 this.loadWatchLater()
197 }
198
199 onVideoBlocked () {
200 this.videoBlocked.emit()
201 }
202
203 onVideoUnblocked () {
204 this.videoUnblocked.emit()
205 }
206
207 onVideoRemoved () {
208 this.videoRemoved.emit()
209 }
210
211 isUserLoggedIn () {
212 return this.authService.isLoggedIn()
213 }
214
215 onWatchLaterClick (currentState: boolean) {
216 if (currentState === true) this.removeFromWatchLater()
217 else this.addToWatchLater()
218
219 this.inWatchLaterPlaylist = !currentState
220 }
221
222 addToWatchLater () {
223 const body = { videoId: this.video.id }
224
225 this.videoPlaylistService.addVideoInPlaylist(this.watchLaterPlaylist.id, body).subscribe(
226 res => {
227 this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id
228 }
229 )
230 }
231
232 removeFromWatchLater () {
233 this.videoPlaylistService.removeVideoFromPlaylist(this.watchLaterPlaylist.id, this.watchLaterPlaylist.playlistElementId, this.video.id)
234 .subscribe(
235 _ => { /* empty */ }
236 )
237 }
238
239 isWatchLaterPlaylistDisplayed () {
240 return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
241 }
242
243 private setUpBy () {
244 if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') {
245 this.ownerDisplayTypeChosen = this.ownerDisplayType
246 return
247 }
248
249 // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12)
250 // -> Use the account name
251 if (
252 this.video.channel.name === `${this.video.account.name}_channel` ||
253 this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
254 ) {
255 this.ownerDisplayTypeChosen = 'account'
256 } else {
257 this.ownerDisplayTypeChosen = 'videoChannel'
258 }
259 }
260
261 private loadWatchLater () {
262 if (!this.isUserLoggedIn() || this.inWatchLaterPlaylist !== undefined) return
263
264 this.authService.userInformationLoaded
265 .pipe(switchMap(() => this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)))
266 .subscribe(existResult => {
267 const watchLaterPlaylist = this.authService.getUser().specialPlaylists.find(p => p.type === VideoPlaylistType.WATCH_LATER)
268 const existsInWatchLater = existResult.find(r => r.playlistId === watchLaterPlaylist.id)
269 this.inWatchLaterPlaylist = false
270
271 this.watchLaterPlaylist = {
272 id: watchLaterPlaylist.id
273 }
274
275 if (existsInWatchLater) {
276 this.inWatchLaterPlaylist = true
277 this.watchLaterPlaylist.playlistElementId = existsInWatchLater.playlistElementId
278 }
279
280 this.cd.markForCheck()
281 })
282
283 this.videoPlaylistService.runPlaylistCheck(this.video.id)
284 }
285}
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
deleted file mode 100644
index fe5510c56..000000000
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ /dev/null
@@ -1,33 +0,0 @@
1<a
2 [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
3 class="video-thumbnail"
4>
5 <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
6
7 <div *ngIf="displayWatchLaterPlaylist" class="video-thumbnail-actions-overlay">
8 <ng-container *ngIf="inWatchLaterPlaylist !== true">
9 <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
10 <my-global-icon iconName="clock" [attr.aria-label]="addToWatchLaterText" role="button"></my-global-icon>
11 </div>
12 </ng-container>
13
14 <ng-container *ngIf="inWatchLaterPlaylist === true">
15 <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addedToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
16 <my-global-icon iconName="tick" [attr.aria-label]="addedToWatchLaterText" role="button"></my-global-icon>
17 </div>
18 </ng-container>
19 </div>
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
24 <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
25
26 <div class="play-overlay">
27 <div class="icon"></div>
28 </div>
29
30 <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
31 <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
32 </div>
33</a>
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
deleted file mode 100644
index feff78a87..000000000
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ /dev/null
@@ -1,74 +0,0 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5.video-thumbnail {
6 @include miniature-thumbnail;
7
8 .progress-bar {
9 height: 3px;
10 width: 100%;
11 position: absolute;
12 bottom: 0;
13 background-color: rgba(0, 0, 0, 0.20);
14
15 div {
16 height: 100%;
17 background-color: pvar(--mainColor);
18 }
19 }
20
21 .video-thumbnail-watch-later-overlay,
22 .video-thumbnail-label-overlay,
23 .video-thumbnail-duration-overlay {
24 @include static-thumbnail-overlay;
25
26 border-radius: 3px;
27 font-size: 12px;
28 font-weight: $font-semibold;
29 line-height: 1.2;
30 z-index: z(miniature);
31 }
32
33 .video-thumbnail-label-overlay {
34 position: absolute;
35 padding: 0 5px;
36 left: 5px;
37 top: 5px;
38 font-weight: $font-bold;
39
40 &.warning { background-color: orange; }
41 &.danger { background-color: red; }
42 }
43
44 .video-thumbnail-duration-overlay {
45 position: absolute;
46 padding: 0 3px;
47 right: 5px;
48 bottom: 5px;
49 }
50
51 .video-thumbnail-actions-overlay {
52 position: absolute;
53 display: flex;
54 flex-direction: column;
55 right: 5px;
56 top: 5px;
57 opacity: 0;
58
59 div:not(:first-child) {
60 margin-top: 2px;
61 }
62
63 .video-thumbnail-watch-later-overlay {
64 padding: 3px;
65
66 my-global-icon {
67 width: 22px;
68 height: 22px;
69
70 @include apply-svg-color(#fff);
71 }
72 }
73 }
74}
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts
deleted file mode 100644
index 111b4c8bb..000000000
--- a/client/src/app/shared/video/video-thumbnail.component.ts
+++ /dev/null
@@ -1,63 +0,0 @@
1import { Component, EventEmitter, Input, Output } from '@angular/core'
2import { Video } from './video.model'
3import { ScreenService } from '@app/shared/misc/screen.service'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5
6@Component({
7 selector: 'my-video-thumbnail',
8 styleUrls: [ './video-thumbnail.component.scss' ],
9 templateUrl: './video-thumbnail.component.html'
10})
11export class VideoThumbnailComponent {
12 @Input() video: Video
13 @Input() nsfw = false
14 @Input() routerLink: any[]
15 @Input() queryParams: { [ p: string ]: any }
16
17 @Input() displayWatchLaterPlaylist: boolean
18 @Input() inWatchLaterPlaylist: boolean
19
20 @Output() watchLaterClick = new EventEmitter<boolean>()
21
22 addToWatchLaterText: string
23 addedToWatchLaterText: string
24
25 constructor (
26 private screenService: ScreenService,
27 private i18n: I18n
28 ) {
29 this.addToWatchLaterText = this.i18n('Add to watch later')
30 this.addedToWatchLaterText = this.i18n('Remove from watch later')
31 }
32
33 getImageUrl () {
34 if (!this.video) return ''
35
36 if (this.screenService.isInMobileView()) {
37 return this.video.previewUrl
38 }
39
40 return this.video.thumbnailUrl
41 }
42
43 getProgressPercent () {
44 if (!this.video.userHistory) return 0
45
46 const currentTime = this.video.userHistory.currentTime
47
48 return (currentTime / this.video.duration) * 100
49 }
50
51 getVideoRouterLink () {
52 if (this.routerLink) return this.routerLink
53
54 return [ '/videos/watch', this.video.uuid ]
55 }
56
57 onWatchLaterClick (event: Event) {
58 this.watchLaterClick.emit(this.inWatchLaterPlaylist)
59
60 event.stopPropagation()
61 return false
62 }
63}
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
deleted file mode 100644
index dc5f45626..000000000
--- a/client/src/app/shared/video/video.model.ts
+++ /dev/null
@@ -1,182 +0,0 @@
1import { User } from '../'
2import { UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
3import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
4import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
5import { durationToString, getAbsoluteAPIUrl } from '../misc/utils'
6import { peertubeTranslate, ServerConfig } from '../../../../../shared/models'
7import { Actor } from '@app/shared/actor/actor.model'
8import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
9import { AuthUser } from '@app/core'
10import { environment } from '../../../environments/environment'
11
12export class Video implements VideoServerModel {
13 byVideoChannel: string
14 byAccount: string
15
16 accountAvatarUrl: string
17 videoChannelAvatarUrl: string
18
19 createdAt: Date
20 updatedAt: Date
21 publishedAt: Date
22 originallyPublishedAt: Date | string
23 category: VideoConstant<number>
24 licence: VideoConstant<number>
25 language: VideoConstant<string>
26 privacy: VideoConstant<VideoPrivacy>
27 description: string
28 duration: number
29 durationLabel: string
30 id: number
31 uuid: string
32 isLocal: boolean
33 name: string
34 serverHost: string
35 thumbnailPath: string
36 thumbnailUrl: string
37
38 previewPath: string
39 previewUrl: string
40
41 embedPath: string
42 embedUrl: string
43
44 url?: string
45
46 views: number
47 likes: number
48 dislikes: number
49 nsfw: boolean
50
51 originInstanceUrl: string
52 originInstanceHost: string
53
54 waitTranscoding?: boolean
55 state?: VideoConstant<VideoState>
56 scheduledUpdate?: VideoScheduleUpdate
57 blacklisted?: boolean
58 blockedReason?: string
59
60 account: {
61 id: number
62 name: string
63 displayName: string
64 url: string
65 host: string
66 avatar?: Avatar
67 }
68
69 channel: {
70 id: number
71 name: string
72 displayName: string
73 url: string
74 host: string
75 avatar?: Avatar
76 }
77
78 userHistory?: {
79 currentTime: number
80 }
81
82 static buildClientUrl (videoUUID: string) {
83 return '/videos/watch/' + videoUUID
84 }
85
86 constructor (hash: VideoServerModel, translations = {}) {
87 const absoluteAPIUrl = getAbsoluteAPIUrl()
88
89 this.createdAt = new Date(hash.createdAt.toString())
90 this.publishedAt = new Date(hash.publishedAt.toString())
91 this.category = hash.category
92 this.licence = hash.licence
93 this.language = hash.language
94 this.privacy = hash.privacy
95 this.waitTranscoding = hash.waitTranscoding
96 this.state = hash.state
97 this.description = hash.description
98
99 this.duration = hash.duration
100 this.durationLabel = durationToString(hash.duration)
101
102 this.id = hash.id
103 this.uuid = hash.uuid
104
105 this.isLocal = hash.isLocal
106 this.name = hash.name
107
108 this.thumbnailPath = hash.thumbnailPath
109 this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
110
111 this.previewPath = hash.previewPath
112 this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
113
114 this.embedPath = hash.embedPath
115 this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath)
116
117 this.url = hash.url
118
119 this.views = hash.views
120 this.likes = hash.likes
121 this.dislikes = hash.dislikes
122
123 this.nsfw = hash.nsfw
124
125 this.account = hash.account
126 this.channel = hash.channel
127
128 this.byAccount = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host)
129 this.byVideoChannel = Actor.CREATE_BY_STRING(hash.channel.name, hash.channel.host)
130 this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
131 this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel)
132
133 this.category.label = peertubeTranslate(this.category.label, translations)
134 this.licence.label = peertubeTranslate(this.licence.label, translations)
135 this.language.label = peertubeTranslate(this.language.label, translations)
136 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
137
138 this.scheduledUpdate = hash.scheduledUpdate
139 this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
140
141 if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
142
143 this.blacklisted = hash.blacklisted
144 this.blockedReason = hash.blacklistedReason
145
146 this.userHistory = hash.userHistory
147
148 this.originInstanceHost = this.account.host
149 this.originInstanceUrl = 'https://' + this.originInstanceHost
150 }
151
152 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
153 // Video is not NSFW, skip
154 if (this.nsfw === false) return false
155
156 // Return user setting if logged in
157 if (user) return user.nsfwPolicy !== 'display'
158
159 // Return default instance config
160 return serverConfig.instance.defaultNSFWPolicy !== 'display'
161 }
162
163 isRemovableBy (user: AuthUser) {
164 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
165 }
166
167 isBlockableBy (user: AuthUser) {
168 return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
169 }
170
171 isUnblockableBy (user: AuthUser) {
172 return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
173 }
174
175 isUpdatableBy (user: AuthUser) {
176 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
177 }
178
179 canBeDuplicatedBy (user: AuthUser) {
180 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
181 }
182}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
deleted file mode 100644
index d66a1f809..000000000
--- a/client/src/app/shared/video/video.service.ts
+++ /dev/null
@@ -1,409 +0,0 @@
1import { catchError, map, switchMap } from 'rxjs/operators'
2import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs'
5import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } from '../../../../../shared'
6import { ResultList } from '../../../../../shared/models/result-list.model'
7import {
8 UserVideoRate,
9 UserVideoRateType,
10 UserVideoRateUpdate,
11 VideoConstant,
12 VideoFilter,
13 VideoPrivacy,
14 VideoUpdate
15} from '../../../../../shared/models/videos'
16import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
17import { environment } from '../../../environments/environment'
18import { ComponentPaginationLight } from '../rest/component-pagination.model'
19import { RestExtractor } from '../rest/rest-extractor.service'
20import { RestService } from '../rest/rest.service'
21import { UserService } from '../users/user.service'
22import { VideoSortField } from './sort-field.type'
23import { VideoDetails } from './video-details.model'
24import { VideoEdit } from './video-edit.model'
25import { Video } from './video.model'
26import { objectToFormData } from '@app/shared/misc/utils'
27import { Account } from '@app/shared/account/account.model'
28import { AccountService } from '@app/shared/account/account.service'
29import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
30import { ServerService, AuthService } from '@app/core'
31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
35import { FfprobeData } from 'fluent-ffmpeg'
36
37export interface VideosProvider {
38 getVideos (parameters: {
39 videoPagination: ComponentPaginationLight,
40 sort: VideoSortField,
41 filter?: VideoFilter,
42 categoryOneOf?: number[],
43 languageOneOf?: string[]
44 nsfwPolicy: NSFWPolicyType
45 }): Observable<ResultList<Video>>
46}
47
48@Injectable()
49export class VideoService implements VideosProvider {
50 static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
51 static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
52
53 constructor (
54 private authHttp: HttpClient,
55 private authService: AuthService,
56 private userService: UserService,
57 private restExtractor: RestExtractor,
58 private restService: RestService,
59 private serverService: ServerService,
60 private i18n: I18n
61 ) {}
62
63 getVideoViewUrl (uuid: string) {
64 return VideoService.BASE_VIDEO_URL + uuid + '/views'
65 }
66
67 getUserWatchingVideoUrl (uuid: string) {
68 return VideoService.BASE_VIDEO_URL + uuid + '/watching'
69 }
70
71 getVideo (options: { videoId: string }): Observable<VideoDetails> {
72 return this.serverService.getServerLocale()
73 .pipe(
74 switchMap(translations => {
75 return this.authHttp.get<VideoDetailsServerModel>(VideoService.BASE_VIDEO_URL + options.videoId)
76 .pipe(map(videoHash => ({ videoHash, translations })))
77 }),
78 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
79 catchError(err => this.restExtractor.handleError(err))
80 )
81 }
82
83 updateVideo (video: VideoEdit) {
84 const language = video.language || null
85 const licence = video.licence || null
86 const category = video.category || null
87 const description = video.description || null
88 const support = video.support || null
89 const scheduleUpdate = video.scheduleUpdate || null
90 const originallyPublishedAt = video.originallyPublishedAt || null
91
92 const body: VideoUpdate = {
93 name: video.name,
94 category,
95 licence,
96 language,
97 support,
98 description,
99 channelId: video.channelId,
100 privacy: video.privacy,
101 tags: video.tags,
102 nsfw: video.nsfw,
103 waitTranscoding: video.waitTranscoding,
104 commentsEnabled: video.commentsEnabled,
105 downloadEnabled: video.downloadEnabled,
106 thumbnailfile: video.thumbnailfile,
107 previewfile: video.previewfile,
108 scheduleUpdate,
109 originallyPublishedAt
110 }
111
112 const data = objectToFormData(body)
113
114 return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, data)
115 .pipe(
116 map(this.restExtractor.extractDataBool),
117 catchError(err => this.restExtractor.handleError(err))
118 )
119 }
120
121 uploadVideo (video: FormData) {
122 const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true })
123
124 return this.authHttp
125 .request<{ video: { id: number, uuid: string } }>(req)
126 .pipe(catchError(err => this.restExtractor.handleError(err)))
127 }
128
129 getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> {
130 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
131
132 let params = new HttpParams()
133 params = this.restService.addRestGetParams(params, pagination, sort)
134 params = this.restService.addObjectParams(params, { search })
135
136 return this.authHttp
137 .get<ResultList<Video>>(UserService.BASE_USERS_URL + 'me/videos', { params })
138 .pipe(
139 switchMap(res => this.extractVideos(res)),
140 catchError(err => this.restExtractor.handleError(err))
141 )
142 }
143
144 getAccountVideos (
145 account: Account,
146 videoPagination: ComponentPaginationLight,
147 sort: VideoSortField
148 ): Observable<ResultList<Video>> {
149 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
150
151 let params = new HttpParams()
152 params = this.restService.addRestGetParams(params, pagination, sort)
153
154 return this.authHttp
155 .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
156 .pipe(
157 switchMap(res => this.extractVideos(res)),
158 catchError(err => this.restExtractor.handleError(err))
159 )
160 }
161
162 getVideoChannelVideos (
163 videoChannel: VideoChannel,
164 videoPagination: ComponentPaginationLight,
165 sort: VideoSortField,
166 nsfwPolicy?: NSFWPolicyType
167 ): Observable<ResultList<Video>> {
168 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
169
170 let params = new HttpParams()
171 params = this.restService.addRestGetParams(params, pagination, sort)
172
173 if (nsfwPolicy) {
174 params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
175 }
176
177 return this.authHttp
178 .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
179 .pipe(
180 switchMap(res => this.extractVideos(res)),
181 catchError(err => this.restExtractor.handleError(err))
182 )
183 }
184
185 getUserSubscriptionVideos (parameters: {
186 videoPagination: ComponentPaginationLight,
187 sort: VideoSortField,
188 skipCount?: boolean
189 }): Observable<ResultList<Video>> {
190 const { videoPagination, sort, skipCount } = parameters
191 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
192
193 let params = new HttpParams()
194 params = this.restService.addRestGetParams(params, pagination, sort)
195
196 if (skipCount) params = params.set('skipCount', skipCount + '')
197
198 return this.authHttp
199 .get<ResultList<Video>>(UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/videos', { params })
200 .pipe(
201 switchMap(res => this.extractVideos(res)),
202 catchError(err => this.restExtractor.handleError(err))
203 )
204 }
205
206 getVideos (parameters: {
207 videoPagination: ComponentPaginationLight,
208 sort: VideoSortField,
209 filter?: VideoFilter,
210 categoryOneOf?: number[],
211 languageOneOf?: string[],
212 skipCount?: boolean,
213 nsfwPolicy?: NSFWPolicyType
214 }): Observable<ResultList<Video>> {
215 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy } = parameters
216
217 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
218
219 let params = new HttpParams()
220 params = this.restService.addRestGetParams(params, pagination, sort)
221
222 if (filter) params = params.set('filter', filter)
223 if (skipCount) params = params.set('skipCount', skipCount + '')
224
225 if (nsfwPolicy) {
226 params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
227 }
228
229 if (languageOneOf) {
230 for (const l of languageOneOf) {
231 params = params.append('languageOneOf[]', l)
232 }
233 }
234
235 if (categoryOneOf) {
236 for (const c of categoryOneOf) {
237 params = params.append('categoryOneOf[]', c + '')
238 }
239 }
240
241 return this.authHttp
242 .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
243 .pipe(
244 switchMap(res => this.extractVideos(res)),
245 catchError(err => this.restExtractor.handleError(err))
246 )
247 }
248
249 buildBaseFeedUrls (params: HttpParams) {
250 const feeds = [
251 {
252 format: FeedFormat.RSS,
253 label: 'rss 2.0',
254 url: VideoService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase()
255 },
256 {
257 format: FeedFormat.ATOM,
258 label: 'atom 1.0',
259 url: VideoService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase()
260 },
261 {
262 format: FeedFormat.JSON,
263 label: 'json 1.0',
264 url: VideoService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase()
265 }
266 ]
267
268 if (params && params.keys().length !== 0) {
269 for (const feed of feeds) {
270 feed.url += '?' + params.toString()
271 }
272 }
273
274 return feeds
275 }
276
277 getVideoFeedUrls (sort: VideoSortField, filter?: VideoFilter, categoryOneOf?: number[]) {
278 let params = this.restService.addRestGetParams(new HttpParams(), undefined, sort)
279
280 if (filter) params = params.set('filter', filter)
281
282 if (categoryOneOf) {
283 for (const c of categoryOneOf) {
284 params = params.append('categoryOneOf[]', c + '')
285 }
286 }
287
288 return this.buildBaseFeedUrls(params)
289 }
290
291 getAccountFeedUrls (accountId: number) {
292 let params = this.restService.addRestGetParams(new HttpParams())
293 params = params.set('accountId', accountId.toString())
294
295 return this.buildBaseFeedUrls(params)
296 }
297
298 getVideoChannelFeedUrls (videoChannelId: number) {
299 let params = this.restService.addRestGetParams(new HttpParams())
300 params = params.set('videoChannelId', videoChannelId.toString())
301
302 return this.buildBaseFeedUrls(params)
303 }
304
305 getVideoFileMetadata (metadataUrl: string) {
306 return this.authHttp
307 .get<FfprobeData>(metadataUrl)
308 .pipe(
309 catchError(err => this.restExtractor.handleError(err))
310 )
311 }
312
313 removeVideo (id: number) {
314 return this.authHttp
315 .delete(VideoService.BASE_VIDEO_URL + id)
316 .pipe(
317 map(this.restExtractor.extractDataBool),
318 catchError(err => this.restExtractor.handleError(err))
319 )
320 }
321
322 loadCompleteDescription (descriptionPath: string) {
323 return this.authHttp
324 .get<{ description: string }>(environment.apiUrl + descriptionPath)
325 .pipe(
326 map(res => res.description),
327 catchError(err => this.restExtractor.handleError(err))
328 )
329 }
330
331 setVideoLike (id: number) {
332 return this.setVideoRate(id, 'like')
333 }
334
335 setVideoDislike (id: number) {
336 return this.setVideoRate(id, 'dislike')
337 }
338
339 unsetVideoLike (id: number) {
340 return this.setVideoRate(id, 'none')
341 }
342
343 getUserVideoRating (id: number) {
344 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
345
346 return this.authHttp.get<UserVideoRate>(url)
347 .pipe(catchError(err => this.restExtractor.handleError(err)))
348 }
349
350 extractVideos (result: ResultList<VideoServerModel>) {
351 return this.serverService.getServerLocale()
352 .pipe(
353 map(translations => {
354 const videosJson = result.data
355 const totalVideos = result.total
356 const videos: Video[] = []
357
358 for (const videoJson of videosJson) {
359 videos.push(new Video(videoJson, translations))
360 }
361
362 return { total: totalVideos, data: videos }
363 })
364 )
365 }
366
367 explainedPrivacyLabels (privacies: VideoConstant<VideoPrivacy>[]) {
368 const base = [
369 {
370 id: VideoPrivacy.PRIVATE,
371 label: this.i18n('Only I can see this video')
372 },
373 {
374 id: VideoPrivacy.UNLISTED,
375 label: this.i18n('Only people with the private link can see this video')
376 },
377 {
378 id: VideoPrivacy.PUBLIC,
379 label: this.i18n('Anyone can see this video')
380 },
381 {
382 id: VideoPrivacy.INTERNAL,
383 label: this.i18n('Only users of this instance can see this video')
384 }
385 ]
386
387 return base.filter(o => !!privacies.find(p => p.id === o.id))
388 }
389
390 nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) {
391 return nsfwPolicy === 'do_not_list'
392 ? 'false'
393 : 'both'
394 }
395
396 private setVideoRate (id: number, rateType: UserVideoRateType) {
397 const url = VideoService.BASE_VIDEO_URL + id + '/rate'
398 const body: UserVideoRateUpdate = {
399 rating: rateType
400 }
401
402 return this.authHttp
403 .put(url, body)
404 .pipe(
405 map(this.restExtractor.extractDataBool),
406 catchError(err => this.restExtractor.handleError(err))
407 )
408 }
409}
diff --git a/client/src/app/shared/video/videos-selection.component.html b/client/src/app/shared/video/videos-selection.component.html
deleted file mode 100644
index 44aa567b9..000000000
--- a/client/src/app/shared/video/videos-selection.component.html
+++ /dev/null
@@ -1,30 +0,0 @@
1<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
2
3<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos">
4 <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
5
6 <div class="checkbox-container">
7 <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox>
8 </div>
9
10 <my-video-miniature
11 [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"
12 [displayVideoActions]="false" [ownerDisplayType]="ownerDisplayType"
13 ></my-video-miniature>
14
15 <!-- Display only once -->
16 <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
17 <div class="action-selection-mode-child">
18 <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
19 Cancel
20 </span>
21
22 <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container>
23 </div>
24 </div>
25
26 <ng-container *ngIf="isInSelectionMode() === false">
27 <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container>
28 </ng-container>
29 </div>
30</div>
diff --git a/client/src/app/shared/video/videos-selection.component.scss b/client/src/app/shared/video/videos-selection.component.scss
deleted file mode 100644
index d3cbabf23..000000000
--- a/client/src/app/shared/video/videos-selection.component.scss
+++ /dev/null
@@ -1,57 +0,0 @@
1@import '_variables';
2@import '_mixins';
3
4.action-selection-mode {
5 display: flex;
6 justify-content: flex-end;
7 flex-grow: 1;
8
9 .action-selection-mode-child {
10 position: fixed;
11
12 .action-button {
13 display: inline-block;
14 }
15
16 .action-button-cancel-selection {
17 @include peertube-button;
18 @include grey-button;
19
20 margin-right: 10px;
21 }
22 }
23}
24
25.video {
26 @include row-blocks;
27
28 &:first-child {
29 margin-top: 47px;
30 }
31
32 .checkbox-container {
33 display: flex;
34 align-items: center;
35 margin-right: 20px;
36 margin-left: 12px;
37 }
38
39 my-video-miniature {
40 flex-grow: 1;
41 }
42}
43
44@media screen and (max-width: $small-view) {
45 .video {
46 flex-direction: column;
47 height: auto;
48
49 .checkbox-container {
50 display: none;
51 }
52
53 my-button {
54 margin-top: 10px;
55 }
56 }
57}
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts
deleted file mode 100644
index 9453664dd..000000000
--- a/client/src/app/shared/video/videos-selection.component.ts
+++ /dev/null
@@ -1,124 +0,0 @@
1import {
2 AfterContentInit,
3 Component,
4 ContentChildren,
5 EventEmitter,
6 Input,
7 OnDestroy,
8 OnInit,
9 Output,
10 QueryList,
11 TemplateRef
12} from '@angular/core'
13import { ActivatedRoute, Router } from '@angular/router'
14import { AbstractVideoList } from '@app/shared/video/abstract-video-list'
15import { AuthService, Notifier, ServerService } from '@app/core'
16import { ScreenService } from '@app/shared/misc/screen.service'
17import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
18import { Observable } from 'rxjs'
19import { Video } from '@app/shared/video/video.model'
20import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
21import { VideoSortField } from '@app/shared/video/sort-field.type'
22import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
23import { I18n } from '@ngx-translate/i18n-polyfill'
24import { ResultList } from '@shared/models'
25import { UserService } from '../users'
26import { LocalStorageService } from '../misc/storage.service'
27
28export type SelectionType = { [ id: number ]: boolean }
29
30@Component({
31 selector: 'my-videos-selection',
32 templateUrl: './videos-selection.component.html',
33 styleUrls: [ './videos-selection.component.scss' ]
34})
35export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
36 @Input() pagination: ComponentPagination
37 @Input() titlePage: string
38 @Input() miniatureDisplayOptions: MiniatureDisplayOptions
39 @Input() ownerDisplayType: OwnerDisplayType
40
41 @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
42
43 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'rowButtons' | 'globalButtons'>>
44
45 @Output() selectionChange = new EventEmitter<SelectionType>()
46 @Output() videosModelChange = new EventEmitter<Video[]>()
47
48 _selection: SelectionType = {}
49
50 rowButtonsTemplate: TemplateRef<any>
51 globalButtonsTemplate: TemplateRef<any>
52
53 constructor (
54 protected i18n: I18n,
55 protected router: Router,
56 protected route: ActivatedRoute,
57 protected notifier: Notifier,
58 protected authService: AuthService,
59 protected userService: UserService,
60 protected screenService: ScreenService,
61 protected storageService: LocalStorageService,
62 protected serverService: ServerService
63 ) {
64 super()
65 }
66
67 @Input() get selection () {
68 return this._selection
69 }
70
71 set selection (selection: SelectionType) {
72 this._selection = selection
73 this.selectionChange.emit(this._selection)
74 }
75
76 @Input() get videosModel () {
77 return this.videos
78 }
79
80 set videosModel (videos: Video[]) {
81 this.videos = videos
82 this.videosModelChange.emit(this.videos)
83 }
84
85 ngOnInit () {
86 super.ngOnInit()
87 }
88
89 ngAfterContentInit () {
90 {
91 const t = this.templates.find(t => t.name === 'rowButtons')
92 if (t) this.rowButtonsTemplate = t.template
93 }
94
95 {
96 const t = this.templates.find(t => t.name === 'globalButtons')
97 if (t) this.globalButtonsTemplate = t.template
98 }
99 }
100
101 ngOnDestroy () {
102 super.ngOnDestroy()
103 }
104
105 getVideosObservable (page: number) {
106 return this.getVideosObservableFunction(page, this.sort)
107 }
108
109 abortSelectionMode () {
110 this._selection = {}
111 }
112
113 isInSelectionMode () {
114 return Object.keys(this._selection).some(k => this._selection[ k ] === true)
115 }
116
117 generateSyndicationList () {
118 throw new Error('Method not implemented.')
119 }
120
121 protected onMoreVideos () {
122 this.videosModel = this.videos
123 }
124}