aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared')
-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
18 files changed, 580 insertions, 19 deletions
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