aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/shared-video-miniature
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared/shared-video-miniature')
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.html49
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.scss75
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.ts310
-rw-r--r--client/src/app/shared/shared-video-miniature/index.ts7
-rw-r--r--client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts40
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html21
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss12
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts269
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.html108
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.scss64
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.ts206
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html66
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.scss200
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts283
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.html30
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.scss57
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.ts118
17 files changed, 1915 insertions, 0 deletions
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/abstract-video-list.html
new file mode 100644
index 000000000..1e919ee72
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.html
@@ -0,0 +1,49 @@
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/shared-video-miniature/abstract-video-list.scss b/client/src/app/shared/shared-video-miniature/abstract-video-list.scss
new file mode 100644
index 000000000..7f23098aa
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.scss
@@ -0,0 +1,75 @@
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/shared-video-miniature/abstract-video-list.ts b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts
new file mode 100644
index 000000000..0ef842652
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts
@@ -0,0 +1,310 @@
1import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
2import { debounceTime, switchMap, tap } from 'rxjs/operators'
3import { OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router'
5import {
6 AuthService,
7 ComponentPaginationLight,
8 LocalStorageService,
9 Notifier,
10 ScreenService,
11 ServerService,
12 User,
13 UserService
14} from '@app/core'
15import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
16import { GlobalIconName } from '@app/shared/shared-icons'
17import { I18n } from '@ngx-translate/i18n-polyfill'
18import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
19import { ServerConfig, VideoSortField } from '@shared/models'
20import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
21import { Syndication, Video } from '../shared-main'
22import { MiniatureDisplayOptions, OwnerDisplayType } from './video-miniature.component'
23
24enum GroupDate {
25 UNKNOWN = 0,
26 TODAY = 1,
27 YESTERDAY = 2,
28 LAST_WEEK = 3,
29 LAST_MONTH = 4,
30 OLDER = 5
31}
32
33export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook {
34 pagination: ComponentPaginationLight = {
35 currentPage: 1,
36 itemsPerPage: 25
37 }
38 sort: VideoSortField = '-publishedAt'
39
40 categoryOneOf?: number[]
41 languageOneOf?: string[]
42 nsfwPolicy?: NSFWPolicyType
43 defaultSort: VideoSortField = '-publishedAt'
44
45 syndicationItems: Syndication[] = []
46
47 loadOnInit = true
48 useUserVideoPreferences = false
49 ownerDisplayType: OwnerDisplayType = 'account'
50 displayModerationBlock = false
51 titleTooltip: string
52 displayVideoActions = true
53 groupByDate = false
54
55 videos: Video[] = []
56 hasDoneFirstQuery = false
57 disabled = false
58
59 displayOptions: MiniatureDisplayOptions = {
60 date: true,
61 views: true,
62 by: true,
63 avatar: false,
64 privacyLabel: true,
65 privacyText: false,
66 state: false,
67 blacklistInfo: false
68 }
69
70 actions: {
71 routerLink: string
72 iconName: GlobalIconName
73 label: string
74 }[] = []
75
76 onDataSubject = new Subject<any[]>()
77
78 userMiniature: User
79
80 protected serverConfig: ServerConfig
81
82 protected abstract notifier: Notifier
83 protected abstract authService: AuthService
84 protected abstract userService: UserService
85 protected abstract route: ActivatedRoute
86 protected abstract serverService: ServerService
87 protected abstract screenService: ScreenService
88 protected abstract storageService: LocalStorageService
89 protected abstract router: Router
90 protected abstract i18n: I18n
91 abstract titlePage: string
92
93 private resizeSubscription: Subscription
94 private angularState: number
95
96 private groupedDateLabels: { [id in GroupDate]: string }
97 private groupedDates: { [id: number]: GroupDate } = {}
98
99 private lastQueryLength: number
100
101 abstract getVideosObservable (page: number): Observable<{ data: Video[] }>
102
103 abstract generateSyndicationList (): void
104
105 ngOnInit () {
106 this.serverConfig = this.serverService.getTmpConfig()
107 this.serverService.getConfig()
108 .subscribe(config => this.serverConfig = config)
109
110 this.groupedDateLabels = {
111 [GroupDate.UNKNOWN]: null,
112 [GroupDate.TODAY]: this.i18n('Today'),
113 [GroupDate.YESTERDAY]: this.i18n('Yesterday'),
114 [GroupDate.LAST_WEEK]: this.i18n('Last week'),
115 [GroupDate.LAST_MONTH]: this.i18n('Last month'),
116 [GroupDate.OLDER]: this.i18n('Older')
117 }
118
119 // Subscribe to route changes
120 const routeParams = this.route.snapshot.queryParams
121 this.loadRouteParams(routeParams)
122
123 this.resizeSubscription = fromEvent(window, 'resize')
124 .pipe(debounceTime(500))
125 .subscribe(() => this.calcPageSizes())
126
127 this.calcPageSizes()
128
129 const loadUserObservable = this.loadUserAndSettings()
130
131 if (this.loadOnInit === true) {
132 loadUserObservable.subscribe(() => this.loadMoreVideos())
133 }
134
135 this.userService.listenAnonymousUpdate()
136 .pipe(switchMap(() => this.loadUserAndSettings()))
137 .subscribe(() => {
138 if (this.hasDoneFirstQuery) this.reloadVideos()
139 })
140
141 // Display avatar in mobile view
142 if (this.screenService.isInMobileView()) {
143 this.displayOptions.avatar = true
144 }
145 }
146
147 ngOnDestroy () {
148 if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
149 }
150
151 disableForReuse () {
152 this.disabled = true
153 }
154
155 enabledForReuse () {
156 this.disabled = false
157 }
158
159 videoById (index: number, video: Video) {
160 return video.id
161 }
162
163 onNearOfBottom () {
164 if (this.disabled) return
165
166 // No more results
167 if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
168
169 this.pagination.currentPage += 1
170
171 this.setScrollRouteParams()
172
173 this.loadMoreVideos()
174 }
175
176 loadMoreVideos (reset = false) {
177 this.getVideosObservable(this.pagination.currentPage).subscribe(
178 ({ data }) => {
179 this.hasDoneFirstQuery = true
180 this.lastQueryLength = data.length
181
182 if (reset) this.videos = []
183 this.videos = this.videos.concat(data)
184
185 if (this.groupByDate) this.buildGroupedDateLabels()
186
187 this.onMoreVideos()
188
189 this.onDataSubject.next(data)
190 },
191
192 error => {
193 const message = this.i18n('Cannot load more videos. Try again later.')
194
195 console.error(message, { error })
196 this.notifier.error(message)
197 }
198 )
199 }
200
201 reloadVideos () {
202 this.pagination.currentPage = 1
203 this.loadMoreVideos(true)
204 }
205
206 toggleModerationDisplay () {
207 throw new Error('toggleModerationDisplay is not implemented')
208 }
209
210 removeVideoFromArray (video: Video) {
211 this.videos = this.videos.filter(v => v.id !== video.id)
212 }
213
214 buildGroupedDateLabels () {
215 let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
216
217 for (const video of this.videos) {
218 const publishedDate = video.publishedAt
219
220 if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) {
221 if (currentGroupedDate === GroupDate.TODAY) continue
222
223 currentGroupedDate = GroupDate.TODAY
224 this.groupedDates[ video.id ] = currentGroupedDate
225 continue
226 }
227
228 if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) {
229 if (currentGroupedDate === GroupDate.YESTERDAY) continue
230
231 currentGroupedDate = GroupDate.YESTERDAY
232 this.groupedDates[ video.id ] = currentGroupedDate
233 continue
234 }
235
236 if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) {
237 if (currentGroupedDate === GroupDate.LAST_WEEK) continue
238
239 currentGroupedDate = GroupDate.LAST_WEEK
240 this.groupedDates[ video.id ] = currentGroupedDate
241 continue
242 }
243
244 if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) {
245 if (currentGroupedDate === GroupDate.LAST_MONTH) continue
246
247 currentGroupedDate = GroupDate.LAST_MONTH
248 this.groupedDates[ video.id ] = currentGroupedDate
249 continue
250 }
251
252 if (currentGroupedDate <= GroupDate.OLDER) {
253 if (currentGroupedDate === GroupDate.OLDER) continue
254
255 currentGroupedDate = GroupDate.OLDER
256 this.groupedDates[ video.id ] = currentGroupedDate
257 }
258 }
259 }
260
261 getCurrentGroupedDateLabel (video: Video) {
262 if (this.groupByDate === false) return undefined
263
264 return this.groupedDateLabels[this.groupedDates[video.id]]
265 }
266
267 // On videos hook for children that want to do something
268 protected onMoreVideos () { /* empty */ }
269
270 protected loadRouteParams (routeParams: { [ key: string ]: any }) {
271 this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort
272 this.categoryOneOf = routeParams[ 'categoryOneOf' ]
273 this.angularState = routeParams[ 'a-state' ]
274 }
275
276 private calcPageSizes () {
277 if (this.screenService.isInMobileView()) {
278 this.pagination.itemsPerPage = 5
279 }
280 }
281
282 private setScrollRouteParams () {
283 // Already set
284 if (this.angularState) return
285
286 this.angularState = 42
287
288 const queryParams = {
289 'a-state': this.angularState,
290 categoryOneOf: this.categoryOneOf
291 }
292
293 let path = this.router.url
294 if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute
295
296 this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
297 }
298
299 private loadUserAndSettings () {
300 return this.userService.getAnonymousOrLoggedUser()
301 .pipe(tap(user => {
302 this.userMiniature = user
303
304 if (!this.useUserVideoPreferences) return
305
306 this.languageOneOf = user.videoLanguages
307 this.nsfwPolicy = user.nsfwPolicy
308 }))
309 }
310}
diff --git a/client/src/app/shared/shared-video-miniature/index.ts b/client/src/app/shared/shared-video-miniature/index.ts
new file mode 100644
index 000000000..47ca6f51b
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/index.ts
@@ -0,0 +1,7 @@
1export * from './abstract-video-list'
2export * from './video-actions-dropdown.component'
3export * from './video-download.component'
4export * from './video-miniature.component'
5export * from './videos-selection.component'
6
7export * from './shared-video-miniature.module'
diff --git a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts
new file mode 100644
index 000000000..666144864
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts
@@ -0,0 +1,40 @@
1
2import { NgModule } from '@angular/core'
3import { SharedFormModule } from '../shared-forms'
4import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module'
6import { SharedModerationModule } from '../shared-moderation'
7import { SharedThumbnailModule } from '../shared-thumbnail'
8import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
9import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
10import { VideoDownloadComponent } from './video-download.component'
11import { VideoMiniatureComponent } from './video-miniature.component'
12import { VideosSelectionComponent } from './videos-selection.component'
13
14@NgModule({
15 imports: [
16 SharedMainModule,
17 SharedFormModule,
18 SharedModerationModule,
19 SharedVideoPlaylistModule,
20 SharedThumbnailModule,
21 SharedGlobalIconModule
22 ],
23
24 declarations: [
25 VideoActionsDropdownComponent,
26 VideoDownloadComponent,
27 VideoMiniatureComponent,
28 VideosSelectionComponent
29 ],
30
31 exports: [
32 VideoActionsDropdownComponent,
33 VideoDownloadComponent,
34 VideoMiniatureComponent,
35 VideosSelectionComponent
36 ],
37
38 providers: [ ]
39})
40export class SharedVideoMiniatureModule { }
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html
new file mode 100644
index 000000000..3c8271b65
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.html
@@ -0,0 +1,21 @@
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/shared-video-miniature/video-actions-dropdown.component.scss b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss
new file mode 100644
index 000000000..67d7ee86a
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.scss
@@ -0,0 +1,12 @@
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/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
new file mode 100644
index 000000000..db8d1c309
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -0,0 +1,269 @@
1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core'
3import { VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { VideoCaption } from '@shared/models'
7import { DropdownAction, DropdownButtonSize, DropdownDirection, RedundancyService, Video, VideoDetails, VideoService } from '../shared-main'
8import { VideoAddToPlaylistComponent } from '../shared-video-playlist'
9import { VideoDownloadComponent } from './video-download.component'
10
11export type VideoActionsDisplayType = {
12 playlist?: boolean
13 download?: boolean
14 update?: boolean
15 blacklist?: boolean
16 delete?: boolean
17 report?: boolean
18 duplicate?: boolean
19}
20
21@Component({
22 selector: 'my-video-actions-dropdown',
23 templateUrl: './video-actions-dropdown.component.html',
24 styleUrls: [ './video-actions-dropdown.component.scss' ]
25})
26export class VideoActionsDropdownComponent implements OnChanges {
27 @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
28 @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
29
30 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
31 @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
32 @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
33
34 @Input() video: Video | VideoDetails
35 @Input() videoCaptions: VideoCaption[] = []
36
37 @Input() displayOptions: VideoActionsDisplayType = {
38 playlist: false,
39 download: true,
40 update: true,
41 blacklist: true,
42 delete: true,
43 report: true,
44 duplicate: true
45 }
46 @Input() placement = 'left'
47
48 @Input() label: string
49
50 @Input() buttonStyled = false
51 @Input() buttonSize: DropdownButtonSize = 'normal'
52 @Input() buttonDirection: DropdownDirection = 'vertical'
53
54 @Output() videoRemoved = new EventEmitter()
55 @Output() videoUnblocked = new EventEmitter()
56 @Output() videoBlocked = new EventEmitter()
57 @Output() modalOpened = new EventEmitter()
58
59 videoActions: DropdownAction<{ video: Video }>[][] = []
60
61 private loaded = false
62
63 constructor (
64 private authService: AuthService,
65 private notifier: Notifier,
66 private confirmService: ConfirmService,
67 private videoBlocklistService: VideoBlockService,
68 private screenService: ScreenService,
69 private videoService: VideoService,
70 private redundancyService: RedundancyService,
71 private i18n: I18n
72 ) { }
73
74 get user () {
75 return this.authService.getUser()
76 }
77
78 ngOnChanges () {
79 if (this.loaded) {
80 this.loaded = false
81 this.playlistAdd.reload()
82 }
83
84 this.buildActions()
85 }
86
87 isUserLoggedIn () {
88 return this.authService.isLoggedIn()
89 }
90
91 loadDropdownInformation () {
92 if (!this.isUserLoggedIn() || this.loaded === true) return
93
94 this.loaded = true
95
96 if (this.displayOptions.playlist) this.playlistAdd.load()
97 }
98
99 /* Show modals */
100
101 showDownloadModal () {
102 this.modalOpened.emit()
103
104 this.videoDownloadModal.show(this.video as VideoDetails, this.videoCaptions)
105 }
106
107 showReportModal () {
108 this.modalOpened.emit()
109
110 this.videoReportModal.show()
111 }
112
113 showBlockModal () {
114 this.modalOpened.emit()
115
116 this.videoBlockModal.show()
117 }
118
119 /* Actions checker */
120
121 isVideoUpdatable () {
122 return this.video.isUpdatableBy(this.user)
123 }
124
125 isVideoRemovable () {
126 return this.video.isRemovableBy(this.user)
127 }
128
129 isVideoBlockable () {
130 return this.video.isBlockableBy(this.user)
131 }
132
133 isVideoUnblockable () {
134 return this.video.isUnblockableBy(this.user)
135 }
136
137 isVideoDownloadable () {
138 return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
139 }
140
141 canVideoBeDuplicated () {
142 return this.video.canBeDuplicatedBy(this.user)
143 }
144
145 /* Action handlers */
146
147 async unblockVideo () {
148 const confirmMessage = this.i18n(
149 'Do you really want to unblock this video? It will be available again in the videos list.'
150 )
151
152 const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblock'))
153 if (res === false) return
154
155 this.videoBlocklistService.unblockVideo(this.video.id).subscribe(
156 () => {
157 this.notifier.success(this.i18n('Video {{name}} unblocked.', { name: this.video.name }))
158
159 this.video.blacklisted = false
160 this.video.blockedReason = null
161
162 this.videoUnblocked.emit()
163 },
164
165 err => this.notifier.error(err.message)
166 )
167 }
168
169 async removeVideo () {
170 this.modalOpened.emit()
171
172 const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
173 if (res === false) return
174
175 this.videoService.removeVideo(this.video.id)
176 .subscribe(
177 () => {
178 this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name }))
179
180 this.videoRemoved.emit()
181 },
182
183 error => this.notifier.error(error.message)
184 )
185 }
186
187 duplicateVideo () {
188 this.redundancyService.addVideoRedundancy(this.video)
189 .subscribe(
190 () => {
191 const message = this.i18n('This video will be duplicated by your instance.')
192 this.notifier.success(message)
193 },
194
195 err => this.notifier.error(err.message)
196 )
197 }
198
199 onVideoBlocked () {
200 this.videoBlocked.emit()
201 }
202
203 getPlaylistDropdownPlacement () {
204 if (this.screenService.isInSmallView()) {
205 return 'bottom-right'
206 }
207
208 return 'bottom-left bottom-right'
209 }
210
211 private buildActions () {
212 this.videoActions = [
213 [
214 {
215 label: this.i18n('Save to playlist'),
216 handler: () => this.playlistDropdown.toggle(),
217 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.playlist,
218 iconName: 'playlist-add'
219 }
220 ],
221 [
222 {
223 label: this.i18n('Download'),
224 handler: () => this.showDownloadModal(),
225 isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(),
226 iconName: 'download'
227 },
228 {
229 label: this.i18n('Update'),
230 linkBuilder: ({ video }) => [ '/videos/update', video.uuid ],
231 iconName: 'edit',
232 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
233 },
234 {
235 label: this.i18n('Block'),
236 handler: () => this.showBlockModal(),
237 iconName: 'no',
238 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoBlockable()
239 },
240 {
241 label: this.i18n('Unblock'),
242 handler: () => this.unblockVideo(),
243 iconName: 'undo',
244 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblockable()
245 },
246 {
247 label: this.i18n('Mirror'),
248 handler: () => this.duplicateVideo(),
249 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
250 iconName: 'cloud-download'
251 },
252 {
253 label: this.i18n('Delete'),
254 handler: () => this.removeVideo(),
255 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
256 iconName: 'delete'
257 }
258 ],
259 [
260 {
261 label: this.i18n('Report'),
262 handler: () => this.showReportModal(),
263 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.report,
264 iconName: 'alert'
265 }
266 ]
267 ]
268 }
269}
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.html b/client/src/app/shared/shared-video-miniature/video-download.component.html
new file mode 100644
index 000000000..c65e371ee
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.html
@@ -0,0 +1,108 @@
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/shared-video-miniature/video-download.component.scss b/client/src/app/shared/shared-video-miniature/video-download.component.scss
new file mode 100644
index 000000000..b09078bea
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.scss
@@ -0,0 +1,64 @@
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/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/video-download.component.ts
new file mode 100644
index 000000000..21df8b674
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts
@@ -0,0 +1,206 @@
1import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
2import { mapValues, pick } from 'lodash-es'
3import { BytesPipe } from 'ngx-pipes'
4import { Component, ElementRef, ViewChild } from '@angular/core'
5import { AuthService, Notifier } from '@app/core'
6import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
9import { NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main'
10
11type DownloadType = 'video' | 'subtitles'
12type FileMetadata = { [key: string]: { label: string, value: string }}
13
14@Component({
15 selector: 'my-video-download',
16 templateUrl: './video-download.component.html',
17 styleUrls: [ './video-download.component.scss' ]
18})
19export class VideoDownloadComponent {
20 @ViewChild('modal', { static: true }) modal: ElementRef
21
22 downloadType: 'direct' | 'torrent' = 'torrent'
23 resolutionId: number | string = -1
24 subtitleLanguageId: string
25
26 video: VideoDetails
27 videoFile: VideoFile
28 videoFileMetadataFormat: FileMetadata
29 videoFileMetadataVideoStream: FileMetadata | undefined
30 videoFileMetadataAudioStream: FileMetadata | undefined
31 videoCaptions: VideoCaption[]
32 activeModal: NgbActiveModal
33
34 type: DownloadType = 'video'
35
36 private bytesPipe: BytesPipe
37 private numbersPipe: NumberFormatterPipe
38
39 constructor (
40 private notifier: Notifier,
41 private modalService: NgbModal,
42 private videoService: VideoService,
43 private auth: AuthService,
44 private i18n: I18n
45 ) {
46 this.bytesPipe = new BytesPipe()
47 this.numbersPipe = new NumberFormatterPipe()
48 }
49
50 get typeText () {
51 return this.type === 'video'
52 ? this.i18n('video')
53 : this.i18n('subtitles')
54 }
55
56 getVideoFiles () {
57 if (!this.video) return []
58
59 return this.video.getFiles()
60 }
61
62 show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
63 this.video = video
64 this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
65
66 this.activeModal = this.modalService.open(this.modal, { centered: true })
67
68 this.resolutionId = this.getVideoFiles()[0].resolution.id
69 this.onResolutionIdChange()
70 if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
71 }
72
73 onClose () {
74 this.video = undefined
75 this.videoCaptions = undefined
76 }
77
78 download () {
79 window.location.assign(this.getLink())
80 this.activeModal.close()
81 }
82
83 getLink () {
84 return this.type === 'subtitles' && this.videoCaptions
85 ? this.getSubtitlesLink()
86 : this.getVideoFileLink()
87 }
88
89 async onResolutionIdChange () {
90 this.videoFile = this.getVideoFile()
91 if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
92
93 await this.hydrateMetadataFromMetadataUrl(this.videoFile)
94
95 this.videoFileMetadataFormat = this.videoFile
96 ? this.getMetadataFormat(this.videoFile.metadata.format)
97 : undefined
98 this.videoFileMetadataVideoStream = this.videoFile
99 ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
100 : undefined
101 this.videoFileMetadataAudioStream = this.videoFile
102 ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
103 : undefined
104 }
105
106 getVideoFile () {
107 // HTML select send us a string, so convert it to a number
108 this.resolutionId = parseInt(this.resolutionId.toString(), 10)
109
110 const file = this.getVideoFiles().find(f => f.resolution.id === this.resolutionId)
111 if (!file) {
112 console.error('Could not find file with resolution %d.', this.resolutionId)
113 return
114 }
115 return file
116 }
117
118 getVideoFileLink () {
119 const file = this.videoFile
120 if (!file) return
121
122 const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
123 ? '?access_token=' + this.auth.getAccessToken()
124 : ''
125
126 switch (this.downloadType) {
127 case 'direct':
128 return file.fileDownloadUrl + suffix
129
130 case 'torrent':
131 return file.torrentDownloadUrl + suffix
132 }
133 }
134
135 getSubtitlesLink () {
136 return window.location.origin + this.videoCaptions.find(caption => caption.language.id === this.subtitleLanguageId).captionPath
137 }
138
139 activateCopiedMessage () {
140 this.notifier.success(this.i18n('Copied'))
141 }
142
143 switchToType (type: DownloadType) {
144 this.type = type
145 }
146
147 getMetadataFormat (format: FfprobeFormat) {
148 const keyToTranslateFunction = {
149 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
150 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
151 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
152 'bit_rate': (value: number) => ({
153 label: this.i18n('Bitrate'),
154 value: `${this.numbersPipe.transform(value)}bps`
155 })
156 }
157
158 // flattening format
159 const sanitizedFormat = Object.assign(format, format.tags)
160 delete sanitizedFormat.tags
161
162 return mapValues(
163 pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
164 (val, key) => keyToTranslateFunction[key](val)
165 )
166 }
167
168 getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
169 const stream = streams.find(s => s.codec_type === type)
170 if (!stream) return undefined
171
172 let keyToTranslateFunction = {
173 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
174 'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
175 'bit_rate': (value: number) => ({
176 label: this.i18n('Bitrate'),
177 value: `${this.numbersPipe.transform(value)}bps`
178 })
179 }
180
181 if (type === 'video') {
182 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
183 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
184 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
185 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
186 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
187 })
188 } else {
189 keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
190 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
191 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
192 })
193 }
194
195 return mapValues(
196 pick(stream, Object.keys(keyToTranslateFunction)),
197 (val, key) => keyToTranslateFunction[key](val)
198 )
199 }
200
201 private hydrateMetadataFromMetadataUrl (file: VideoFile) {
202 const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
203 observable.subscribe(res => file.metadata = res)
204 return observable.toPromise()
205 }
206}
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
new file mode 100644
index 000000000..82afc866f
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -0,0 +1,66 @@
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/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
new file mode 100644
index 000000000..38cac5b6e
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
@@ -0,0 +1,200 @@
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/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
new file mode 100644
index 000000000..6f32977b3
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -0,0 +1,283 @@
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, ScreenService, ServerService, User } from '@app/core'
14import { I18n } from '@ngx-translate/i18n-polyfill'
15import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
16import { Video } from '../shared-main'
17import { VideoPlaylistService } from '../shared-video-playlist'
18import { VideoActionsDisplayType } from './video-actions-dropdown.component'
19
20export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
21export type MiniatureDisplayOptions = {
22 date?: boolean
23 views?: boolean
24 by?: boolean
25 avatar?: boolean
26 privacyLabel?: boolean
27 privacyText?: boolean
28 state?: boolean
29 blacklistInfo?: boolean
30 nsfw?: boolean
31}
32
33@Component({
34 selector: 'my-video-miniature',
35 styleUrls: [ './video-miniature.component.scss' ],
36 templateUrl: './video-miniature.component.html',
37 changeDetection: ChangeDetectionStrategy.OnPush
38})
39export class VideoMiniatureComponent implements OnInit {
40 @Input() user: User
41 @Input() video: Video
42
43 @Input() ownerDisplayType: OwnerDisplayType = 'account'
44 @Input() displayOptions: MiniatureDisplayOptions = {
45 date: true,
46 views: true,
47 by: true,
48 avatar: false,
49 privacyLabel: false,
50 privacyText: false,
51 state: false,
52 blacklistInfo: false
53 }
54 @Input() displayAsRow = false
55 @Input() displayVideoActions = true
56 @Input() fitWidth = false
57
58 @Input() useLazyLoadUrl = false
59
60 @Output() videoBlocked = new EventEmitter()
61 @Output() videoUnblocked = new EventEmitter()
62 @Output() videoRemoved = new EventEmitter()
63
64 videoActionsDisplayOptions: VideoActionsDisplayType = {
65 playlist: true,
66 download: false,
67 update: true,
68 blacklist: true,
69 delete: true,
70 report: true,
71 duplicate: true
72 }
73 showActions = false
74 serverConfig: ServerConfig
75
76 addToWatchLaterText: string
77 addedToWatchLaterText: string
78 inWatchLaterPlaylist: boolean
79 channelLinkTitle = ''
80
81 watchLaterPlaylist: {
82 id: number
83 playlistElementId?: number
84 }
85
86 videoLink: any[] = []
87
88 private ownerDisplayTypeChosen: 'account' | 'videoChannel'
89
90 constructor (
91 private screenService: ScreenService,
92 private serverService: ServerService,
93 private i18n: I18n,
94 private authService: AuthService,
95 private videoPlaylistService: VideoPlaylistService,
96 private cd: ChangeDetectorRef,
97 @Inject(LOCALE_ID) private localeId: string
98 ) {}
99
100 get isVideoBlur () {
101 return this.video.isVideoNSFWForUser(this.user, this.serverConfig)
102 }
103
104 ngOnInit () {
105 this.serverConfig = this.serverService.getTmpConfig()
106 this.serverService.getConfig()
107 .subscribe(config => {
108 this.serverConfig = config
109 this.buildVideoLink()
110 })
111
112 this.setUpBy()
113
114 this.channelLinkTitle = this.i18n(
115 '{{name}} (channel page)',
116 { name: this.video.channel.name, handle: this.video.byVideoChannel }
117 )
118
119 // We rely on mouseenter to lazy load actions
120 if (this.screenService.isInTouchScreen()) {
121 this.loadActions()
122 }
123 }
124
125 buildVideoLink () {
126 if (this.useLazyLoadUrl && this.video.url) {
127 const remoteUriConfig = this.serverConfig.search.remoteUri
128
129 // Redirect on the external instance if not allowed to fetch remote data
130 const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
131 const fromPath = window.location.pathname + window.location.search
132
133 this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
134 return
135 }
136
137 this.videoLink = [ '/videos/watch', this.video.uuid ]
138 }
139
140 displayOwnerAccount () {
141 return this.ownerDisplayTypeChosen === 'account'
142 }
143
144 displayOwnerVideoChannel () {
145 return this.ownerDisplayTypeChosen === 'videoChannel'
146 }
147
148 isUnlistedVideo () {
149 return this.video.privacy.id === VideoPrivacy.UNLISTED
150 }
151
152 isPrivateVideo () {
153 return this.video.privacy.id === VideoPrivacy.PRIVATE
154 }
155
156 getStateLabel (video: Video) {
157 if (!video.state) return ''
158
159 if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
160 return this.i18n('Published')
161 }
162
163 if (video.scheduledUpdate) {
164 const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
165 return this.i18n('Publication scheduled on ') + updateAt
166 }
167
168 if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
169 return this.i18n('Waiting transcoding')
170 }
171
172 if (video.state.id === VideoState.TO_TRANSCODE) {
173 return this.i18n('To transcode')
174 }
175
176 if (video.state.id === VideoState.TO_IMPORT) {
177 return this.i18n('To import')
178 }
179
180 return ''
181 }
182
183 getAvatarUrl () {
184 if (this.ownerDisplayTypeChosen === 'account') {
185 return this.video.accountAvatarUrl
186 }
187
188 return this.video.videoChannelAvatarUrl
189 }
190
191 loadActions () {
192 if (this.displayVideoActions) this.showActions = true
193
194 this.loadWatchLater()
195 }
196
197 onVideoBlocked () {
198 this.videoBlocked.emit()
199 }
200
201 onVideoUnblocked () {
202 this.videoUnblocked.emit()
203 }
204
205 onVideoRemoved () {
206 this.videoRemoved.emit()
207 }
208
209 isUserLoggedIn () {
210 return this.authService.isLoggedIn()
211 }
212
213 onWatchLaterClick (currentState: boolean) {
214 if (currentState === true) this.removeFromWatchLater()
215 else this.addToWatchLater()
216
217 this.inWatchLaterPlaylist = !currentState
218 }
219
220 addToWatchLater () {
221 const body = { videoId: this.video.id }
222
223 this.videoPlaylistService.addVideoInPlaylist(this.watchLaterPlaylist.id, body).subscribe(
224 res => {
225 this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id
226 }
227 )
228 }
229
230 removeFromWatchLater () {
231 this.videoPlaylistService.removeVideoFromPlaylist(this.watchLaterPlaylist.id, this.watchLaterPlaylist.playlistElementId, this.video.id)
232 .subscribe(
233 _ => { /* empty */ }
234 )
235 }
236
237 isWatchLaterPlaylistDisplayed () {
238 return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
239 }
240
241 private setUpBy () {
242 if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') {
243 this.ownerDisplayTypeChosen = this.ownerDisplayType
244 return
245 }
246
247 // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12)
248 // -> Use the account name
249 if (
250 this.video.channel.name === `${this.video.account.name}_channel` ||
251 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}$/)
252 ) {
253 this.ownerDisplayTypeChosen = 'account'
254 } else {
255 this.ownerDisplayTypeChosen = 'videoChannel'
256 }
257 }
258
259 private loadWatchLater () {
260 if (!this.isUserLoggedIn() || this.inWatchLaterPlaylist !== undefined) return
261
262 this.authService.userInformationLoaded
263 .pipe(switchMap(() => this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)))
264 .subscribe(existResult => {
265 const watchLaterPlaylist = this.authService.getUser().specialPlaylists.find(p => p.type === VideoPlaylistType.WATCH_LATER)
266 const existsInWatchLater = existResult.find(r => r.playlistId === watchLaterPlaylist.id)
267 this.inWatchLaterPlaylist = false
268
269 this.watchLaterPlaylist = {
270 id: watchLaterPlaylist.id
271 }
272
273 if (existsInWatchLater) {
274 this.inWatchLaterPlaylist = true
275 this.watchLaterPlaylist.playlistElementId = existsInWatchLater.playlistElementId
276 }
277
278 this.cd.markForCheck()
279 })
280
281 this.videoPlaylistService.runPlaylistCheck(this.video.id)
282 }
283}
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.html b/client/src/app/shared/shared-video-miniature/videos-selection.component.html
new file mode 100644
index 000000000..44aa567b9
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.html
@@ -0,0 +1,30 @@
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/shared-video-miniature/videos-selection.component.scss b/client/src/app/shared/shared-video-miniature/videos-selection.component.scss
new file mode 100644
index 000000000..d3cbabf23
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.scss
@@ -0,0 +1,57 @@
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/shared-video-miniature/videos-selection.component.ts b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts
new file mode 100644
index 000000000..3e0e3b983
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts
@@ -0,0 +1,118 @@
1import { Observable } from 'rxjs'
2import {
3 AfterContentInit,
4 Component,
5 ContentChildren,
6 EventEmitter,
7 Input,
8 OnDestroy,
9 OnInit,
10 Output,
11 QueryList,
12 TemplateRef
13} from '@angular/core'
14import { ActivatedRoute, Router } from '@angular/router'
15import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
16import { I18n } from '@ngx-translate/i18n-polyfill'
17import { ResultList, VideoSortField } from '@shared/models'
18import { PeerTubeTemplateDirective, Video } from '../shared-main'
19import { AbstractVideoList } from './abstract-video-list'
20import { MiniatureDisplayOptions, OwnerDisplayType } from './video-miniature.component'
21
22export type SelectionType = { [ id: number ]: boolean }
23
24@Component({
25 selector: 'my-videos-selection',
26 templateUrl: './videos-selection.component.html',
27 styleUrls: [ './videos-selection.component.scss' ]
28})
29export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
30 @Input() pagination: ComponentPagination
31 @Input() titlePage: string
32 @Input() miniatureDisplayOptions: MiniatureDisplayOptions
33 @Input() ownerDisplayType: OwnerDisplayType
34
35 @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
36
37 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'rowButtons' | 'globalButtons'>>
38
39 @Output() selectionChange = new EventEmitter<SelectionType>()
40 @Output() videosModelChange = new EventEmitter<Video[]>()
41
42 _selection: SelectionType = {}
43
44 rowButtonsTemplate: TemplateRef<any>
45 globalButtonsTemplate: TemplateRef<any>
46
47 constructor (
48 protected i18n: I18n,
49 protected router: Router,
50 protected route: ActivatedRoute,
51 protected notifier: Notifier,
52 protected authService: AuthService,
53 protected userService: UserService,
54 protected screenService: ScreenService,
55 protected storageService: LocalStorageService,
56 protected serverService: ServerService
57 ) {
58 super()
59 }
60
61 @Input() get selection () {
62 return this._selection
63 }
64
65 set selection (selection: SelectionType) {
66 this._selection = selection
67 this.selectionChange.emit(this._selection)
68 }
69
70 @Input() get videosModel () {
71 return this.videos
72 }
73
74 set videosModel (videos: Video[]) {
75 this.videos = videos
76 this.videosModelChange.emit(this.videos)
77 }
78
79 ngOnInit () {
80 super.ngOnInit()
81 }
82
83 ngAfterContentInit () {
84 {
85 const t = this.templates.find(t => t.name === 'rowButtons')
86 if (t) this.rowButtonsTemplate = t.template
87 }
88
89 {
90 const t = this.templates.find(t => t.name === 'globalButtons')
91 if (t) this.globalButtonsTemplate = t.template
92 }
93 }
94
95 ngOnDestroy () {
96 super.ngOnDestroy()
97 }
98
99 getVideosObservable (page: number) {
100 return this.getVideosObservableFunction(page, this.sort)
101 }
102
103 abortSelectionMode () {
104 this._selection = {}
105 }
106
107 isInSelectionMode () {
108 return Object.keys(this._selection).some(k => this._selection[ k ] === true)
109 }
110
111 generateSyndicationList () {
112 throw new Error('Method not implemented.')
113 }
114
115 protected onMoreVideos () {
116 this.videosModel = this.videos
117 }
118}