aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--CREDITS.md7
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts12
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss2
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html1
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html16
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss2
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts62
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html4
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts17
-rw-r--r--client/src/app/+my-account/my-account.module.ts6
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.html4
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.scss8
-rw-r--r--client/src/app/shared/forms/timestamp-input.component.ts61
-rw-r--r--client/src/app/shared/images/global-icon.component.html0
-rw-r--r--client/src/app/shared/images/global-icon.component.ts3
-rw-r--r--client/src/app/shared/shared.module.ts20
-rw-r--r--client/src/app/shared/user-subscription/subscribe-button.component.ts2
-rw-r--r--client/src/app/shared/user-subscription/user-subscription.service.ts6
-rw-r--r--client/src/app/shared/users/user-notifications.component.scss2
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.html74
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.scss98
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.ts195
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.html6
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.scss11
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.ts8
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.model.ts5
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.service.ts77
-rw-r--r--client/src/app/shared/video/video.service.ts19
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.html12
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.scss5
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.ts11
-rw-r--r--client/src/app/videos/+video-watch/video-watch-routing.module.ts11
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html20
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss11
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts14
-rw-r--r--client/src/app/videos/videos-routing.module.ts6
-rw-r--r--client/src/assets/images/global/add.html6
-rw-r--r--client/src/assets/images/video/playlist-add.html10
-rw-r--r--client/src/assets/images/video/watch-later.html11
-rw-r--r--client/src/assets/player/peertube-player-manager.ts9
-rw-r--r--client/src/assets/player/peertube-plugin.ts16
-rw-r--r--client/src/assets/player/peertube-videojs-typings.ts7
-rw-r--r--client/src/assets/player/utils.ts20
-rw-r--r--client/src/assets/player/webtorrent/webtorrent-plugin.ts4
-rw-r--r--client/src/sass/include/_mixins.scss1
-rw-r--r--client/src/sass/include/_variables.scss2
-rw-r--r--client/src/standalone/videos/embed.ts3
-rw-r--r--server/controllers/api/users/index.ts2
-rw-r--r--server/controllers/api/users/my-video-playlists.ts47
-rw-r--r--server/controllers/api/video-playlist.ts34
-rw-r--r--server/helpers/custom-validators/misc.ts10
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts23
-rw-r--r--server/models/video/video-playlist.ts23
-rw-r--r--shared/models/videos/playlist/video-exist-in-playlist.model.ts7
55 files changed, 961 insertions, 94 deletions
diff --git a/CREDITS.md b/CREDITS.md
index 716f3fca2..1f7aaad7a 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -206,6 +206,9 @@
206 206
207# Design 207# Design
208 208
209By [Olivier Massain](https://twitter.com/omassain) 209 * [Olivier Massain](https://twitter.com/omassain)
210 210
211Icons from [Robbie Pearce](https://robbiepearce.com/softies/) 211# Icons
212
213 * [Robbie Pearce](https://robbiepearce.com/softies/)
214 * playlist add by Google
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index 0193afff7..3f921b13f 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -22,6 +22,9 @@ import {
22import { 22import {
23 MyAccountVideoPlaylistUpdateComponent 23 MyAccountVideoPlaylistUpdateComponent
24} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' 24} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
25import {
26 MyAccountVideoPlaylistElementsComponent
27} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
25 28
26const myAccountRoutes: Routes = [ 29const myAccountRoutes: Routes = [
27 { 30 {
@@ -82,6 +85,15 @@ const myAccountRoutes: Routes = [
82 } 85 }
83 }, 86 },
84 { 87 {
88 path: 'video-playlists/:videoPlaylistId',
89 component: MyAccountVideoPlaylistElementsComponent,
90 data: {
91 meta: {
92 title: 'Playlist elements'
93 }
94 }
95 },
96 {
85 path: 'video-playlists/create', 97 path: 'video-playlists/create',
86 component: MyAccountVideoPlaylistCreateComponent, 98 component: MyAccountVideoPlaylistCreateComponent,
87 data: { 99 data: {
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
index 6feb16ab1..0274f47c5 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
@@ -4,7 +4,7 @@
4.custom-row { 4.custom-row {
5 display: flex; 5 display: flex;
6 align-items: center; 6 align-items: center;
7 border-bottom: 1px solid rgba(0, 0, 0, 0.10); 7 border-bottom: 1px solid $separator-border-color;
8 8
9 &:first-child { 9 &:first-child {
10 font-size: 16px; 10 font-size: 16px;
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
index b76488c78..5d1184218 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
@@ -60,5 +60,6 @@
60 </div> 60 </div>
61 </div> 61 </div>
62 </div> 62 </div>
63
63 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> 64 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
64</form> 65</form>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
new file mode 100644
index 000000000..28ea7a857
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
@@ -0,0 +1,16 @@
1<div class="no-results">No videos in this playlist.</div>
2
3<div class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
4 <div *ngFor="let video of videos" class="video">
5 <my-video-thumbnail [video]="video"></my-video-thumbnail>
6
7 <div class="video-info">
8 <div class="position">{{ video.playlistElement.position }}</div>
9
10 <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
11
12 <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
13 <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
14 </div>
15 </div>
16</div>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
new file mode 100644
index 000000000..5e6774739
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss
@@ -0,0 +1,2 @@
1@import '_variables';
2@import '_mixins';
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
new file mode 100644
index 000000000..8b70a9b1a
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
@@ -0,0 +1,62 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { Notifier } from '@app/core'
3import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm'
5import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
6import { Video } from '@app/shared/video/video.model'
7import { Subscription } from 'rxjs'
8import { ActivatedRoute } from '@angular/router'
9import { VideoService } from '@app/shared/video/video.service'
10
11@Component({
12 selector: 'my-account-video-playlist-elements',
13 templateUrl: './my-account-video-playlist-elements.component.html',
14 styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
15})
16export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
17 videos: Video[] = []
18
19 pagination: ComponentPagination = {
20 currentPage: 1,
21 itemsPerPage: 10,
22 totalItems: null
23 }
24
25 private videoPlaylistId: string | number
26 private paramsSub: Subscription
27
28 constructor (
29 private authService: AuthService,
30 private notifier: Notifier,
31 private confirmService: ConfirmService,
32 private route: ActivatedRoute,
33 private videoService: VideoService
34 ) {}
35
36 ngOnInit () {
37 this.paramsSub = this.route.params.subscribe(routeParams => {
38 this.videoPlaylistId = routeParams[ 'videoPlaylistId' ]
39 this.loadElements()
40 })
41 }
42
43 ngOnDestroy () {
44 if (this.paramsSub) this.paramsSub.unsubscribe()
45 }
46
47 onNearOfBottom () {
48 // Last page
49 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
50
51 this.pagination.currentPage += 1
52 this.loadElements()
53 }
54
55 private loadElements () {
56 this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
57 .subscribe(({ totalVideos, videos }) => {
58 this.videos = this.videos.concat(videos)
59 this.pagination.totalItems = totalVideos
60 })
61 }
62}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
index ab5d9cc5a..7d1bed12a 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
@@ -5,10 +5,10 @@
5 </a> 5 </a>
6</div> 6</div>
7 7
8<div class="video-playlists"> 8<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
9 <div *ngFor="let playlist of videoPlaylists" class="video-playlist"> 9 <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
10 <div class="miniature-wrapper"> 10 <div class="miniature-wrapper">
11 <my-video-playlist-miniature [playlist]="playlist"></my-video-playlist-miniature> 11 <my-video-playlist-miniature [playlist]="playlist" [toManage]="true"></my-video-playlist-miniature>
12 </div> 12 </div>
13 13
14 <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons"> 14 <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
index 761ce90e8..e30656b92 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
@@ -69,17 +69,20 @@ export class MyAccountVideoPlaylistsComponent implements OnInit {
69 return playlist.type.id === VideoPlaylistType.REGULAR 69 return playlist.type.id === VideoPlaylistType.REGULAR
70 } 70 }
71 71
72 private loadVideoPlaylists () { 72 onNearOfBottom () {
73 this.authService.userInformationLoaded
74 .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account)))
75 .subscribe(res => this.videoPlaylists = res.data)
76 }
77
78 private ofNearOfBottom () {
79 // Last page 73 // Last page
80 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return 74 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
81 75
82 this.pagination.currentPage += 1 76 this.pagination.currentPage += 1
83 this.loadVideoPlaylists() 77 this.loadVideoPlaylists()
84 } 78 }
79
80 private loadVideoPlaylists () {
81 this.authService.userInformationLoaded
82 .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt')))
83 .subscribe(res => {
84 this.videoPlaylists = this.videoPlaylists.concat(res.data)
85 this.pagination.totalItems = res.total
86 })
87 }
85} 88}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 3dbce2b92..ba8300111 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -32,6 +32,9 @@ import {
32 MyAccountVideoPlaylistUpdateComponent 32 MyAccountVideoPlaylistUpdateComponent
33} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' 33} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
34import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' 34import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
35import {
36 MyAccountVideoPlaylistElementsComponent
37} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
35 38
36@NgModule({ 39@NgModule({
37 imports: [ 40 imports: [
@@ -68,7 +71,8 @@ import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-vi
68 71
69 MyAccountVideoPlaylistCreateComponent, 72 MyAccountVideoPlaylistCreateComponent,
70 MyAccountVideoPlaylistUpdateComponent, 73 MyAccountVideoPlaylistUpdateComponent,
71 MyAccountVideoPlaylistsComponent 74 MyAccountVideoPlaylistsComponent,
75 MyAccountVideoPlaylistElementsComponent
72 ], 76 ],
73 77
74 exports: [ 78 exports: [
diff --git a/client/src/app/shared/forms/timestamp-input.component.html b/client/src/app/shared/forms/timestamp-input.component.html
new file mode 100644
index 000000000..c57a4b32c
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.html
@@ -0,0 +1,4 @@
1<p-inputMask
2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
3 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
4></p-inputMask>
diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss
new file mode 100644
index 000000000..7115777fd
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.scss
@@ -0,0 +1,8 @@
1p-inputmask {
2 /deep/ input {
3 width: 80px;
4 font-size: 15px;
5
6 border: none;
7 }
8}
diff --git a/client/src/app/shared/forms/timestamp-input.component.ts b/client/src/app/shared/forms/timestamp-input.component.ts
new file mode 100644
index 000000000..8d67a96ac
--- /dev/null
+++ b/client/src/app/shared/forms/timestamp-input.component.ts
@@ -0,0 +1,61 @@
1import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { secondsToTime, timeToInt } from '../../../assets/player/utils'
4
5@Component({
6 selector: 'my-timestamp-input',
7 styleUrls: [ './timestamp-input.component.scss' ],
8 templateUrl: './timestamp-input.component.html',
9 providers: [
10 {
11 provide: NG_VALUE_ACCESSOR,
12 useExisting: forwardRef(() => TimestampInputComponent),
13 multi: true
14 }
15 ]
16})
17export class TimestampInputComponent implements ControlValueAccessor, OnInit {
18 @Input() maxTimestamp: number
19 @Input() timestamp: number
20 @Input() disabled = false
21
22 timestampString: string
23
24 constructor (private changeDetector: ChangeDetectorRef) {}
25
26 ngOnInit () {
27 this.writeValue(this.timestamp || 0)
28 }
29
30 propagateChange = (_: any) => { /* empty */ }
31
32 writeValue (timestamp: number) {
33 this.timestamp = timestamp
34
35 this.timestampString = secondsToTime(this.timestamp, true, ':')
36 }
37
38 registerOnChange (fn: (_: any) => void) {
39 this.propagateChange = fn
40 }
41
42 registerOnTouched () {
43 // Unused
44 }
45
46 onModelChange () {
47 this.timestamp = timeToInt(this.timestampString)
48
49 this.propagateChange(this.timestamp)
50 }
51
52 onBlur () {
53 if (this.maxTimestamp && this.timestamp > this.maxTimestamp) {
54 this.writeValue(this.maxTimestamp)
55
56 this.changeDetector.detectChanges()
57
58 this.propagateChange(this.timestamp)
59 }
60 }
61}
diff --git a/client/src/app/shared/images/global-icon.component.html b/client/src/app/shared/images/global-icon.component.html
deleted file mode 100644
index e69de29bb..000000000
--- a/client/src/app/shared/images/global-icon.component.html
+++ /dev/null
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
index e8ada0324..3fda7ee4d 100644
--- a/client/src/app/shared/images/global-icon.component.ts
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -25,7 +25,8 @@ const icons = {
25 'like': require('../../../assets/images/video/like.html'), 25 'like': require('../../../assets/images/video/like.html'),
26 'more': require('../../../assets/images/video/more.html'), 26 'more': require('../../../assets/images/video/more.html'),
27 'share': require('../../../assets/images/video/share.html'), 27 'share': require('../../../assets/images/video/share.html'),
28 'upload': require('../../../assets/images/video/upload.html') 28 'upload': require('../../../assets/images/video/upload.html'),
29 'playlist-add': require('../../../assets/images/video/playlist-add.html')
29} 30}
30 31
31export type GlobalIconName = keyof typeof icons 32export type GlobalIconName = keyof typeof icons
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 60a7bd6e2..1f9eee0b7 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -9,6 +9,7 @@ import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.d
9 9
10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' 10import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
11import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' 11import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
12import { KeyFilterModule } from 'primeng/keyfilter'
12 13
13import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 14import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
14import { ButtonComponent } from './buttons/button.component' 15import { ButtonComponent } from './buttons/button.component'
@@ -49,6 +50,7 @@ import {
49 VideoValidatorsService 50 VideoValidatorsService
50} from '@app/shared/forms' 51} from '@app/shared/forms'
51import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' 52import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
53import { InputMaskModule } from 'primeng/inputmask'
52import { ScreenService } from '@app/shared/misc/screen.service' 54import { ScreenService } from '@app/shared/misc/screen.service'
53import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' 55import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
54import { VideoCaptionService } from '@app/shared/video-caption' 56import { VideoCaptionService } from '@app/shared/video-caption'
@@ -74,6 +76,8 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.
74import { ImageUploadComponent } from '@app/shared/images/image-upload.component' 76import { ImageUploadComponent } from '@app/shared/images/image-upload.component'
75import { GlobalIconComponent } from '@app/shared/images/global-icon.component' 77import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
76import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' 78import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
79import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
80import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
77 81
78@NgModule({ 82@NgModule({
79 imports: [ 83 imports: [
@@ -90,6 +94,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
90 NgbTooltipModule, 94 NgbTooltipModule,
91 95
92 PrimeSharedModule, 96 PrimeSharedModule,
97 InputMaskModule,
98 KeyFilterModule,
93 NgPipesModule 99 NgPipesModule
94 ], 100 ],
95 101
@@ -100,11 +106,14 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
100 VideoThumbnailComponent, 106 VideoThumbnailComponent,
101 VideoMiniatureComponent, 107 VideoMiniatureComponent,
102 VideoPlaylistMiniatureComponent, 108 VideoPlaylistMiniatureComponent,
109 VideoAddToPlaylistComponent,
103 110
104 FeedComponent, 111 FeedComponent,
112
105 ButtonComponent, 113 ButtonComponent,
106 DeleteButtonComponent, 114 DeleteButtonComponent,
107 EditButtonComponent, 115 EditButtonComponent,
116
108 ActionDropdownComponent, 117 ActionDropdownComponent,
109 NumberFormatterPipe, 118 NumberFormatterPipe,
110 ObjectLengthPipe, 119 ObjectLengthPipe,
@@ -113,8 +122,11 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
113 InfiniteScrollerDirective, 122 InfiniteScrollerDirective,
114 TextareaAutoResizeDirective, 123 TextareaAutoResizeDirective,
115 HelpComponent, 124 HelpComponent,
125
116 ReactiveFileComponent, 126 ReactiveFileComponent,
117 PeertubeCheckboxComponent, 127 PeertubeCheckboxComponent,
128 TimestampInputComponent,
129
118 SubscribeButtonComponent, 130 SubscribeButtonComponent,
119 RemoteSubscribeComponent, 131 RemoteSubscribeComponent,
120 InstanceFeaturesTableComponent, 132 InstanceFeaturesTableComponent,
@@ -142,6 +154,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
142 NgbTooltipModule, 154 NgbTooltipModule,
143 155
144 PrimeSharedModule, 156 PrimeSharedModule,
157 InputMaskModule,
158 KeyFilterModule,
145 BytesPipe, 159 BytesPipe,
146 KeysPipe, 160 KeysPipe,
147 161
@@ -151,18 +165,24 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
151 VideoThumbnailComponent, 165 VideoThumbnailComponent,
152 VideoMiniatureComponent, 166 VideoMiniatureComponent,
153 VideoPlaylistMiniatureComponent, 167 VideoPlaylistMiniatureComponent,
168 VideoAddToPlaylistComponent,
154 169
155 FeedComponent, 170 FeedComponent,
171
156 ButtonComponent, 172 ButtonComponent,
157 DeleteButtonComponent, 173 DeleteButtonComponent,
158 EditButtonComponent, 174 EditButtonComponent,
175
159 ActionDropdownComponent, 176 ActionDropdownComponent,
160 MarkdownTextareaComponent, 177 MarkdownTextareaComponent,
161 InfiniteScrollerDirective, 178 InfiniteScrollerDirective,
162 TextareaAutoResizeDirective, 179 TextareaAutoResizeDirective,
163 HelpComponent, 180 HelpComponent,
181
164 ReactiveFileComponent, 182 ReactiveFileComponent,
165 PeertubeCheckboxComponent, 183 PeertubeCheckboxComponent,
184 TimestampInputComponent,
185
166 SubscribeButtonComponent, 186 SubscribeButtonComponent,
167 RemoteSubscribeComponent, 187 RemoteSubscribeComponent,
168 InstanceFeaturesTableComponent, 188 InstanceFeaturesTableComponent,
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts
index 8f1754c7f..ef470ee44 100644
--- a/client/src/app/shared/user-subscription/subscribe-button.component.ts
+++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts
@@ -38,7 +38,7 @@ export class SubscribeButtonComponent implements OnInit {
38 38
39 ngOnInit () { 39 ngOnInit () {
40 if (this.isUserLoggedIn()) { 40 if (this.isUserLoggedIn()) {
41 this.userSubscriptionService.isSubscriptionExists(this.uri) 41 this.userSubscriptionService.doesSubscriptionExist(this.uri)
42 .subscribe( 42 .subscribe(
43 res => this.subscribed = res[this.uri], 43 res => this.subscribed = res[this.uri],
44 44
diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts
index 3d05f071e..cfd5b100f 100644
--- a/client/src/app/shared/user-subscription/user-subscription.service.ts
+++ b/client/src/app/shared/user-subscription/user-subscription.service.ts
@@ -28,7 +28,7 @@ export class UserSubscriptionService {
28 this.existsObservable = this.existsSubject.pipe( 28 this.existsObservable = this.existsSubject.pipe(
29 bufferTime(500), 29 bufferTime(500),
30 filter(uris => uris.length !== 0), 30 filter(uris => uris.length !== 0),
31 switchMap(uris => this.areSubscriptionExist(uris)), 31 switchMap(uris => this.doSubscriptionsExist(uris)),
32 share() 32 share()
33 ) 33 )
34 } 34 }
@@ -69,13 +69,13 @@ export class UserSubscriptionService {
69 ) 69 )
70 } 70 }
71 71
72 isSubscriptionExists (nameWithHost: string) { 72 doesSubscriptionExist (nameWithHost: string) {
73 this.existsSubject.next(nameWithHost) 73 this.existsSubject.next(nameWithHost)
74 74
75 return this.existsObservable.pipe(first()) 75 return this.existsObservable.pipe(first())
76 } 76 }
77 77
78 private areSubscriptionExist (uris: string[]): Observable<SubscriptionExistResult> { 78 private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> {
79 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' 79 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
80 let params = new HttpParams() 80 let params = new HttpParams()
81 81
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss
index 315d504c9..88f38d9cf 100644
--- a/client/src/app/shared/users/user-notifications.component.scss
+++ b/client/src/app/shared/users/user-notifications.component.scss
@@ -13,7 +13,7 @@
13 align-items: center; 13 align-items: center;
14 font-size: inherit; 14 font-size: inherit;
15 padding: 15px 5px 15px 10px; 15 padding: 15px 5px 15px 10px;
16 border-bottom: 1px solid rgba(0, 0, 0, 0.10); 16 border-bottom: 1px solid $separator-border-color;
17 17
18 &.unread { 18 &.unread {
19 background-color: rgba(0, 0, 0, 0.05); 19 background-color: rgba(0, 0, 0, 0.05);
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
new file mode 100644
index 000000000..ed3cd8dc5
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
@@ -0,0 +1,74 @@
1<div class="header">
2 <div class="first-row">
3 <div i18n class="title">Save to</div>
4
5 <div i18n class="options" (click)="displayOptions = !displayOptions">
6 <my-global-icon iconName="cog"></my-global-icon>
7
8 Options
9 </div>
10 </div>
11
12 <div class="options-row" *ngIf="displayOptions">
13 <div>
14 <my-peertube-checkbox
15 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
16 i18n-labelText labelText="Start at"
17 ></my-peertube-checkbox>
18
19 <my-timestamp-input
20 [timestamp]="timestampOptions.startTimestamp"
21 [maxTimestamp]="video.duration"
22 [disabled]="!timestampOptions.startTimestampEnabled"
23 [(ngModel)]="timestampOptions.startTimestamp"
24 ></my-timestamp-input>
25 </div>
26
27 <div>
28 <my-peertube-checkbox
29 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
30 i18n-labelText labelText="Stop at"
31 ></my-peertube-checkbox>
32
33 <my-timestamp-input
34 [timestamp]="timestampOptions.stopTimestamp"
35 [maxTimestamp]="video.duration"
36 [disabled]="!timestampOptions.stopTimestampEnabled"
37 [(ngModel)]="timestampOptions.stopTimestamp"
38 ></my-timestamp-input>
39 </div>
40 </div>
41</div>
42
43<div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
44 <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist"></my-peertube-checkbox>
45
46 <div class="display-name">
47 {{ playlist.displayName }}
48
49 <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
50 {{ formatTimestamp(playlist) }}
51 </div>
52 </div>
53</div>
54
55<div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
56 <my-global-icon iconName="add"></my-global-icon>
57
58 Create a new playlist
59</div>
60
61<form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
62 <div class="form-group">
63 <label i18n for="display-name">Display name</label>
64 <input
65 type="text" id="display-name"
66 formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
67 >
68 <div *ngIf="formErrors['display-name']" class="form-error">
69 {{ formErrors['display-name'] }}
70 </div>
71 </div>
72
73 <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
74</form>
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
new file mode 100644
index 000000000..68dcda1eb
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
@@ -0,0 +1,98 @@
1@import '_variables';
2@import '_mixins';
3
4.header {
5 min-width: 240px;
6 padding: 6px 24px 10px 24px;
7
8 margin-bottom: 10px;
9 border-bottom: 1px solid $separator-border-color;
10
11 .first-row {
12 display: flex;
13 align-items: center;
14
15 .title {
16 font-size: 18px;
17 flex-grow: 1;
18 }
19
20 .options {
21 font-size: 14px;
22 cursor: pointer;
23
24 my-global-icon {
25 @include apply-svg-color(#333);
26
27 width: 16px;
28 height: 16px;
29 }
30 }
31 }
32
33 .options-row {
34 margin-top: 10px;
35
36 > div {
37 display: flex;
38 align-items: center;
39 }
40 }
41}
42
43.dropdown-item {
44 padding: 6px 24px;
45}
46
47.playlist {
48 display: flex;
49 cursor: pointer;
50
51 my-peertube-checkbox {
52 margin-right: 10px;
53 }
54
55 .display-name {
56 display: flex;
57 align-items: flex-end;
58
59 .timestamp-info {
60 font-size: 0.9em;
61 color: $grey-foreground-color;
62 margin-left: 5px;
63 }
64 }
65}
66
67.new-playlist-button,
68.new-playlist-block {
69 padding-top: 10px;
70 margin-top: 10px;
71 border-top: 1px solid $separator-border-color;
72}
73
74.new-playlist-button {
75 cursor: pointer;
76
77 my-global-icon {
78 @include apply-svg-color(#333);
79
80 position: relative;
81 left: -1px;
82 top: -1px;
83 margin-right: 4px;
84 width: 21px;
85 height: 21px;
86 }
87}
88
89input[type=text] {
90 @include peertube-input-text(200px);
91
92 display: block;
93}
94
95input[type=submit] {
96 @include peertube-button;
97 @include orange-button;
98}
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
new file mode 100644
index 000000000..c6fb6dbed
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
@@ -0,0 +1,195 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
3import { AuthService, Notifier } from '@app/core'
4import { forkJoin } from 'rxjs'
5import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
6import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { secondsToTime, timeToInt } from '../../../assets/player/utils'
9
10type PlaylistSummary = {
11 id: number
12 inPlaylist: boolean
13 displayName: string
14
15 startTimestamp?: number
16 stopTimestamp?: number
17}
18
19@Component({
20 selector: 'my-video-add-to-playlist',
21 styleUrls: [ './video-add-to-playlist.component.scss' ],
22 templateUrl: './video-add-to-playlist.component.html'
23})
24export class VideoAddToPlaylistComponent extends FormReactive implements OnInit {
25 @Input() video: Video
26 @Input() currentVideoTimestamp: number
27
28 isNewPlaylistBlockOpened = false
29 videoPlaylists: PlaylistSummary[] = []
30 timestampOptions: {
31 startTimestampEnabled: boolean
32 startTimestamp: number
33 stopTimestampEnabled: boolean
34 stopTimestamp: number
35 }
36 displayOptions = false
37
38 constructor (
39 protected formValidatorService: FormValidatorService,
40 private authService: AuthService,
41 private notifier: Notifier,
42 private i18n: I18n,
43 private videoPlaylistService: VideoPlaylistService,
44 private videoPlaylistValidatorsService: VideoPlaylistValidatorsService
45 ) {
46 super()
47 }
48
49 get user () {
50 return this.authService.getUser()
51 }
52
53 ngOnInit () {
54 this.resetOptions(true)
55
56 this.buildForm({
57 'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
58 })
59
60 forkJoin([
61 this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt'),
62 this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id)
63 ])
64 .subscribe(
65 ([ playlistsResult, existResult ]) => {
66 for (const playlist of playlistsResult.data) {
67 const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id)
68
69 this.videoPlaylists.push({
70 id: playlist.id,
71 displayName: playlist.displayName,
72 inPlaylist: !!existingPlaylist,
73 startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
74 stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
75 })
76 }
77 }
78 )
79 }
80
81 openChange (opened: boolean) {
82 if (opened === false) {
83 this.isNewPlaylistBlockOpened = false
84 this.displayOptions = false
85 }
86 }
87
88 openCreateBlock (event: Event) {
89 event.preventDefault()
90
91 this.isNewPlaylistBlockOpened = true
92 }
93
94 togglePlaylist (event: Event, playlist: PlaylistSummary) {
95 event.preventDefault()
96
97 if (playlist.inPlaylist === true) {
98 this.removeVideoFromPlaylist(playlist)
99 } else {
100 this.addVideoInPlaylist(playlist)
101 }
102
103 playlist.inPlaylist = !playlist.inPlaylist
104 this.resetOptions()
105 }
106
107 createPlaylist () {
108 const displayName = this.form.value[ 'display-name' ]
109
110 const videoPlaylistCreate: VideoPlaylistCreate = {
111 displayName,
112 privacy: VideoPlaylistPrivacy.PRIVATE
113 }
114
115 this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
116 res => {
117 this.videoPlaylists.push({
118 id: res.videoPlaylist.id,
119 displayName,
120 inPlaylist: false
121 })
122
123 this.isNewPlaylistBlockOpened = false
124 },
125
126 err => this.notifier.error(err.message)
127 )
128 }
129
130 resetOptions (resetTimestamp = false) {
131 this.displayOptions = false
132
133 this.timestampOptions = {} as any
134 this.timestampOptions.startTimestampEnabled = false
135 this.timestampOptions.stopTimestampEnabled = false
136
137 if (resetTimestamp) {
138 this.timestampOptions.startTimestamp = 0
139 this.timestampOptions.stopTimestamp = this.video.duration
140 }
141 }
142
143 formatTimestamp (playlist: PlaylistSummary) {
144 const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
145 const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
146
147 return `(${start}-${stop})`
148 }
149
150 private removeVideoFromPlaylist (playlist: PlaylistSummary) {
151 this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.video.id)
152 .subscribe(
153 () => {
154 this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
155
156 playlist.inPlaylist = false
157 },
158
159 err => {
160 this.notifier.error(err.message)
161
162 playlist.inPlaylist = true
163 }
164 )
165 }
166
167 private addVideoInPlaylist (playlist: PlaylistSummary) {
168 const body: VideoPlaylistElementCreate = { videoId: this.video.id }
169
170 if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
171 if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
172
173 this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
174 .subscribe(
175 () => {
176 playlist.inPlaylist = true
177
178 playlist.startTimestamp = body.startTimestamp
179 playlist.stopTimestamp = body.stopTimestamp
180
181 const message = body.startTimestamp || body.stopTimestamp
182 ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
183 : this.i18n('Video added in {{n}}', { n: playlist.displayName })
184
185 this.notifier.success(message)
186 },
187
188 err => {
189 this.notifier.error(err.message)
190
191 playlist.inPlaylist = false
192 }
193 )
194 }
195}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
index 1a39f5fe5..a136f9233 100644
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
@@ -1,6 +1,6 @@
1<div class="miniature"> 1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
2 <a 2 <a
3 [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName" 3 [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName"
4 class="miniature-thumbnail" 4 class="miniature-thumbnail"
5 > 5 >
6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> 6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
@@ -15,7 +15,7 @@
15 </a> 15 </a>
16 16
17 <div class="miniature-bottom"> 17 <div class="miniature-bottom">
18 <a tabindex="-1" class="miniature-name" [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName"> 18 <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName">
19 {{ playlist.displayName }} 19 {{ playlist.displayName }}
20 </a> 20 </a>
21 </div> 21 </div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
index a47206577..f8cd47f73 100644
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
@@ -5,6 +5,17 @@
5.miniature { 5.miniature {
6 display: inline-block; 6 display: inline-block;
7 7
8 &.no-videos:not(.to-manage){
9 a {
10 cursor: default !important;
11 }
12 }
13
14 &.to-manage .play-overlay,
15 &.no-videos {
16 display: none;
17 }
18
8 .miniature-thumbnail { 19 .miniature-thumbnail {
9 @include miniature-thumbnail; 20 @include miniature-thumbnail;
10 21
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
index b3bba7c87..cb5803400 100644
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
@@ -8,4 +8,12 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
8}) 8})
9export class VideoPlaylistMiniatureComponent { 9export class VideoPlaylistMiniatureComponent {
10 @Input() playlist: VideoPlaylist 10 @Input() playlist: VideoPlaylist
11 @Input() toManage = false
12
13 getPlaylistUrl () {
14 if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
15 if (this.playlist.videosLength === 0) return null
16
17 return [ '/videos/watch/playlist', this.playlist.uuid ]
18 }
11} 19}
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
index 9d0b02789..ec8013e89 100644
--- a/client/src/app/shared/video-playlist/video-playlist.model.ts
+++ b/client/src/app/shared/video-playlist/video-playlist.model.ts
@@ -46,6 +46,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
46 this.isLocal = hash.isLocal 46 this.isLocal = hash.isLocal
47 47
48 this.displayName = hash.displayName 48 this.displayName = hash.displayName
49
49 this.description = hash.description 50 this.description = hash.description
50 this.privacy = hash.privacy 51 this.privacy = hash.privacy
51 52
@@ -70,5 +71,9 @@ export class VideoPlaylist implements ServerVideoPlaylist {
70 } 71 }
71 72
72 this.privacy.label = peertubeTranslate(this.privacy.label, translations) 73 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
74
75 if (this.type.id === VideoPlaylistType.WATCH_LATER) {
76 this.displayName = peertubeTranslate(this.displayName, translations)
77 }
73 } 78 }
74} 79}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
index 8b66e122c..f7b37f83a 100644
--- a/client/src/app/shared/video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/video-playlist/video-playlist.service.ts
@@ -1,9 +1,9 @@
1import { catchError, map, switchMap } from 'rxjs/operators' 1import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators'
2import { Injectable } from '@angular/core' 2import { Injectable } from '@angular/core'
3import { Observable } from 'rxjs' 3import { Observable, ReplaySubject, Subject } from 'rxjs'
4import { RestExtractor } from '../rest/rest-extractor.service' 4import { RestExtractor } from '../rest/rest-extractor.service'
5import { HttpClient } from '@angular/common/http' 5import { HttpClient, HttpParams } from '@angular/common/http'
6import { ResultList } from '../../../../../shared' 6import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
7import { environment } from '../../../environments/environment' 7import { environment } from '../../../environments/environment'
8import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' 8import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
@@ -15,16 +15,31 @@ import { ServerService } from '@app/core'
15import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 15import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
16import { AccountService } from '@app/shared/account/account.service' 16import { AccountService } from '@app/shared/account/account.service'
17import { Account } from '@app/shared/account/account.model' 17import { Account } from '@app/shared/account/account.model'
18import { RestService } from '@app/shared/rest'
19import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
18 20
19@Injectable() 21@Injectable()
20export class VideoPlaylistService { 22export class VideoPlaylistService {
21 static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' 23 static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
24 static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
25
26 // Use a replay subject because we "next" a value before subscribing
27 private videoExistsInPlaylistSubject: Subject<number> = new ReplaySubject(1)
28 private readonly videoExistsInPlaylistObservable: Observable<VideoExistInPlaylist>
22 29
23 constructor ( 30 constructor (
24 private authHttp: HttpClient, 31 private authHttp: HttpClient,
25 private serverService: ServerService, 32 private serverService: ServerService,
26 private restExtractor: RestExtractor 33 private restExtractor: RestExtractor,
27 ) { } 34 private restService: RestService
35 ) {
36 this.videoExistsInPlaylistObservable = this.videoExistsInPlaylistSubject.pipe(
37 bufferTime(500),
38 filter(videoIds => videoIds.length !== 0),
39 switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
40 share()
41 )
42 }
28 43
29 listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> { 44 listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> {
30 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' 45 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
@@ -36,10 +51,13 @@ export class VideoPlaylistService {
36 ) 51 )
37 } 52 }
38 53
39 listAccountPlaylists (account: Account): Observable<ResultList<VideoPlaylist>> { 54 listAccountPlaylists (account: Account, sort: string): Observable<ResultList<VideoPlaylist>> {
40 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' 55 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
41 56
42 return this.authHttp.get<ResultList<VideoPlaylist>>(url) 57 let params = new HttpParams()
58 params = this.restService.addRestGetParams(params, undefined, sort)
59
60 return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
43 .pipe( 61 .pipe(
44 switchMap(res => this.extractPlaylists(res)), 62 switchMap(res => this.extractPlaylists(res)),
45 catchError(err => this.restExtractor.handleError(err)) 63 catchError(err => this.restExtractor.handleError(err))
@@ -59,9 +77,8 @@ export class VideoPlaylistService {
59 createVideoPlaylist (body: VideoPlaylistCreate) { 77 createVideoPlaylist (body: VideoPlaylistCreate) {
60 const data = objectToFormData(body) 78 const data = objectToFormData(body)
61 79
62 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) 80 return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
63 .pipe( 81 .pipe(
64 map(this.restExtractor.extractDataBool),
65 catchError(err => this.restExtractor.handleError(err)) 82 catchError(err => this.restExtractor.handleError(err))
66 ) 83 )
67 } 84 }
@@ -84,6 +101,36 @@ export class VideoPlaylistService {
84 ) 101 )
85 } 102 }
86 103
104 addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
105 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos', body)
106 .pipe(
107 map(this.restExtractor.extractDataBool),
108 catchError(err => this.restExtractor.handleError(err))
109 )
110 }
111
112 updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) {
113 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body)
114 .pipe(
115 map(this.restExtractor.extractDataBool),
116 catchError(err => this.restExtractor.handleError(err))
117 )
118 }
119
120 removeVideoFromPlaylist (playlistId: number, videoId: number) {
121 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId)
122 .pipe(
123 map(this.restExtractor.extractDataBool),
124 catchError(err => this.restExtractor.handleError(err))
125 )
126 }
127
128 doesVideoExistInPlaylist (videoId: number) {
129 this.videoExistsInPlaylistSubject.next(videoId)
130
131 return this.videoExistsInPlaylistObservable.pipe(first())
132 }
133
87 extractPlaylists (result: ResultList<VideoPlaylistServerModel>) { 134 extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
88 return this.serverService.localeObservable 135 return this.serverService.localeObservable
89 .pipe( 136 .pipe(
@@ -105,4 +152,14 @@ export class VideoPlaylistService {
105 return this.serverService.localeObservable 152 return this.serverService.localeObservable
106 .pipe(map(translations => new VideoPlaylist(playlist, translations))) 153 .pipe(map(translations => new VideoPlaylist(playlist, translations)))
107 } 154 }
155
156 private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> {
157 const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
158 let params = new HttpParams()
159
160 params = this.restService.addObjectParams(params, { videoIds })
161
162 return this.authHttp.get<VideoExistInPlaylist>(url, { params })
163 .pipe(catchError(err => this.restExtractor.handleError(err)))
164 }
108} 165}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 960846e21..ef489648c 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -31,6 +31,8 @@ import { ServerService } from '@app/core'
31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' 31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill' 33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
35import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
34 36
35export interface VideosProvider { 37export interface VideosProvider {
36 getVideos ( 38 getVideos (
@@ -170,6 +172,23 @@ export class VideoService implements VideosProvider {
170 ) 172 )
171 } 173 }
172 174
175 getPlaylistVideos (
176 videoPlaylistId: number | string,
177 videoPagination: ComponentPagination
178 ): Observable<{ videos: Video[], totalVideos: number }> {
179 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
180
181 let params = new HttpParams()
182 params = this.restService.addRestGetParams(params, pagination)
183
184 return this.authHttp
185 .get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params })
186 .pipe(
187 switchMap(res => this.extractVideos(res)),
188 catchError(err => this.restExtractor.handleError(err))
189 )
190 }
191
173 getUserSubscriptionVideos ( 192 getUserSubscriptionVideos (
174 videoPagination: ComponentPagination, 193 videoPagination: ComponentPagination,
175 sort: VideoSortField 194 sort: VideoSortField
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html
index 9f3c37fe8..955b2b80c 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.html
@@ -6,11 +6,19 @@
6 6
7 <div class="modal-body"> 7 <div class="modal-body">
8 8
9 <div *ngIf="currentVideoTimestampString" class="start-at"> 9 <div class="start-at">
10 <my-peertube-checkbox 10 <my-peertube-checkbox
11 inputName="startAt" [(ngModel)]="startAtCheckbox" 11 inputName="startAt" [(ngModel)]="startAtCheckbox"
12 i18n-labelText [labelText]="getStartCheckboxLabel()" 12 i18n-labelText labelText="Start at"
13 ></my-peertube-checkbox> 13 ></my-peertube-checkbox>
14
15 <my-timestamp-input
16 [timestamp]="currentVideoTimestamp"
17 [maxTimestamp]="video.duration"
18 [disabled]="!startAtCheckbox"
19 [(ngModel)]="currentVideoTimestamp"
20 >
21 </my-timestamp-input>
14 </div> 22 </div>
15 23
16 <div class="form-group"> 24 <div class="form-group">
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss
index 4937506b9..472a45920 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.scss
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.scss
@@ -13,4 +13,9 @@
13 display: flex; 13 display: flex;
14 justify-content: center; 14 justify-content: center;
15 margin-top: 10px; 15 margin-top: 10px;
16 align-items: center;
17
18 my-timestamp-input {
19 margin-left: 10px;
20 }
16} 21}
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts
index c6205e355..6565d7f88 100644
--- a/client/src/app/videos/+video-watch/modal/video-share.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts
@@ -16,10 +16,8 @@ export class VideoShareComponent {
16 16
17 @Input() video: VideoDetails = null 17 @Input() video: VideoDetails = null
18 18
19 currentVideoTimestamp: number
19 startAtCheckbox = false 20 startAtCheckbox = false
20 currentVideoTimestampString: string
21
22 private currentVideoTimestamp: number
23 21
24 constructor ( 22 constructor (
25 private modalService: NgbModal, 23 private modalService: NgbModal,
@@ -28,8 +26,7 @@ export class VideoShareComponent {
28 ) { } 26 ) { }
29 27
30 show (currentVideoTimestamp?: number) { 28 show (currentVideoTimestamp?: number) {
31 this.currentVideoTimestamp = Math.floor(currentVideoTimestamp) 29 this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0
32 this.currentVideoTimestampString = durationToString(this.currentVideoTimestamp)
33 30
34 this.modalService.open(this.modal) 31 this.modalService.open(this.modal)
35 } 32 }
@@ -52,10 +49,6 @@ export class VideoShareComponent {
52 this.notifier.success(this.i18n('Copied')) 49 this.notifier.success(this.i18n('Copied'))
53 } 50 }
54 51
55 getStartCheckboxLabel () {
56 return this.i18n('Start at {{timestamp}}', { timestamp: this.currentVideoTimestampString })
57 }
58
59 private getVideoTimestampIfEnabled () { 52 private getVideoTimestampIfEnabled () {
60 if (this.startAtCheckbox === true) return this.currentVideoTimestamp 53 if (this.startAtCheckbox === true) return this.currentVideoTimestamp
61 54
diff --git a/client/src/app/videos/+video-watch/video-watch-routing.module.ts b/client/src/app/videos/+video-watch/video-watch-routing.module.ts
index bdd4f945e..0d7809044 100644
--- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts
+++ b/client/src/app/videos/+video-watch/video-watch-routing.module.ts
@@ -7,7 +7,16 @@ import { VideoWatchComponent } from './video-watch.component'
7 7
8const videoWatchRoutes: Routes = [ 8const videoWatchRoutes: Routes = [
9 { 9 {
10 path: '', 10 path: 'playlist/:uuid',
11 component: VideoWatchComponent,
12 canActivate: [ MetaGuard ]
13 },
14 {
15 path: ':uuid/comments/:commentId',
16 redirectTo: ':uuid'
17 },
18 {
19 path: ':uuid',
11 component: VideoWatchComponent, 20 component: VideoWatchComponent,
12 canActivate: [ MetaGuard ] 21 canActivate: [ MetaGuard ]
13 } 22 }
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index fffcc1275..615b88bd6 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -65,17 +65,31 @@
65 <my-global-icon iconName="dislike"></my-global-icon> 65 <my-global-icon iconName="dislike"></my-global-icon>
66 </div> 66 </div>
67 67
68 <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support"> 68 <div *ngIf="video.support" (click)="showSupportModal()" class="action-button">
69 <my-global-icon iconName="heart"></my-global-icon> 69 <my-global-icon iconName="heart"></my-global-icon>
70 <span class="icon-text" i18n>Support</span> 70 <span class="icon-text" i18n>Support</span>
71 </div> 71 </div>
72 72
73 <div (click)="showShareModal()" class="action-button action-button-share" role="button"> 73 <div (click)="showShareModal()" class="action-button" role="button">
74 <my-global-icon iconName="share"></my-global-icon> 74 <my-global-icon iconName="share"></my-global-icon>
75 <span class="icon-text" i18n>Share</span> 75 <span class="icon-text" i18n>Share</span>
76 </div> 76 </div>
77 77
78 <div class="action-more" ngbDropdown placement="top" role="button"> 78 <div
79 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
80 *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)"
81 >
82 <div class="action-button action-button-save" ngbDropdownToggle role="button">
83 <my-global-icon iconName="playlist-add"></my-global-icon>
84 <span class="icon-text" i18n>Save</span>
85 </div>
86
87 <div ngbDropdownMenu>
88 <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist>
89 </div>
90 </div>
91
92 <div class="action-dropdown" ngbDropdown placement="top" role="button">
79 <div class="action-button" ngbDropdownToggle role="button"> 93 <div class="action-button" ngbDropdownToggle role="button">
80 <my-global-icon class="more-icon" iconName="more"></my-global-icon> 94 <my-global-icon class="more-icon" iconName="more"></my-global-icon>
81 </div> 95 </div>
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index 33d77e62c..ff321fdbc 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -176,7 +176,7 @@ $other-videos-width: 260px;
176 display: flex; 176 display: flex;
177 align-items: center; 177 align-items: center;
178 178
179 .action-button:not(:first-child), .action-more { 179 .action-button:not(:first-child), .action-dropdown {
180 margin-left: 10px; 180 margin-left: 10px;
181 } 181 }
182 182
@@ -212,12 +212,19 @@ $other-videos-width: 260px;
212 } 212 }
213 } 213 }
214 214
215 &.action-button-save {
216 my-global-icon {
217 top: 0 !important;
218 right: -1px;
219 }
220 }
221
215 .icon-text { 222 .icon-text {
216 margin-left: 3px; 223 margin-left: 3px;
217 } 224 }
218 } 225 }
219 226
220 .action-more { 227 .action-dropdown {
221 display: inline-block; 228 display: inline-block;
222 229
223 .dropdown-menu .dropdown-item { 230 .dropdown-menu .dropdown-item {
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index 0f04441ba..359217f3b 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -59,6 +59,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
59 remoteServerDown = false 59 remoteServerDown = false
60 hotkeys: Hotkey[] 60 hotkeys: Hotkey[]
61 61
62 private currentTime: number
62 private paramsSub: Subscription 63 private paramsSub: Subscription
63 64
64 constructor ( 65 constructor (
@@ -114,10 +115,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
114 ) 115 )
115 .subscribe(([ video, captionsResult ]) => { 116 .subscribe(([ video, captionsResult ]) => {
116 const startTime = this.route.snapshot.queryParams.start 117 const startTime = this.route.snapshot.queryParams.start
118 const stopTime = this.route.snapshot.queryParams.stop
117 const subtitle = this.route.snapshot.queryParams.subtitle 119 const subtitle = this.route.snapshot.queryParams.subtitle
118 const playerMode = this.route.snapshot.queryParams.mode 120 const playerMode = this.route.snapshot.queryParams.mode
119 121
120 this.onVideoFetched(video, captionsResult.data, { startTime, subtitle, playerMode }) 122 this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
121 .catch(err => this.handleError(err)) 123 .catch(err => this.handleError(err))
122 }) 124 })
123 }) 125 })
@@ -219,7 +221,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
219 showShareModal () { 221 showShareModal () {
220 const currentTime = this.player ? this.player.currentTime() : undefined 222 const currentTime = this.player ? this.player.currentTime() : undefined
221 223
222 this.videoShareModal.show(currentTime) 224 this.videoShareModal.show(this.currentTime)
223 } 225 }
224 226
225 showDownloadModal (event: Event) { 227 showDownloadModal (event: Event) {
@@ -371,7 +373,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
371 private async onVideoFetched ( 373 private async onVideoFetched (
372 video: VideoDetails, 374 video: VideoDetails,
373 videoCaptions: VideoCaption[], 375 videoCaptions: VideoCaption[],
374 urlOptions: { startTime?: number, subtitle?: string, playerMode?: string } 376 urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string }
375 ) { 377 ) {
376 this.video = video 378 this.video = video
377 379
@@ -379,6 +381,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
379 this.descriptionLoading = false 381 this.descriptionLoading = false
380 this.completeDescriptionShown = false 382 this.completeDescriptionShown = false
381 this.remoteServerDown = false 383 this.remoteServerDown = false
384 this.currentTime = undefined
382 385
383 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) 386 let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
384 // If we are at the end of the video, reset the timer 387 // If we are at the end of the video, reset the timer
@@ -420,6 +423,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
420 inactivityTimeout: 2500, 423 inactivityTimeout: 2500,
421 poster: this.video.previewUrl, 424 poster: this.video.previewUrl,
422 startTime, 425 startTime,
426 stopTime: urlOptions.stopTime,
423 427
424 theaterMode: true, 428 theaterMode: true,
425 captions: videoCaptions.length !== 0, 429 captions: videoCaptions.length !== 0,
@@ -466,6 +470,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
466 this.zone.runOutsideAngular(async () => { 470 this.zone.runOutsideAngular(async () => {
467 this.player = await PeertubePlayerManager.initialize(mode, options) 471 this.player = await PeertubePlayerManager.initialize(mode, options)
468 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) 472 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
473
474 this.player.on('timeupdate', () => {
475 this.currentTime = Math.floor(this.player.currentTime())
476 })
469 }) 477 })
470 478
471 this.setVideoDescriptionHTML() 479 this.setVideoDescriptionHTML()
diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts
index 58988ffd1..69a9232ce 100644
--- a/client/src/app/videos/videos-routing.module.ts
+++ b/client/src/app/videos/videos-routing.module.ts
@@ -78,11 +78,7 @@ const videosRoutes: Routes = [
78 } 78 }
79 }, 79 },
80 { 80 {
81 path: 'watch/:uuid/comments/:commentId', 81 path: 'watch',
82 redirectTo: 'watch/:uuid'
83 },
84 {
85 path: 'watch/:uuid',
86 loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule', 82 loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule',
87 data: { 83 data: {
88 preload: 3000 84 preload: 3000
diff --git a/client/src/assets/images/global/add.html b/client/src/assets/images/global/add.html
index bfb0a52bc..34f497056 100644
--- a/client/src/assets/images/global/add.html
+++ b/client/src/assets/images/global/add.html
@@ -2,9 +2,9 @@
2 <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> 2 <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
3 <g transform="translate(-92.000000, -115.000000)"> 3 <g transform="translate(-92.000000, -115.000000)">
4 <g id="2" transform="translate(92.000000, 115.000000)"> 4 <g id="2" transform="translate(92.000000, 115.000000)">
5 <circle id="Oval-1" stroke="#ffffff" stroke-width="2" cx="12" cy="12" r="10"></circle> 5 <circle id="Oval-1" stroke="#000000" stroke-width="2" cx="12" cy="12" r="10"></circle>
6 <rect id="Rectangle-1" fill="#ffffff" x="11" y="7" width="2" height="10" rx="1"></rect> 6 <rect id="Rectangle-1" fill="#000000" x="11" y="7" width="2" height="10" rx="1"></rect>
7 <rect id="Rectangle-1" fill="#ffffff" x="7" y="11" width="10" height="2" rx="1"></rect> 7 <rect id="Rectangle-1" fill="#000000" x="7" y="11" width="10" height="2" rx="1"></rect>
8 </g> 8 </g>
9 </g> 9 </g>
10 </g> 10 </g>
diff --git a/client/src/assets/images/video/playlist-add.html b/client/src/assets/images/video/playlist-add.html
new file mode 100644
index 000000000..ada845c75
--- /dev/null
+++ b/client/src/assets/images/video/playlist-add.html
@@ -0,0 +1,10 @@
1<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
2 viewBox="0 0 426.667 426.667" xml:space="preserve">
3 <g fill="#000000">
4 <rect x="0" y="64" width="256" height="42.667"/>
5 <rect x="0" y="149.333" width="256" height="42.667"/>
6 <rect x="0" y="234.667" width="170.667" height="42.667"/>
7 <polygon points="341.333,234.667 341.333,149.333 298.667,149.333 298.667,234.667 213.333,234.667 213.333,277.333
8 298.667,277.333 298.667,362.667 341.333,362.667 341.333,277.333 426.667,277.333 426.667,234.667 "/>
9 </g>
10</svg>
diff --git a/client/src/assets/images/video/watch-later.html b/client/src/assets/images/video/watch-later.html
new file mode 100644
index 000000000..927afebe4
--- /dev/null
+++ b/client/src/assets/images/video/watch-later.html
@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 80 100"
2 enable-background="new 0 0 80 80" xml:space="preserve"><g><path fill="#000000" d="M33.3,51.5L33.3,51.5c-1.8,0-3.3-1.4-3.3-3.1V37.3c0-1.7,1.5-3.1,3.3-3.1c0.5,0,1,0.1,1.5,0.4l10.7,5.5 c1,0.5,1.6,1.5,1.6,2.7c0,1.2-0.6,2.2-1.7,2.8l-10.6,5.6C34.3,51.3,33.8,51.5,33.3,51.5z M33.3,36.2c-0.6,0-1.3,0.4-1.3,1.1v11.1 c0,0.6,0.7,1.1,1.3,1.1l0,0c0.2,0,0.4,0,0.5-0.1l10.6-5.6c0.4-0.2,0.6-0.6,0.6-1c0-0.2-0.1-0.6-0.5-0.9l-10.7-5.5 C33.6,36.2,33.4,36.2,33.3,36.2z"/></g>
3 <g><path fill="#000000" d="M62.9,65H12.1C10.4,65,9,63.6,9,61.9V22.1c0-1.7,1.4-3.1,3.1-3.1h50.8c1.7,0,3.1,1.4,3.1,3.1v39.8 C66,63.6,64.6,65,62.9,65z M12.1,21c-0.6,0-1.1,0.5-1.1,1.1v39.8c0,0.6,0.5,1.1,1.1,1.1h50.8c0.6,0,1.1-0.5,1.1-1.1V22.1 c0-0.6-0.5-1.1-1.1-1.1H12.1z"/></g>
4 <g><path fill="#000000" d="M63,16h-2c0-1-0.4-1-0.9-1H14.9c-0.5,0-0.9,0-0.9,1h-2c0-2,1.3-3,2.9-3h45.3C61.7,13,63,14,63,16z"/></g>
5 <g><path fill="#000000" d="M58,11h-2c0-1-0.4-1-0.5-1H19.5c-0.1,0-0.5,0-0.5,1h-2c0-2,1.1-3,2.5-3h36.1C56.9,8,58,9,58,11z"/></g>
6 <g><path fill="#000000" d="M68,29v-2c4,0,6.5-2.9,6.5-6.5S72,14,68,14v-2c5,0,8.5,3.8,8.5,8.5S73,29,68,29z"/></g>
7 <g><polygon fill="#000000" points="71.3,18.7 65.6,13 71.3,7.3 72.7,8.7 68.4,13 72.7,17.3 "/></g>
8 <text x="0" y="95" fill="#000000" font-size="5px" font-weight="bold"
9 font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Yaroslav Samoylov</text>
10 <text x="0" y="100" fill="#000000" font-size="5px" font-weight="bold"
11 font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 7631d095f..6cdd54372 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -49,6 +49,7 @@ export type CommonOptions = {
49 inactivityTimeout: number 49 inactivityTimeout: number
50 poster: string 50 poster: string
51 startTime: number | string 51 startTime: number | string
52 stopTime: number | string
52 53
53 theaterMode: boolean 54 theaterMode: boolean
54 captions: boolean 55 captions: boolean
@@ -199,10 +200,10 @@ export class PeertubePlayerManager {
199 autoplay, // Use peertube plugin autoplay because we get the file by webtorrent 200 autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
200 videoViewUrl: commonOptions.videoViewUrl, 201 videoViewUrl: commonOptions.videoViewUrl,
201 videoDuration: commonOptions.videoDuration, 202 videoDuration: commonOptions.videoDuration,
202 startTime: commonOptions.startTime,
203 userWatching: commonOptions.userWatching, 203 userWatching: commonOptions.userWatching,
204 subtitle: commonOptions.subtitle, 204 subtitle: commonOptions.subtitle,
205 videoCaptions: commonOptions.videoCaptions 205 videoCaptions: commonOptions.videoCaptions,
206 stopTime: commonOptions.stopTime
206 } 207 }
207 } 208 }
208 209
@@ -210,6 +211,7 @@ export class PeertubePlayerManager {
210 const p2pMediaLoader: P2PMediaLoaderPluginOptions = { 211 const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
211 redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, 212 redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls,
212 type: 'application/x-mpegURL', 213 type: 'application/x-mpegURL',
214 startTime: commonOptions.startTime,
213 src: p2pMediaLoaderOptions.playlistUrl 215 src: p2pMediaLoaderOptions.playlistUrl
214 } 216 }
215 217
@@ -254,7 +256,8 @@ export class PeertubePlayerManager {
254 autoplay, 256 autoplay,
255 videoDuration: commonOptions.videoDuration, 257 videoDuration: commonOptions.videoDuration,
256 playerElement: commonOptions.playerElement, 258 playerElement: commonOptions.playerElement,
257 videoFiles: webtorrentOptions.videoFiles 259 videoFiles: webtorrentOptions.videoFiles,
260 startTime: commonOptions.startTime
258 } 261 }
259 Object.assign(plugins, { webtorrent }) 262 Object.assign(plugins, { webtorrent })
260 263
diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts
index 92ac57cf5..3991e4627 100644
--- a/client/src/assets/player/peertube-plugin.ts
+++ b/client/src/assets/player/peertube-plugin.ts
@@ -22,7 +22,6 @@ import {
22 22
23const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') 23const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
24class PeerTubePlugin extends Plugin { 24class PeerTubePlugin extends Plugin {
25 private readonly startTime: number = 0
26 private readonly videoViewUrl: string 25 private readonly videoViewUrl: string
27 private readonly videoDuration: number 26 private readonly videoDuration: number
28 private readonly CONSTANTS = { 27 private readonly CONSTANTS = {
@@ -35,13 +34,11 @@ class PeerTubePlugin extends Plugin {
35 34
36 private videoViewInterval: any 35 private videoViewInterval: any
37 private userWatchingVideoInterval: any 36 private userWatchingVideoInterval: any
38 private qualityObservationTimer: any
39 private lastResolutionChange: ResolutionUpdateData 37 private lastResolutionChange: ResolutionUpdateData
40 38
41 constructor (player: videojs.Player, options: PeerTubePluginOptions) { 39 constructor (player: videojs.Player, options: PeerTubePluginOptions) {
42 super(player, options) 40 super(player, options)
43 41
44 this.startTime = timeToInt(options.startTime)
45 this.videoViewUrl = options.videoViewUrl 42 this.videoViewUrl = options.videoViewUrl
46 this.videoDuration = options.videoDuration 43 this.videoDuration = options.videoDuration
47 this.videoCaptions = options.videoCaptions 44 this.videoCaptions = options.videoCaptions
@@ -84,6 +81,14 @@ class PeerTubePlugin extends Plugin {
84 saveMuteInStore(this.player.muted()) 81 saveMuteInStore(this.player.muted())
85 }) 82 })
86 83
84 if (options.stopTime) {
85 const stopTime = timeToInt(options.stopTime)
86
87 this.player.on('timeupdate', () => {
88 if (this.player.currentTime() > stopTime) this.player.pause()
89 })
90 }
91
87 this.player.textTracks().on('change', () => { 92 this.player.textTracks().on('change', () => {
88 const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { 93 const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
89 return t.kind === 'captions' && t.mode === 'showing' 94 return t.kind === 'captions' && t.mode === 'showing'
@@ -109,10 +114,7 @@ class PeerTubePlugin extends Plugin {
109 } 114 }
110 115
111 dispose () { 116 dispose () {
112 clearTimeout(this.qualityObservationTimer) 117 if (this.videoViewInterval) clearInterval(this.videoViewInterval)
113
114 clearInterval(this.videoViewInterval)
115
116 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) 118 if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
117 } 119 }
118 120
diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts
index 79a5a6c4d..a96b0bc8c 100644
--- a/client/src/assets/player/peertube-videojs-typings.ts
+++ b/client/src/assets/player/peertube-videojs-typings.ts
@@ -41,12 +41,13 @@ type PeerTubePluginOptions = {
41 autoplay: boolean 41 autoplay: boolean
42 videoViewUrl: string 42 videoViewUrl: string
43 videoDuration: number 43 videoDuration: number
44 startTime: number | string
45 44
46 userWatching?: UserWatching 45 userWatching?: UserWatching
47 subtitle?: string 46 subtitle?: string
48 47
49 videoCaptions: VideoJSCaption[] 48 videoCaptions: VideoJSCaption[]
49
50 stopTime: number | string
50} 51}
51 52
52type WebtorrentPluginOptions = { 53type WebtorrentPluginOptions = {
@@ -56,12 +57,16 @@ type WebtorrentPluginOptions = {
56 videoDuration: number 57 videoDuration: number
57 58
58 videoFiles: VideoFile[] 59 videoFiles: VideoFile[]
60
61 startTime: number | string
59} 62}
60 63
61type P2PMediaLoaderPluginOptions = { 64type P2PMediaLoaderPluginOptions = {
62 redundancyBaseUrls: string[] 65 redundancyBaseUrls: string[]
63 type: string 66 type: string
64 src: string 67 src: string
68
69 startTime: number | string
65} 70}
66 71
67type VideoJSPluginOptions = { 72type VideoJSPluginOptions = {
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts
index 8d87567c2..54f131310 100644
--- a/client/src/assets/player/utils.ts
+++ b/client/src/assets/player/utils.ts
@@ -42,7 +42,7 @@ function timeToInt (time: number | string) {
42 if (!time) return 0 42 if (!time) return 0
43 if (typeof time === 'number') return time 43 if (typeof time === 'number') return time
44 44
45 const reg = /^((\d+)h)?((\d+)m)?((\d+)s?)?$/ 45 const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/
46 const matches = time.match(reg) 46 const matches = time.match(reg)
47 47
48 if (!matches) return 0 48 if (!matches) return 0
@@ -54,18 +54,27 @@ function timeToInt (time: number | string) {
54 return hours * 3600 + minutes * 60 + seconds 54 return hours * 3600 + minutes * 60 + seconds
55} 55}
56 56
57function secondsToTime (seconds: number) { 57function secondsToTime (seconds: number, full = false, symbol?: string) {
58 let time = '' 58 let time = ''
59 59
60 const hourSymbol = (symbol || 'h')
61 const minuteSymbol = (symbol || 'm')
62 const secondsSymbol = full ? '' : 's'
63
60 let hours = Math.floor(seconds / 3600) 64 let hours = Math.floor(seconds / 3600)
61 if (hours >= 1) time = hours + 'h' 65 if (hours >= 1) time = hours + hourSymbol
66 else if (full) time = '0' + hourSymbol
62 67
63 seconds %= 3600 68 seconds %= 3600
64 let minutes = Math.floor(seconds / 60) 69 let minutes = Math.floor(seconds / 60)
65 if (minutes >= 1) time += minutes + 'm' 70 if (minutes >= 1 && minutes < 10 && full) time += '0' + minutes + minuteSymbol
71 else if (minutes >= 1) time += minutes + minuteSymbol
72 else if (full) time += '00' + minuteSymbol
66 73
67 seconds %= 60 74 seconds %= 60
68 if (seconds >= 1) time += seconds + 's' 75 if (seconds >= 1 && seconds < 10 && full) time += '0' + seconds + secondsSymbol
76 else if (seconds >= 1) time += seconds + secondsSymbol
77 else if (full) time += '00'
69 78
70 return time 79 return time
71} 80}
@@ -131,6 +140,7 @@ export {
131 getRtcConfig, 140 getRtcConfig,
132 toTitleCase, 141 toTitleCase,
133 timeToInt, 142 timeToInt,
143 secondsToTime,
134 buildVideoLink, 144 buildVideoLink,
135 buildVideoEmbed, 145 buildVideoEmbed,
136 videoFileMaxByResolution, 146 videoFileMaxByResolution,
diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts
index c69bf31fa..c7182acc9 100644
--- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts
+++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts
@@ -6,7 +6,7 @@ import * as WebTorrent from 'webtorrent'
6import { VideoFile } from '../../../../../shared/models/videos/video.model' 6import { VideoFile } from '../../../../../shared/models/videos/video.model'
7import { renderVideo } from './video-renderer' 7import { renderVideo } from './video-renderer'
8import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' 8import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings'
9import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' 9import { getRtcConfig, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from '../utils'
10import { PeertubeChunkStore } from './peertube-chunk-store' 10import { PeertubeChunkStore } from './peertube-chunk-store'
11import { 11import {
12 getAverageBandwidthInStore, 12 getAverageBandwidthInStore,
@@ -73,6 +73,8 @@ class WebTorrentPlugin extends Plugin {
73 constructor (player: videojs.Player, options: WebtorrentPluginOptions) { 73 constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
74 super(player, options) 74 super(player, options)
75 75
76 this.startTime = timeToInt(options.startTime)
77
76 // Disable auto play on iOS 78 // Disable auto play on iOS
77 this.autoplay = options.autoplay && this.isIOS() === false 79 this.autoplay = options.autoplay && this.isIOS() === false
78 this.playerRefusedP2P = !getStoredWebTorrentEnabled() 80 this.playerRefusedP2P = !getStoredWebTorrentEnabled()
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index 59b2f42a5..3eefdb6fb 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -515,4 +515,3 @@
515 align-items: center; 515 align-items: center;
516 } 516 }
517} 517}
518
diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss
index 56ca4c2d3..deabbf6d4 100644
--- a/client/src/sass/include/_variables.scss
+++ b/client/src/sass/include/_variables.scss
@@ -44,6 +44,8 @@ $footer-margin: 30px;
44 44
45$footer-border-color: $header-border-color; 45$footer-border-color: $header-border-color;
46 46
47$separator-border-color: rgba(0, 0, 0, 0.10);
48
47$video-thumbnail-height: 122px; 49$video-thumbnail-height: 122px;
48$video-thumbnail-width: 223px; 50$video-thumbnail-width: 223px;
49 51
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 32bf42e12..28c10c75c 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -168,6 +168,7 @@ class PeerTubeEmbed {
168 subtitle: string 168 subtitle: string
169 enableApi = false 169 enableApi = false
170 startTime: number | string = 0 170 startTime: number | string = 0
171 stopTime: number | string
171 mode: PlayerMode 172 mode: PlayerMode
172 scope = 'peertube' 173 scope = 'peertube'
173 174
@@ -262,6 +263,7 @@ class PeerTubeEmbed {
262 this.scope = this.getParamString(params, 'scope', this.scope) 263 this.scope = this.getParamString(params, 'scope', this.scope)
263 this.subtitle = this.getParamString(params, 'subtitle') 264 this.subtitle = this.getParamString(params, 'subtitle')
264 this.startTime = this.getParamString(params, 'start') 265 this.startTime = this.getParamString(params, 'start')
266 this.stopTime = this.getParamString(params, 'stop')
265 267
266 this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent' 268 this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent'
267 } catch (err) { 269 } catch (err) {
@@ -306,6 +308,7 @@ class PeerTubeEmbed {
306 loop: this.loop, 308 loop: this.loop,
307 captions: videoCaptions.length !== 0, 309 captions: videoCaptions.length !== 0,
308 startTime: this.startTime, 310 startTime: this.startTime,
311 stopTime: this.stopTime,
309 subtitle: this.subtitle, 312 subtitle: this.subtitle,
310 313
311 videoCaptions, 314 videoCaptions,
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 5758c8227..f7edbddf3 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -38,6 +38,7 @@ import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../h
38import { meRouter } from './me' 38import { meRouter } from './me'
39import { deleteUserToken } from '../../../lib/oauth-model' 39import { deleteUserToken } from '../../../lib/oauth-model'
40import { myBlocklistRouter } from './my-blocklist' 40import { myBlocklistRouter } from './my-blocklist'
41import { myVideoPlaylistsRouter } from './my-video-playlists'
41import { myVideosHistoryRouter } from './my-history' 42import { myVideosHistoryRouter } from './my-history'
42import { myNotificationsRouter } from './my-notifications' 43import { myNotificationsRouter } from './my-notifications'
43import { Notifier } from '../../../lib/notifier' 44import { Notifier } from '../../../lib/notifier'
@@ -60,6 +61,7 @@ usersRouter.use('/', myNotificationsRouter)
60usersRouter.use('/', mySubscriptionsRouter) 61usersRouter.use('/', mySubscriptionsRouter)
61usersRouter.use('/', myBlocklistRouter) 62usersRouter.use('/', myBlocklistRouter)
62usersRouter.use('/', myVideosHistoryRouter) 63usersRouter.use('/', myVideosHistoryRouter)
64usersRouter.use('/', myVideoPlaylistsRouter)
63usersRouter.use('/', meRouter) 65usersRouter.use('/', meRouter)
64 66
65usersRouter.get('/autocomplete', 67usersRouter.get('/autocomplete',
diff --git a/server/controllers/api/users/my-video-playlists.ts b/server/controllers/api/users/my-video-playlists.ts
new file mode 100644
index 000000000..1ec175f64
--- /dev/null
+++ b/server/controllers/api/users/my-video-playlists.ts
@@ -0,0 +1,47 @@
1import * as express from 'express'
2import { asyncMiddleware, authenticate } from '../../../middlewares'
3import { UserModel } from '../../../models/account/user'
4import { doVideosInPlaylistExistValidator } from '../../../middlewares/validators/videos/video-playlists'
5import { VideoPlaylistModel } from '../../../models/video/video-playlist'
6import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model'
7
8const myVideoPlaylistsRouter = express.Router()
9
10myVideoPlaylistsRouter.get('/me/video-playlists/videos-exist',
11 authenticate,
12 doVideosInPlaylistExistValidator,
13 asyncMiddleware(doVideosInPlaylistExist)
14)
15
16// ---------------------------------------------------------------------------
17
18export {
19 myVideoPlaylistsRouter
20}
21
22// ---------------------------------------------------------------------------
23
24async function doVideosInPlaylistExist (req: express.Request, res: express.Response) {
25 const videoIds = req.query.videoIds as number[]
26 const user = res.locals.oauth.token.User as UserModel
27
28 const results = await VideoPlaylistModel.listPlaylistIdsOf(user.Account.id, videoIds)
29
30 const existObject: VideoExistInPlaylist = {}
31
32 for (const videoId of videoIds) {
33 existObject[videoId] = []
34 }
35
36 for (const result of results) {
37 for (const element of result.VideoPlaylistElements) {
38 existObject[element.videoId].push({
39 playlistId: result.id,
40 startTimestamp: element.startTimestamp,
41 stopTimestamp: element.stopTimestamp
42 })
43 }
44 }
45
46 return res.json(existObject)
47}
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index 145764d35..49432d3aa 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -291,23 +291,26 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
291 videoId: video.id 291 videoId: video.id
292 }, { transaction: t }) 292 }, { transaction: t })
293 293
294 // If the user did not set a thumbnail, automatically take the video thumbnail 294 videoPlaylist.updatedAt = new Date()
295 if (playlistElement.position === 1) { 295 await videoPlaylist.save({ transaction: t })
296 const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
297
298 if (await pathExists(playlistThumbnailPath) === false) {
299 logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
300
301 const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
302 await copy(videoThumbnailPath, playlistThumbnailPath)
303 }
304 }
305 296
306 await sendUpdateVideoPlaylist(videoPlaylist, t) 297 await sendUpdateVideoPlaylist(videoPlaylist, t)
307 298
308 return playlistElement 299 return playlistElement
309 }) 300 })
310 301
302 // If the user did not set a thumbnail, automatically take the video thumbnail
303 if (playlistElement.position === 1) {
304 const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
305
306 if (await pathExists(playlistThumbnailPath) === false) {
307 logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
308
309 const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
310 await copy(videoThumbnailPath, playlistThumbnailPath)
311 }
312 }
313
311 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) 314 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
312 315
313 return res.json({ 316 return res.json({
@@ -328,6 +331,9 @@ async function updateVideoPlaylistElement (req: express.Request, res: express.Re
328 331
329 const element = await videoPlaylistElement.save({ transaction: t }) 332 const element = await videoPlaylistElement.save({ transaction: t })
330 333
334 videoPlaylist.updatedAt = new Date()
335 await videoPlaylist.save({ transaction: t })
336
331 await sendUpdateVideoPlaylist(videoPlaylist, t) 337 await sendUpdateVideoPlaylist(videoPlaylist, t)
332 338
333 return element 339 return element
@@ -349,6 +355,9 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
349 // Decrease position of the next elements 355 // Decrease position of the next elements
350 await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t) 356 await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t)
351 357
358 videoPlaylist.updatedAt = new Date()
359 await videoPlaylist.save({ transaction: t })
360
352 await sendUpdateVideoPlaylist(videoPlaylist, t) 361 await sendUpdateVideoPlaylist(videoPlaylist, t)
353 362
354 logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) 363 logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
@@ -390,6 +399,9 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons
390 // Decrease positions of elements after the old position of our ordered elements (decrease) 399 // Decrease positions of elements after the old position of our ordered elements (decrease)
391 await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t) 400 await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t)
392 401
402 videoPlaylist.updatedAt = new Date()
403 await videoPlaylist.save({ transaction: t })
404
393 await sendUpdateVideoPlaylist(videoPlaylist, t) 405 await sendUpdateVideoPlaylist(videoPlaylist, t)
394 }) 406 })
395 407
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 76647fea2..3a3deab0c 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -49,12 +49,19 @@ function toValueOrNull (value: string) {
49 return value 49 return value
50} 50}
51 51
52function toArray (value: string) { 52function toArray (value: any) {
53 if (value && isArray(value) === false) return [ value ] 53 if (value && isArray(value) === false) return [ value ]
54 54
55 return value 55 return value
56} 56}
57 57
58function toIntArray (value: any) {
59 if (!value) return []
60 if (isArray(value) === false) return [ validator.toInt(value) ]
61
62 return value.map(v => validator.toInt(v))
63}
64
58function isFileValid ( 65function isFileValid (
59 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], 66 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
60 mimeTypeRegex: string, 67 mimeTypeRegex: string,
@@ -97,5 +104,6 @@ export {
97 isBooleanValid, 104 isBooleanValid,
98 toIntOrNull, 105 toIntOrNull,
99 toArray, 106 toArray,
107 toIntArray,
100 isFileValid 108 isFileValid
101} 109}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 54c390540..169a98ceb 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -56,7 +56,7 @@ const SORTABLE_COLUMNS = {
56 56
57 USER_NOTIFICATIONS: [ 'createdAt' ], 57 USER_NOTIFICATIONS: [ 'createdAt' ],
58 58
59 VIDEO_PLAYLISTS: [ 'createdAt' ] 59 VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ]
60} 60}
61 61
62const OAUTH_LIFETIME = { 62const OAUTH_LIFETIME = {
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index 22b8b8ff1..87d2c7b51 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -4,9 +4,9 @@ import { UserRight } from '../../../../shared'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { UserModel } from '../../../models/account/user' 5import { UserModel } from '../../../models/account/user'
6import { areValidationErrors } from '../utils' 6import { areValidationErrors } from '../utils'
7import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos' 7import { isVideoExist, isVideoFileInfoHashValid, isVideoImage } from '../../../helpers/custom-validators/videos'
8import { CONSTRAINTS_FIELDS } from '../../../initializers' 8import { CONSTRAINTS_FIELDS } from '../../../initializers'
9import { isIdOrUUIDValid, isUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc' 9import { isArrayOf, isIdOrUUIDValid, isIdValid, isUUIDValid, toArray, toValueOrNull, toIntArray } from '../../../helpers/custom-validators/misc'
10import { 10import {
11 isVideoPlaylistDescriptionValid, 11 isVideoPlaylistDescriptionValid,
12 isVideoPlaylistExist, 12 isVideoPlaylistExist,
@@ -23,6 +23,7 @@ import { VideoModel } from '../../../models/video/video'
23import { authenticatePromiseIfNeeded } from '../../oauth' 23import { authenticatePromiseIfNeeded } from '../../oauth'
24import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' 24import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
25import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' 25import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model'
26import { areValidActorHandles } from '../../../helpers/custom-validators/activitypub/actor'
26 27
27const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ 28const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
28 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 29 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -305,6 +306,20 @@ const commonVideoPlaylistFiltersValidator = [
305 } 306 }
306] 307]
307 308
309const doVideosInPlaylistExistValidator = [
310 query('videoIds')
311 .customSanitizer(toIntArray)
312 .custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
313
314 (req: express.Request, res: express.Response, next: express.NextFunction) => {
315 logger.debug('Checking areVideosInPlaylistExistValidator parameters', { parameters: req.query })
316
317 if (areValidationErrors(req, res)) return
318
319 return next()
320 }
321]
322
308// --------------------------------------------------------------------------- 323// ---------------------------------------------------------------------------
309 324
310export { 325export {
@@ -319,7 +334,9 @@ export {
319 334
320 videoPlaylistElementAPGetValidator, 335 videoPlaylistElementAPGetValidator,
321 336
322 commonVideoPlaylistFiltersValidator 337 commonVideoPlaylistFiltersValidator,
338
339 doVideosInPlaylistExistValidator
323} 340}
324 341
325// --------------------------------------------------------------------------- 342// ---------------------------------------------------------------------------
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 4d2ea0a66..aa42687cd 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -317,6 +317,29 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
317 }) 317 })
318 } 318 }
319 319
320 static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
321 const query = {
322 attributes: [ 'id' ],
323 where: {
324 ownerAccountId: accountId
325 },
326 include: [
327 {
328 attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ],
329 model: VideoPlaylistElementModel.unscoped(),
330 where: {
331 videoId: {
332 [Sequelize.Op.any]: videoIds
333 }
334 },
335 required: true
336 }
337 ]
338 }
339
340 return VideoPlaylistModel.findAll(query)
341 }
342
320 static doesPlaylistExist (url: string) { 343 static doesPlaylistExist (url: string) {
321 const query = { 344 const query = {
322 attributes: [], 345 attributes: [],
diff --git a/shared/models/videos/playlist/video-exist-in-playlist.model.ts b/shared/models/videos/playlist/video-exist-in-playlist.model.ts
new file mode 100644
index 000000000..71240f51d
--- /dev/null
+++ b/shared/models/videos/playlist/video-exist-in-playlist.model.ts
@@ -0,0 +1,7 @@
1export type VideoExistInPlaylist = {
2 [videoId: number ]: {
3 playlistId: number
4 startTimestamp?: number
5 stopTimestamp?: number
6 }[]
7}