aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/shared-video-playlist
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared/shared-video-playlist')
-rw-r--r--client/src/app/shared/shared-video-playlist/index.ts8
-rw-r--r--client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts36
-rw-r--r--client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html82
-rw-r--r--client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss107
-rw-r--r--client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts278
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html92
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss224
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts182
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts24
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html34
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss78
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts22
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist.model.ts98
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist.service.ts355
14 files changed, 1620 insertions, 0 deletions
diff --git a/client/src/app/shared/shared-video-playlist/index.ts b/client/src/app/shared/shared-video-playlist/index.ts
new file mode 100644
index 000000000..63bb046c6
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/index.ts
@@ -0,0 +1,8 @@
1export * from './video-add-to-playlist.component'
2export * from './video-playlist-element-miniature.component'
3export * from './video-playlist-element.model'
4export * from './video-playlist-miniature.component'
5export * from './video-playlist.model'
6export * from './video-playlist.service'
7
8export * from './shared-video-playlist.module'
diff --git a/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts b/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts
new file mode 100644
index 000000000..0566b1592
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/shared-video-playlist.module.ts
@@ -0,0 +1,36 @@
1
2import { NgModule } from '@angular/core'
3import { SharedFormModule } from '../shared-forms'
4import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module'
6import { SharedThumbnailModule } from '../shared-thumbnail'
7import { VideoAddToPlaylistComponent } from './video-add-to-playlist.component'
8import { VideoPlaylistElementMiniatureComponent } from './video-playlist-element-miniature.component'
9import { VideoPlaylistMiniatureComponent } from './video-playlist-miniature.component'
10import { VideoPlaylistService } from './video-playlist.service'
11
12@NgModule({
13 imports: [
14 SharedMainModule,
15 SharedFormModule,
16 SharedThumbnailModule,
17 SharedGlobalIconModule
18 ],
19
20 declarations: [
21 VideoAddToPlaylistComponent,
22 VideoPlaylistElementMiniatureComponent,
23 VideoPlaylistMiniatureComponent
24 ],
25
26 exports: [
27 VideoAddToPlaylistComponent,
28 VideoPlaylistElementMiniatureComponent,
29 VideoPlaylistMiniatureComponent
30 ],
31
32 providers: [
33 VideoPlaylistService
34 ]
35})
36export class SharedVideoPlaylistModule { }
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html
new file mode 100644
index 000000000..a40e0699e
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html
@@ -0,0 +1,82 @@
1<div class="root">
2 <div class="header">
3 <div class="first-row">
4 <div i18n class="title">Save to</div>
5
6 <div class="options" (click)="displayOptions = !displayOptions">
7 <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
8
9 <span i18n>Options</span>
10 </div>
11 </div>
12
13 <div class="options-row" *ngIf="displayOptions">
14 <div>
15 <my-peertube-checkbox
16 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
17 i18n-labelText labelText="Start at"
18 ></my-peertube-checkbox>
19
20 <my-timestamp-input
21 [timestamp]="timestampOptions.startTimestamp"
22 [maxTimestamp]="video.duration"
23 [disabled]="!timestampOptions.startTimestampEnabled"
24 [(ngModel)]="timestampOptions.startTimestamp"
25 ></my-timestamp-input>
26 </div>
27
28 <div>
29 <my-peertube-checkbox
30 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
31 i18n-labelText labelText="Stop at"
32 ></my-peertube-checkbox>
33
34 <my-timestamp-input
35 [timestamp]="timestampOptions.stopTimestamp"
36 [maxTimestamp]="video.duration"
37 [disabled]="!timestampOptions.stopTimestampEnabled"
38 [(ngModel)]="timestampOptions.stopTimestamp"
39 ></my-timestamp-input>
40 </div>
41 </div>
42 </div>
43
44 <div class="input-container">
45 <input type="text" placeholder="Search playlists" i18n-placeholder [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
46 </div>
47
48 <div class="playlists">
49 <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
50 <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox>
51
52 <div class="display-name">
53 {{ playlist.displayName }}
54
55 <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
56 {{ formatTimestamp(playlist) }}
57 </div>
58 </div>
59 </div>
60 </div>
61
62 <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
63 <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
64
65 <span i18n>Create a private playlist</span>
66 </div>
67
68 <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
69 <div class="form-group">
70 <label i18n for="displayName">Display name</label>
71 <input
72 type="text" id="displayName"
73 formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
74 >
75 <div *ngIf="formErrors['displayName']" class="form-error">
76 {{ formErrors['displayName'] }}
77 </div>
78 </div>
79
80 <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
81 </form>
82</div>
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss
new file mode 100644
index 000000000..47baa997b
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss
@@ -0,0 +1,107 @@
1@import '_variables';
2@import '_mixins';
3
4.header,
5.dropdown-item,
6.input-container {
7 padding: 8px 24px;
8}
9
10.header {
11 min-width: 240px;
12 margin-bottom: 10px;
13 border-bottom: 1px solid $separator-border-color;
14
15 .first-row {
16 display: flex;
17 align-items: center;
18
19 .title {
20 font-size: 18px;
21 flex-grow: 1;
22 }
23
24 .options {
25 display: flex;
26 align-items: center;
27 font-size: 14px;
28 cursor: pointer;
29
30 my-global-icon {
31 @include apply-svg-color(#333);
32
33 width: 16px;
34 height: 23px;
35 margin-right: 3px;
36 }
37 }
38 }
39
40 .options-row {
41 margin-top: 10px;
42 padding-left: 10px;
43
44 > div {
45 display: flex;
46 align-items: center;
47 }
48 }
49}
50
51.playlists {
52 max-height: 180px;
53 overflow-y: auto;
54}
55
56.playlist {
57 display: inline-flex;
58 cursor: pointer;
59
60 my-peertube-checkbox {
61 margin-right: 10px;
62 align-self: center;
63 }
64
65 .display-name {
66 display: flex;
67 align-items: flex-end;
68
69 .timestamp-info {
70 font-size: 0.9em;
71 color: pvar(--greyForegroundColor);
72 margin-left: 5px;
73 }
74 }
75}
76
77.new-playlist-button,
78.new-playlist-block {
79 padding-top: 10px;
80 border-top: 1px solid $separator-border-color;
81}
82
83.new-playlist-button {
84 cursor: pointer;
85
86 my-global-icon {
87 @include apply-svg-color(#333);
88
89 position: relative;
90 left: -1px;
91 top: -1px;
92 margin-right: 4px;
93 width: 21px;
94 height: 21px;
95 }
96}
97
98input[type=text] {
99 @include peertube-input-text(200px);
100
101 display: block;
102}
103
104input[type=submit] {
105 @include peertube-button;
106 @include orange-button;
107}
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
new file mode 100644
index 000000000..f611fc46b
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
@@ -0,0 +1,278 @@
1import * as debug from 'debug'
2import { Subject, Subscription } from 'rxjs'
3import { debounceTime, filter } from 'rxjs/operators'
4import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
5import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
6import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { Video, VideoExistInPlaylist, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
9import { secondsToTime } from '../../../assets/player/utils'
10import { CachedPlaylist, VideoPlaylistService } from './video-playlist.service'
11
12const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
13
14type PlaylistSummary = {
15 id: number
16 inPlaylist: boolean
17 displayName: string
18
19 playlistElementId?: number
20 startTimestamp?: number
21 stopTimestamp?: number
22}
23
24@Component({
25 selector: 'my-video-add-to-playlist',
26 styleUrls: [ './video-add-to-playlist.component.scss' ],
27 templateUrl: './video-add-to-playlist.component.html',
28 changeDetection: ChangeDetectionStrategy.OnPush
29})
30export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook {
31 @Input() video: Video
32 @Input() currentVideoTimestamp: number
33 @Input() lazyLoad = false
34
35 isNewPlaylistBlockOpened = false
36 videoPlaylistSearch: string
37 videoPlaylistSearchChanged = new Subject<string>()
38 videoPlaylists: PlaylistSummary[] = []
39 timestampOptions: {
40 startTimestampEnabled: boolean
41 startTimestamp: number
42 stopTimestampEnabled: boolean
43 stopTimestamp: number
44 }
45 displayOptions = false
46
47 private disabled = false
48
49 private listenToPlaylistChangeSub: Subscription
50 private playlistsData: CachedPlaylist[] = []
51
52 constructor (
53 protected formValidatorService: FormValidatorService,
54 private authService: AuthService,
55 private notifier: Notifier,
56 private i18n: I18n,
57 private videoPlaylistService: VideoPlaylistService,
58 private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
59 private cd: ChangeDetectorRef
60 ) {
61 super()
62 }
63
64 get user () {
65 return this.authService.getUser()
66 }
67
68 ngOnInit () {
69 this.buildForm({
70 displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
71 })
72
73 this.videoPlaylistService.listenToMyAccountPlaylistsChange()
74 .subscribe(result => {
75 this.playlistsData = result.data
76
77 this.videoPlaylistService.runPlaylistCheck(this.video.id)
78 })
79
80 this.videoPlaylistSearchChanged
81 .pipe(debounceTime(500))
82 .subscribe(() => this.load())
83
84 if (this.lazyLoad === false) this.load()
85 }
86
87 ngOnChanges (simpleChanges: SimpleChanges) {
88 if (simpleChanges['video']) {
89 this.reload()
90 }
91 }
92
93 ngOnDestroy () {
94 this.unsubscribePlaylistChanges()
95 }
96
97 disableForReuse () {
98 this.disabled = true
99 }
100
101 enabledForReuse () {
102 this.disabled = false
103 }
104
105 reload () {
106 logger('Reloading component')
107
108 this.videoPlaylists = []
109 this.videoPlaylistSearch = undefined
110
111 this.resetOptions(true)
112 this.load()
113
114 this.cd.markForCheck()
115 }
116
117 load () {
118 logger('Loading component')
119
120 this.listenToPlaylistChanges()
121
122 this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
123 .subscribe(playlistsResult => {
124 this.playlistsData = playlistsResult.data
125
126 this.videoPlaylistService.runPlaylistCheck(this.video.id)
127 })
128 }
129
130 openChange (opened: boolean) {
131 if (opened === false) {
132 this.isNewPlaylistBlockOpened = false
133 this.displayOptions = false
134 }
135 }
136
137 openCreateBlock (event: Event) {
138 event.preventDefault()
139
140 this.isNewPlaylistBlockOpened = true
141 }
142
143 togglePlaylist (event: Event, playlist: PlaylistSummary) {
144 event.preventDefault()
145
146 if (playlist.inPlaylist === true) {
147 this.removeVideoFromPlaylist(playlist)
148 } else {
149 this.addVideoInPlaylist(playlist)
150 }
151
152 playlist.inPlaylist = !playlist.inPlaylist
153 this.resetOptions()
154
155 this.cd.markForCheck()
156 }
157
158 createPlaylist () {
159 const displayName = this.form.value[ 'displayName' ]
160
161 const videoPlaylistCreate: VideoPlaylistCreate = {
162 displayName,
163 privacy: VideoPlaylistPrivacy.PRIVATE
164 }
165
166 this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
167 () => {
168 this.isNewPlaylistBlockOpened = false
169
170 this.cd.markForCheck()
171 },
172
173 err => this.notifier.error(err.message)
174 )
175 }
176
177 resetOptions (resetTimestamp = false) {
178 this.displayOptions = false
179
180 this.timestampOptions = {} as any
181 this.timestampOptions.startTimestampEnabled = false
182 this.timestampOptions.stopTimestampEnabled = false
183
184 if (resetTimestamp) {
185 this.timestampOptions.startTimestamp = 0
186 this.timestampOptions.stopTimestamp = this.video.duration
187 }
188 }
189
190 formatTimestamp (playlist: PlaylistSummary) {
191 const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
192 const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
193
194 return `(${start}-${stop})`
195 }
196
197 onVideoPlaylistSearchChanged () {
198 this.videoPlaylistSearchChanged.next()
199 }
200
201 private removeVideoFromPlaylist (playlist: PlaylistSummary) {
202 if (!playlist.playlistElementId) return
203
204 this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id)
205 .subscribe(
206 () => {
207 this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
208 },
209
210 err => {
211 this.notifier.error(err.message)
212 },
213
214 () => this.cd.markForCheck()
215 )
216 }
217
218 private listenToPlaylistChanges () {
219 this.unsubscribePlaylistChanges()
220
221 this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
222 .pipe(filter(() => this.disabled === false))
223 .subscribe(existResult => this.rebuildPlaylists(existResult))
224 }
225
226 private unsubscribePlaylistChanges () {
227 if (this.listenToPlaylistChangeSub) {
228 this.listenToPlaylistChangeSub.unsubscribe()
229 this.listenToPlaylistChangeSub = undefined
230 }
231 }
232
233 private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
234 logger('Got existing results for %d.', this.video.id, existResult)
235
236 this.videoPlaylists = []
237 for (const playlist of this.playlistsData) {
238 const existingPlaylist = existResult.find(p => p.playlistId === playlist.id)
239
240 this.videoPlaylists.push({
241 id: playlist.id,
242 displayName: playlist.displayName,
243 inPlaylist: !!existingPlaylist,
244 playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined,
245 startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
246 stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
247 })
248 }
249
250 logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists)
251
252 this.cd.markForCheck()
253 }
254
255 private addVideoInPlaylist (playlist: PlaylistSummary) {
256 const body: VideoPlaylistElementCreate = { videoId: this.video.id }
257
258 if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
259 if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
260
261 this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
262 .subscribe(
263 () => {
264 const message = body.startTimestamp || body.stopTimestamp
265 ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
266 : this.i18n('Video added in {{n}}', { n: playlist.displayName })
267
268 this.notifier.success(message)
269 },
270
271 err => {
272 this.notifier.error(err.message)
273 },
274
275 () => this.cd.markForCheck()
276 )
277 }
278}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
new file mode 100644
index 000000000..e3f7ef017
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
@@ -0,0 +1,92 @@
1<div class="video" [ngClass]="{ playing: playing }">
2 <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
3 <div class="position">
4 <my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
5 <ng-container *ngIf="!playing">{{ position }}</ng-container>
6 </div>
7
8 <my-video-thumbnail
9 *ngIf="playlistElement.video"
10 [video]="playlistElement.video" [nsfw]="isVideoBlur(playlistElement.video)"
11 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
12 ></my-video-thumbnail>
13
14 <div class="fake-thumbnail" *ngIf="!playlistElement.video"></div>
15
16 <div class="video-info">
17 <ng-container *ngIf="playlistElement.video">
18 <a tabindex="-1" class="video-info-name"
19 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
20 [attr.title]="playlistElement.video.name"
21 >{{ playlistElement.video.name }}</a>
22
23 <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
24 {{ playlistElement.video.byAccount }}
25 </a>
26 <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
27
28 <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(playlistElement) }}</span>
29 </ng-container>
30
31 <span *ngIf="!playlistElement.video" class="video-info-name">
32 <ng-container i18n *ngIf="isUnavailable(playlistElement)">Unavailable</ng-container>
33 <ng-container i18n *ngIf="isPrivate(playlistElement)">Private</ng-container>
34 <ng-container i18n *ngIf="isDeleted(playlistElement)">Deleted</ng-container>
35 </span>
36 </div>
37 </a>
38
39 <my-edit-button *ngIf="owned && touchScreenEditButton" [routerLink]="[ '/my-account', 'video-playlists', playlist.uuid ]"></my-edit-button>
40
41 <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom auto"
42 (openChange)="onDropdownOpenChange()" autoClose="outside"
43 >
44 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
45
46 <div ngbDropdownMenu>
47 <ng-container *ngIf="playlistElement.video">
48 <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, playlistElement)">
49 <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
50 <ng-container i18n>Edit starts/stops at</ng-container>
51 </div>
52
53 <div class="timestamp-options" *ngIf="displayTimestampOptions">
54 <div>
55 <my-peertube-checkbox
56 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
57 i18n-labelText labelText="Start at"
58 ></my-peertube-checkbox>
59
60 <my-timestamp-input
61 [timestamp]="timestampOptions.startTimestamp"
62 [maxTimestamp]="playlistElement.video.duration"
63 [disabled]="!timestampOptions.startTimestampEnabled"
64 [(ngModel)]="timestampOptions.startTimestamp"
65 ></my-timestamp-input>
66 </div>
67
68 <div>
69 <my-peertube-checkbox
70 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
71 i18n-labelText labelText="Stop at"
72 ></my-peertube-checkbox>
73
74 <my-timestamp-input
75 [timestamp]="timestampOptions.stopTimestamp"
76 [maxTimestamp]="playlistElement.video.duration"
77 [disabled]="!timestampOptions.stopTimestampEnabled"
78 [(ngModel)]="timestampOptions.stopTimestamp"
79 ></my-timestamp-input>
80 </div>
81
82 <input type="submit" i18n-value value="Save" (click)="updateTimestamps(playlistElement)">
83 </div>
84 </ng-container>
85
86 <span class="dropdown-item" (click)="removeFromPlaylist(playlistElement)">
87 <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
88 <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
89 </span>
90 </div>
91 </div>
92</div>
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss
new file mode 100644
index 000000000..afd775b25
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.scss
@@ -0,0 +1,224 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5$thumbnail-width: 130px;
6$thumbnail-height: 72px;
7
8my-video-thumbnail {
9 @include thumbnail-size-component($thumbnail-width, $thumbnail-height);
10}
11
12.fake-thumbnail {
13 width: $thumbnail-width;
14 height: $thumbnail-height;
15 background-color: #ececec;
16}
17
18my-video-thumbnail,
19.fake-thumbnail {
20 display: flex; // Avoids an issue with line-height that adds space below the element
21 margin-right: 10px;
22}
23
24.video {
25 display: flex;
26 align-items: center;
27 background-color: pvar(--mainBackgroundColor);
28 padding: 10px;
29 border-bottom: 1px solid $separator-border-color;
30
31 &:hover {
32 background-color: rgba(0, 0, 0, 0.05);
33
34 .more {
35 opacity: 1;
36 }
37 }
38
39 @media not all and (hover: hover) and (pointer: fine) {
40 .more {
41 opacity: 1 !important;
42 }
43 }
44
45 &.playing {
46 background-color: rgba(0, 0, 0, 0.02);
47 }
48
49 a {
50 @include disable-default-a-behaviour;
51
52 color: pvar(--mainForegroundColor);
53 display: flex;
54 min-width: 0;
55 align-items: center;
56 cursor: pointer;
57
58 .position {
59 font-weight: $font-semibold;
60 margin-right: 10px;
61 color: pvar(--greyForegroundColor);
62 min-width: 25px;
63
64 my-global-icon {
65 @include apply-svg-color(pvar(--greyForegroundColor));
66
67 width: 17px;
68 position: relative;
69 left: -2px;
70 }
71 }
72
73 .video-info {
74 display: flex;
75 flex-direction: column;
76 align-self: flex-start;
77 min-width: 0;
78
79 a {
80 width: auto;
81 }
82
83 .video-info-account, .video-info-timestamp {
84 color: pvar(--greyForegroundColor);
85 }
86 }
87 }
88
89 .video-info-name {
90 font-size: 18px;
91 font-weight: $font-semibold;
92 display: inline-block;
93
94 @include ellipsis;
95 }
96
97 .more, my-edit-button {
98 justify-self: flex-end;
99 margin-left: auto;
100 cursor: pointer;
101 min-width: 24px;
102 }
103
104 .more {
105 opacity: 0;
106
107 &.show {
108 opacity: 1;
109 }
110
111 .icon-more {
112 @include apply-svg-color(pvar(--greyForegroundColor));
113
114 display: flex;
115
116 &::after {
117 border: none;
118 }
119 }
120
121 .dropdown-item {
122 @include dropdown-with-icon-item;
123 }
124
125 .timestamp-options {
126 padding-top: 0;
127 padding-left: 35px;
128 margin-bottom: 15px;
129
130 > div {
131 display: flex;
132 align-items: center;
133 }
134
135 input {
136 @include peertube-button;
137 @include orange-button;
138
139 margin-top: 10px;
140 }
141 }
142 }
143}
144
145@mixin more-dropdown-control {
146 .video {
147 my-edit-button {
148 display: none;
149
150 + .more {
151 display: inline-flex;
152 }
153 }
154 }
155}
156
157@mixin edit-button-control {
158 .video {
159 my-edit-button {
160 display: none;
161 }
162
163 &.playing {
164 my-edit-button {
165 display: inline-flex;
166 height: max-content;
167 }
168 }
169
170 my-edit-button + .more {
171 display: none;
172 }
173 }
174}
175
176@mixin edit-button-in-mobile-view {
177 .video {
178 my-edit-button {
179 ::ng-deep .action-button-edit {
180 padding: 0 13px;
181
182 .button-label {
183 display: none;
184 }
185 }
186 }
187 }
188}
189
190@media screen and (min-width: $small-view) {
191 :host-context(.expanded) {
192 @include more-dropdown-control();
193 }
194}
195
196@media screen and (max-width: $small-view) {
197 :host-context(.expanded) {
198 @include edit-button-control();
199 }
200}
201
202@media screen and (max-width: $mobile-view) {
203 :host-context(.expanded) {
204 @include edit-button-in-mobile-view();
205 }
206}
207
208@media screen and (min-width: #{$small-view + $menu-width}) {
209 :host-context(.main-col:not(.expanded)) {
210 @include more-dropdown-control();
211 }
212}
213
214@media screen and (max-width: #{$small-view + $menu-width}) {
215 :host-context(.main-col:not(.expanded)) {
216 @include edit-button-control();
217 }
218}
219
220@media screen and (max-width: #{$mobile-view + $menu-width}) {
221 :host-context(.main-col:not(.expanded)) {
222 @include edit-button-in-mobile-view();
223 }
224}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
new file mode 100644
index 000000000..57a5fbe61
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
@@ -0,0 +1,182 @@
1import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { AuthService, Notifier, ServerService } from '@app/core'
3import { Video } from '@app/shared/shared-main'
4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
7import { secondsToTime } from '../../../assets/player/utils'
8import { VideoPlaylistElement } from './video-playlist-element.model'
9import { VideoPlaylist } from './video-playlist.model'
10import { VideoPlaylistService } from './video-playlist.service'
11
12@Component({
13 selector: 'my-video-playlist-element-miniature',
14 styleUrls: [ './video-playlist-element-miniature.component.scss' ],
15 templateUrl: './video-playlist-element-miniature.component.html',
16 changeDetection: ChangeDetectionStrategy.OnPush
17})
18export class VideoPlaylistElementMiniatureComponent implements OnInit {
19 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
20
21 @Input() playlist: VideoPlaylist
22 @Input() playlistElement: VideoPlaylistElement
23 @Input() owned = false
24 @Input() playing = false
25 @Input() rowLink = false
26 @Input() accountLink = true
27 @Input() position: number // Keep this property because we're in the OnPush change detection strategy
28 @Input() touchScreenEditButton = false
29
30 @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
31
32 displayTimestampOptions = false
33
34 timestampOptions: {
35 startTimestampEnabled: boolean
36 startTimestamp: number
37 stopTimestampEnabled: boolean
38 stopTimestamp: number
39 } = {} as any
40
41 private serverConfig: ServerConfig
42
43 constructor (
44 private authService: AuthService,
45 private serverService: ServerService,
46 private notifier: Notifier,
47 private i18n: I18n,
48 private videoPlaylistService: VideoPlaylistService,
49 private cdr: ChangeDetectorRef
50 ) {}
51
52 ngOnInit (): void {
53 this.serverConfig = this.serverService.getTmpConfig()
54 this.serverService.getConfig()
55 .subscribe(config => {
56 this.serverConfig = config
57 this.cdr.detectChanges()
58 })
59 }
60
61 isUnavailable (e: VideoPlaylistElement) {
62 return e.type === VideoPlaylistElementType.UNAVAILABLE
63 }
64
65 isPrivate (e: VideoPlaylistElement) {
66 return e.type === VideoPlaylistElementType.PRIVATE
67 }
68
69 isDeleted (e: VideoPlaylistElement) {
70 return e.type === VideoPlaylistElementType.DELETED
71 }
72
73 buildRouterLink () {
74 if (!this.playlist) return null
75
76 return [ '/videos/watch/playlist', this.playlist.uuid ]
77 }
78
79 buildRouterQuery () {
80 if (!this.playlistElement || !this.playlistElement.video) return {}
81
82 return {
83 videoId: this.playlistElement.video.uuid,
84 start: this.playlistElement.startTimestamp,
85 stop: this.playlistElement.stopTimestamp,
86 resume: true
87 }
88 }
89
90 isVideoBlur (video: Video) {
91 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig)
92 }
93
94 removeFromPlaylist (playlistElement: VideoPlaylistElement) {
95 const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined
96
97 this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId)
98 .subscribe(
99 () => {
100 this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
101
102 this.elementRemoved.emit(playlistElement)
103 },
104
105 err => this.notifier.error(err.message)
106 )
107
108 this.moreDropdown.close()
109 }
110
111 updateTimestamps (playlistElement: VideoPlaylistElement) {
112 const body: VideoPlaylistElementUpdate = {}
113
114 body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
115 body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
116
117 this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id)
118 .subscribe(
119 () => {
120 this.notifier.success(this.i18n('Timestamps updated'))
121
122 playlistElement.startTimestamp = body.startTimestamp
123 playlistElement.stopTimestamp = body.stopTimestamp
124
125 this.cdr.detectChanges()
126 },
127
128 err => this.notifier.error(err.message)
129 )
130
131 this.moreDropdown.close()
132 }
133
134 formatTimestamp (playlistElement: VideoPlaylistElement) {
135 const start = playlistElement.startTimestamp
136 const stop = playlistElement.stopTimestamp
137
138 const startFormatted = secondsToTime(start, true, ':')
139 const stopFormatted = secondsToTime(stop, true, ':')
140
141 if (start === null && stop === null) return ''
142
143 if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
144 if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
145
146 return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
147 }
148
149 onDropdownOpenChange () {
150 this.displayTimestampOptions = false
151 }
152
153 toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
154 event.preventDefault()
155
156 this.displayTimestampOptions = !this.displayTimestampOptions
157
158 if (this.displayTimestampOptions === true) {
159 this.timestampOptions = {
160 startTimestampEnabled: false,
161 stopTimestampEnabled: false,
162 startTimestamp: 0,
163 stopTimestamp: playlistElement.video.duration
164 }
165
166 if (playlistElement.startTimestamp) {
167 this.timestampOptions.startTimestampEnabled = true
168 this.timestampOptions.startTimestamp = playlistElement.startTimestamp
169 }
170
171 if (playlistElement.stopTimestamp) {
172 this.timestampOptions.stopTimestampEnabled = true
173 this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
174 }
175 }
176
177 // FIXME: why do we have to use setTimeout here?
178 setTimeout(() => {
179 this.cdr.detectChanges()
180 })
181 }
182}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts
new file mode 100644
index 000000000..27a79d1fd
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element.model.ts
@@ -0,0 +1,24 @@
1import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
2import { Video } from '@app/shared/shared-main'
3
4export class VideoPlaylistElement implements ServerVideoPlaylistElement {
5 id: number
6 position: number
7 startTimestamp: number
8 stopTimestamp: number
9
10 type: VideoPlaylistElementType
11
12 video?: Video
13
14 constructor (hash: ServerVideoPlaylistElement, translations: {}) {
15 this.id = hash.id
16 this.position = hash.position
17 this.startTimestamp = hash.startTimestamp
18 this.stopTimestamp = hash.stopTimestamp
19
20 this.type = hash.type
21
22 if (hash.video) this.video = new Video(hash.video, translations)
23 }
24}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
new file mode 100644
index 000000000..86f6664cb
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
@@ -0,0 +1,34 @@
1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
2 <a
3 [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
4 class="miniature-thumbnail"
5 >
6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
7
8 <div class="miniature-playlist-info-overlay">
9 <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}}</ng-container>
10 </div>
11
12 <div class="play-overlay">
13 <div class="icon"></div>
14 </div>
15 </a>
16
17 <div class="miniature-info">
18 <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description">
19 {{ playlist.displayName }}
20 </a>
21
22 <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
23 {{ playlist.videoChannelBy }}
24 </a>
25
26 <div class="privacy-date">
27 <span class="video-info-privacy" *ngIf="displayPrivacy">{{ playlist.privacy.label }}</span>
28
29 <span i18n class="updated-at">Updated {{ playlist.updatedAt | myFromNow }}</span>
30 </div>
31
32 <div *ngIf="displayDescription" class="video-info-description">{{ playlist.description }}</div>
33 </div>
34</div>
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
new file mode 100644
index 000000000..1b16dbb01
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
@@ -0,0 +1,78 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5.miniature {
6 display: inline-block;
7
8 &.no-videos:not(.to-manage){
9 a {
10 cursor: default !important;
11 }
12 }
13
14 &.to-manage,
15 &.no-videos {
16 .play-overlay {
17 display: none;
18 }
19 }
20
21 .miniature-thumbnail {
22 @include miniature-thumbnail;
23
24 .miniature-playlist-info-overlay {
25 @include static-thumbnail-overlay;
26
27 position: absolute;
28 right: 0;
29 bottom: 0;
30 height: $video-thumbnail-height;
31 padding: 0 10px;
32 display: flex;
33 align-items: center;
34 font-size: 14px;
35 font-weight: $font-semibold;
36 }
37 }
38
39 .miniature-info {
40 width: 200px;
41 margin-top: 2px;
42 line-height: normal;
43
44 .miniature-name {
45 @include miniature-name;
46
47 @include ellipsis-multiline(1.3em, 2);
48
49 margin: 0;
50 }
51
52 .by {
53 @include disable-default-a-behaviour;
54
55 display: block;
56 color: pvar(--greyForegroundColor);
57 }
58
59 .privacy-date {
60 margin-top: 5px;
61
62 .video-info-privacy {
63 font-size: 14px;
64 font-weight: $font-semibold;
65
66 &::after {
67 content: '-';
68 margin: 0 3px;
69 }
70 }
71 }
72
73 .video-info-description {
74 margin-top: 10px;
75 color: pvar(--greyForegroundColor);
76 }
77 }
78}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts
new file mode 100644
index 000000000..4b0669a32
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.ts
@@ -0,0 +1,22 @@
1import { Component, Input } from '@angular/core'
2import { VideoPlaylist } from './video-playlist.model'
3
4@Component({
5 selector: 'my-video-playlist-miniature',
6 styleUrls: [ './video-playlist-miniature.component.scss' ],
7 templateUrl: './video-playlist-miniature.component.html'
8})
9export class VideoPlaylistMiniatureComponent {
10 @Input() playlist: VideoPlaylist
11 @Input() toManage = false
12 @Input() displayChannel = false
13 @Input() displayDescription = false
14 @Input() displayPrivacy = false
15
16 getPlaylistUrl () {
17 if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
18 if (this.playlist.videosLength === 0) return null
19
20 return [ '/videos/watch/playlist', this.playlist.uuid ]
21 }
22}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.model.ts b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts
new file mode 100644
index 000000000..8f63d2abd
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.model.ts
@@ -0,0 +1,98 @@
1import { getAbsoluteAPIUrl } from '@app/helpers'
2import { Actor } from '@app/shared/shared-main'
3import {
4 AccountSummary,
5 peertubeTranslate,
6 VideoChannelSummary,
7 VideoConstant,
8 VideoPlaylist as ServerVideoPlaylist,
9 VideoPlaylistPrivacy,
10 VideoPlaylistType
11} from '@shared/models'
12
13export class VideoPlaylist implements ServerVideoPlaylist {
14 id: number
15 uuid: string
16 isLocal: boolean
17
18 displayName: string
19 description: string
20 privacy: VideoConstant<VideoPlaylistPrivacy>
21
22 thumbnailPath: string
23
24 videosLength: number
25
26 type: VideoConstant<VideoPlaylistType>
27
28 createdAt: Date | string
29 updatedAt: Date | string
30
31 ownerAccount: AccountSummary
32 videoChannel?: VideoChannelSummary
33
34 thumbnailUrl: string
35
36 ownerBy: string
37 ownerAvatarUrl: string
38
39 videoChannelBy?: string
40 videoChannelAvatarUrl?: string
41
42 private thumbnailVersion: number
43 private originThumbnailUrl: string
44
45 constructor (hash: ServerVideoPlaylist, translations: {}) {
46 const absoluteAPIUrl = getAbsoluteAPIUrl()
47
48 this.id = hash.id
49 this.uuid = hash.uuid
50 this.isLocal = hash.isLocal
51
52 this.displayName = hash.displayName
53
54 this.description = hash.description
55 this.privacy = hash.privacy
56
57 this.thumbnailPath = hash.thumbnailPath
58
59 if (this.thumbnailPath) {
60 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
61 this.originThumbnailUrl = this.thumbnailUrl
62 } else {
63 this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
64 }
65
66 this.videosLength = hash.videosLength
67
68 this.type = hash.type
69
70 this.createdAt = new Date(hash.createdAt)
71 this.updatedAt = new Date(hash.updatedAt)
72
73 this.ownerAccount = hash.ownerAccount
74 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
75 this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
76
77 if (hash.videoChannel) {
78 this.videoChannel = hash.videoChannel
79 this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
80 this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
81 }
82
83 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
84
85 if (this.type.id === VideoPlaylistType.WATCH_LATER) {
86 this.displayName = peertubeTranslate(this.displayName, translations)
87 }
88 }
89
90 refreshThumbnail () {
91 if (!this.originThumbnailUrl) return
92
93 if (!this.thumbnailVersion) this.thumbnailVersion = 0
94 this.thumbnailVersion++
95
96 this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
97 }
98}
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
new file mode 100644
index 000000000..cc3d04b9e
--- /dev/null
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
@@ -0,0 +1,355 @@
1import * as debug from 'debug'
2import { uniq } from 'lodash-es'
3import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
4import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
5import { HttpClient, HttpParams } from '@angular/common/http'
6import { Injectable, NgZone } from '@angular/core'
7import { AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core'
8import { enterZone, leaveZone, objectToFormData } from '@app/helpers'
9import { Account, AccountService, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
10import {
11 ResultList,
12 VideoExistInPlaylist,
13 VideoPlaylist as VideoPlaylistServerModel,
14 VideoPlaylistCreate,
15 VideoPlaylistElement as ServerVideoPlaylistElement,
16 VideoPlaylistElementCreate,
17 VideoPlaylistElementUpdate,
18 VideoPlaylistReorder,
19 VideoPlaylistUpdate,
20 VideosExistInPlaylists
21} from '@shared/models'
22import { environment } from '../../../environments/environment'
23import { VideoPlaylistElement } from './video-playlist-element.model'
24import { VideoPlaylist } from './video-playlist.model'
25
26const logger = debug('peertube:playlists:VideoPlaylistService')
27
28export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string }
29
30@Injectable()
31export class VideoPlaylistService {
32 static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
33 static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
34
35 // Use a replay subject because we "next" a value before subscribing
36 private videoExistsInPlaylistNotifier = new ReplaySubject<number>(1)
37 private videoExistsInPlaylistCacheSubject = new Subject<VideosExistInPlaylists>()
38 private readonly videoExistsInPlaylistObservable: Observable<VideosExistInPlaylists>
39
40 private videoExistsObservableCache: { [ id: number ]: Observable<VideoExistInPlaylist[]> } = {}
41 private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {}
42
43 private myAccountPlaylistCache: ResultList<CachedPlaylist> = undefined
44 private myAccountPlaylistCacheRunning: Observable<ResultList<CachedPlaylist>>
45 private myAccountPlaylistCacheSubject = new Subject<ResultList<CachedPlaylist>>()
46
47 constructor (
48 private authHttp: HttpClient,
49 private serverService: ServerService,
50 private restExtractor: RestExtractor,
51 private restService: RestService,
52 private ngZone: NgZone
53 ) {
54 this.videoExistsInPlaylistObservable = merge(
55 this.videoExistsInPlaylistNotifier.pipe(
56 // We leave Angular zone so Protractor does not get stuck
57 bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
58 filter(videoIds => videoIds.length !== 0),
59 map(videoIds => uniq(videoIds)),
60 observeOn(enterZone(this.ngZone, asyncScheduler)),
61 switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
62 share()
63 ),
64
65 this.videoExistsInPlaylistCacheSubject
66 )
67 }
68
69 listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
70 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
71 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
72
73 let params = new HttpParams()
74 params = this.restService.addRestGetParams(params, pagination)
75
76 return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
77 .pipe(
78 switchMap(res => this.extractPlaylists(res)),
79 catchError(err => this.restExtractor.handleError(err))
80 )
81 }
82
83 listMyPlaylistWithCache (user: AuthUser, search?: string) {
84 if (!search) {
85 if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning
86 if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
87 }
88
89 const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
90 .pipe(
91 tap(result => {
92 if (!search) {
93 this.myAccountPlaylistCacheRunning = undefined
94 this.myAccountPlaylistCache = result
95 }
96 }),
97 share()
98 )
99
100 if (!search) this.myAccountPlaylistCacheRunning = obs
101 return obs
102 }
103
104 listAccountPlaylists (
105 account: Account,
106 componentPagination: ComponentPaginationLight,
107 sort: string,
108 search?: string
109 ): Observable<ResultList<VideoPlaylist>> {
110 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
111 const pagination = componentPagination
112 ? this.restService.componentPaginationToRestPagination(componentPagination)
113 : undefined
114
115 let params = new HttpParams()
116 params = this.restService.addRestGetParams(params, pagination, sort)
117 if (search) params = this.restService.addObjectParams(params, { search })
118
119 return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
120 .pipe(
121 switchMap(res => this.extractPlaylists(res)),
122 catchError(err => this.restExtractor.handleError(err))
123 )
124 }
125
126 getVideoPlaylist (id: string | number) {
127 const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
128
129 return this.authHttp.get<VideoPlaylist>(url)
130 .pipe(
131 switchMap(res => this.extractPlaylist(res)),
132 catchError(err => this.restExtractor.handleError(err))
133 )
134 }
135
136 createVideoPlaylist (body: VideoPlaylistCreate) {
137 const data = objectToFormData(body)
138
139 return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
140 .pipe(
141 tap(res => {
142 if (!this.myAccountPlaylistCache) return
143
144 this.myAccountPlaylistCache.total++
145
146 this.myAccountPlaylistCache.data.push({
147 id: res.videoPlaylist.id,
148 displayName: body.displayName
149 })
150
151 this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
152 }),
153 catchError(err => this.restExtractor.handleError(err))
154 )
155 }
156
157 updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
158 const data = objectToFormData(body)
159
160 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
161 .pipe(
162 map(this.restExtractor.extractDataBool),
163 tap(() => {
164 if (!this.myAccountPlaylistCache) return
165
166 const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id)
167 playlist.displayName = body.displayName
168
169 this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
170 }),
171 catchError(err => this.restExtractor.handleError(err))
172 )
173 }
174
175 removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
176 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
177 .pipe(
178 map(this.restExtractor.extractDataBool),
179 tap(() => {
180 if (!this.myAccountPlaylistCache) return
181
182 this.myAccountPlaylistCache.total--
183 this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data
184 .filter(p => p.id !== videoPlaylist.id)
185
186 this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
187 }),
188 catchError(err => this.restExtractor.handleError(err))
189 )
190 }
191
192 addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
193 const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos'
194
195 return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body)
196 .pipe(
197 tap(res => {
198 const existsResult = this.videoExistsCache[body.videoId]
199 existsResult.push({
200 playlistId,
201 playlistElementId: res.videoPlaylistElement.id,
202 startTimestamp: body.startTimestamp,
203 stopTimestamp: body.stopTimestamp
204 })
205
206 this.runPlaylistCheck(body.videoId)
207 }),
208 catchError(err => this.restExtractor.handleError(err))
209 )
210 }
211
212 updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) {
213 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
214 .pipe(
215 map(this.restExtractor.extractDataBool),
216 tap(() => {
217 const existsResult = this.videoExistsCache[videoId]
218 const elem = existsResult.find(e => e.playlistElementId === playlistElementId)
219
220 elem.startTimestamp = body.startTimestamp
221 elem.stopTimestamp = body.stopTimestamp
222
223 this.runPlaylistCheck(videoId)
224 }),
225 catchError(err => this.restExtractor.handleError(err))
226 )
227 }
228
229 removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) {
230 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
231 .pipe(
232 map(this.restExtractor.extractDataBool),
233 tap(() => {
234 if (!videoId) return
235
236 this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId)
237 this.runPlaylistCheck(videoId)
238 }),
239 catchError(err => this.restExtractor.handleError(err))
240 )
241 }
242
243 reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
244 const body: VideoPlaylistReorder = {
245 startPosition: oldPosition,
246 insertAfterPosition: newPosition
247 }
248
249 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body)
250 .pipe(
251 map(this.restExtractor.extractDataBool),
252 catchError(err => this.restExtractor.handleError(err))
253 )
254 }
255
256 getPlaylistVideos (
257 videoPlaylistId: number | string,
258 componentPagination: ComponentPaginationLight
259 ): Observable<ResultList<VideoPlaylistElement>> {
260 const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
261 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
262
263 let params = new HttpParams()
264 params = this.restService.addRestGetParams(params, pagination)
265
266 return this.authHttp
267 .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
268 .pipe(
269 switchMap(res => this.extractVideoPlaylistElements(res)),
270 catchError(err => this.restExtractor.handleError(err))
271 )
272 }
273
274 listenToMyAccountPlaylistsChange () {
275 return this.myAccountPlaylistCacheSubject.asObservable()
276 }
277
278 listenToVideoPlaylistChange (videoId: number) {
279 if (this.videoExistsObservableCache[ videoId ]) {
280 return this.videoExistsObservableCache[ videoId ]
281 }
282
283 const obs = this.videoExistsInPlaylistObservable
284 .pipe(
285 map(existsResult => existsResult[ videoId ]),
286 filter(r => !!r),
287 tap(result => this.videoExistsCache[ videoId ] = result)
288 )
289
290 this.videoExistsObservableCache[ videoId ] = obs
291 return obs
292 }
293
294 runPlaylistCheck (videoId: number) {
295 logger('Running playlist check.')
296
297 if (this.videoExistsCache[videoId]) {
298 logger('Found cache for %d.', videoId)
299
300 return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] })
301 }
302
303 logger('Fetching from network for %d.', videoId)
304 return this.videoExistsInPlaylistNotifier.next(videoId)
305 }
306
307 extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
308 return this.serverService.getServerLocale()
309 .pipe(
310 map(translations => {
311 const playlistsJSON = result.data
312 const total = result.total
313 const playlists: VideoPlaylist[] = []
314
315 for (const playlistJSON of playlistsJSON) {
316 playlists.push(new VideoPlaylist(playlistJSON, translations))
317 }
318
319 return { data: playlists, total }
320 })
321 )
322 }
323
324 extractPlaylist (playlist: VideoPlaylistServerModel) {
325 return this.serverService.getServerLocale()
326 .pipe(map(translations => new VideoPlaylist(playlist, translations)))
327 }
328
329 extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
330 return this.serverService.getServerLocale()
331 .pipe(
332 map(translations => {
333 const elementsJson = result.data
334 const total = result.total
335 const elements: VideoPlaylistElement[] = []
336
337 for (const elementJson of elementsJson) {
338 elements.push(new VideoPlaylistElement(elementJson, translations))
339 }
340
341 return { total, data: elements }
342 })
343 )
344 }
345
346 private doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
347 const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
348
349 let params = new HttpParams()
350 params = this.restService.addObjectParams(params, { videoIds })
351
352 return this.authHttp.get<VideoExistInPlaylist>(url, { params, headers: { ignoreLoadingBar: '' } })
353 .pipe(catchError(err => this.restExtractor.handleError(err)))
354 }
355}