aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/video-playlist
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared/video-playlist')
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.html82
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.scss107
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.ts280
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html92
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss224
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts187
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element.model.ts24
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.html34
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.scss78
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-miniature.component.ts22
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.model.ts97
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.service.ts357
12 files changed, 0 insertions, 1584 deletions
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
deleted file mode 100644
index a40e0699e..000000000
--- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html
+++ /dev/null
@@ -1,82 +0,0 @@
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/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
deleted file mode 100644
index 47baa997b..000000000
--- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss
+++ /dev/null
@@ -1,107 +0,0 @@
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/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
deleted file mode 100644
index 0c593a79a..000000000
--- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
+++ /dev/null
@@ -1,280 +0,0 @@
1import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
2import { CachedPlaylist, VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
3import { AuthService, Notifier } from '@app/core'
4import { Subject, Subscription } from 'rxjs'
5import { debounceTime, filter } from 'rxjs/operators'
6import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
7import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms'
8import { I18n } from '@ngx-translate/i18n-polyfill'
9import { secondsToTime } from '../../../assets/player/utils'
10import * as debug from 'debug'
11import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
12import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
13
14const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
15
16type PlaylistSummary = {
17 id: number
18 inPlaylist: boolean
19 displayName: string
20
21 playlistElementId?: number
22 startTimestamp?: number
23 stopTimestamp?: number
24}
25
26@Component({
27 selector: 'my-video-add-to-playlist',
28 styleUrls: [ './video-add-to-playlist.component.scss' ],
29 templateUrl: './video-add-to-playlist.component.html',
30 changeDetection: ChangeDetectionStrategy.OnPush
31})
32export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook {
33 @Input() video: Video
34 @Input() currentVideoTimestamp: number
35 @Input() lazyLoad = false
36
37 isNewPlaylistBlockOpened = false
38 videoPlaylistSearch: string
39 videoPlaylistSearchChanged = new Subject<string>()
40 videoPlaylists: PlaylistSummary[] = []
41 timestampOptions: {
42 startTimestampEnabled: boolean
43 startTimestamp: number
44 stopTimestampEnabled: boolean
45 stopTimestamp: number
46 }
47 displayOptions = false
48
49 private disabled = false
50
51 private listenToPlaylistChangeSub: Subscription
52 private playlistsData: CachedPlaylist[] = []
53
54 constructor (
55 protected formValidatorService: FormValidatorService,
56 private authService: AuthService,
57 private notifier: Notifier,
58 private i18n: I18n,
59 private videoPlaylistService: VideoPlaylistService,
60 private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
61 private cd: ChangeDetectorRef
62 ) {
63 super()
64 }
65
66 get user () {
67 return this.authService.getUser()
68 }
69
70 ngOnInit () {
71 this.buildForm({
72 displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
73 })
74
75 this.videoPlaylistService.listenToMyAccountPlaylistsChange()
76 .subscribe(result => {
77 this.playlistsData = result.data
78
79 this.videoPlaylistService.runPlaylistCheck(this.video.id)
80 })
81
82 this.videoPlaylistSearchChanged
83 .pipe(debounceTime(500))
84 .subscribe(() => this.load())
85
86 if (this.lazyLoad === false) this.load()
87 }
88
89 ngOnChanges (simpleChanges: SimpleChanges) {
90 if (simpleChanges['video']) {
91 this.reload()
92 }
93 }
94
95 ngOnDestroy () {
96 this.unsubscribePlaylistChanges()
97 }
98
99 disableForReuse () {
100 this.disabled = true
101 }
102
103 enabledForReuse () {
104 this.disabled = false
105 }
106
107 reload () {
108 logger('Reloading component')
109
110 this.videoPlaylists = []
111 this.videoPlaylistSearch = undefined
112
113 this.resetOptions(true)
114 this.load()
115
116 this.cd.markForCheck()
117 }
118
119 load () {
120 logger('Loading component')
121
122 this.listenToPlaylistChanges()
123
124 this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
125 .subscribe(playlistsResult => {
126 this.playlistsData = playlistsResult.data
127
128 this.videoPlaylistService.runPlaylistCheck(this.video.id)
129 })
130 }
131
132 openChange (opened: boolean) {
133 if (opened === false) {
134 this.isNewPlaylistBlockOpened = false
135 this.displayOptions = false
136 }
137 }
138
139 openCreateBlock (event: Event) {
140 event.preventDefault()
141
142 this.isNewPlaylistBlockOpened = true
143 }
144
145 togglePlaylist (event: Event, playlist: PlaylistSummary) {
146 event.preventDefault()
147
148 if (playlist.inPlaylist === true) {
149 this.removeVideoFromPlaylist(playlist)
150 } else {
151 this.addVideoInPlaylist(playlist)
152 }
153
154 playlist.inPlaylist = !playlist.inPlaylist
155 this.resetOptions()
156
157 this.cd.markForCheck()
158 }
159
160 createPlaylist () {
161 const displayName = this.form.value[ 'displayName' ]
162
163 const videoPlaylistCreate: VideoPlaylistCreate = {
164 displayName,
165 privacy: VideoPlaylistPrivacy.PRIVATE
166 }
167
168 this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
169 () => {
170 this.isNewPlaylistBlockOpened = false
171
172 this.cd.markForCheck()
173 },
174
175 err => this.notifier.error(err.message)
176 )
177 }
178
179 resetOptions (resetTimestamp = false) {
180 this.displayOptions = false
181
182 this.timestampOptions = {} as any
183 this.timestampOptions.startTimestampEnabled = false
184 this.timestampOptions.stopTimestampEnabled = false
185
186 if (resetTimestamp) {
187 this.timestampOptions.startTimestamp = 0
188 this.timestampOptions.stopTimestamp = this.video.duration
189 }
190 }
191
192 formatTimestamp (playlist: PlaylistSummary) {
193 const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
194 const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
195
196 return `(${start}-${stop})`
197 }
198
199 onVideoPlaylistSearchChanged () {
200 this.videoPlaylistSearchChanged.next()
201 }
202
203 private removeVideoFromPlaylist (playlist: PlaylistSummary) {
204 if (!playlist.playlistElementId) return
205
206 this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id)
207 .subscribe(
208 () => {
209 this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
210 },
211
212 err => {
213 this.notifier.error(err.message)
214 },
215
216 () => this.cd.markForCheck()
217 )
218 }
219
220 private listenToPlaylistChanges () {
221 this.unsubscribePlaylistChanges()
222
223 this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
224 .pipe(filter(() => this.disabled === false))
225 .subscribe(existResult => this.rebuildPlaylists(existResult))
226 }
227
228 private unsubscribePlaylistChanges () {
229 if (this.listenToPlaylistChangeSub) {
230 this.listenToPlaylistChangeSub.unsubscribe()
231 this.listenToPlaylistChangeSub = undefined
232 }
233 }
234
235 private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
236 logger('Got existing results for %d.', this.video.id, existResult)
237
238 this.videoPlaylists = []
239 for (const playlist of this.playlistsData) {
240 const existingPlaylist = existResult.find(p => p.playlistId === playlist.id)
241
242 this.videoPlaylists.push({
243 id: playlist.id,
244 displayName: playlist.displayName,
245 inPlaylist: !!existingPlaylist,
246 playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined,
247 startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
248 stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
249 })
250 }
251
252 logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists)
253
254 this.cd.markForCheck()
255 }
256
257 private addVideoInPlaylist (playlist: PlaylistSummary) {
258 const body: VideoPlaylistElementCreate = { videoId: this.video.id }
259
260 if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
261 if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
262
263 this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
264 .subscribe(
265 () => {
266 const message = body.startTimestamp || body.stopTimestamp
267 ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
268 : this.i18n('Video added in {{n}}', { n: playlist.displayName })
269
270 this.notifier.success(message)
271 },
272
273 err => {
274 this.notifier.error(err.message)
275 },
276
277 () => this.cd.markForCheck()
278 )
279 }
280}
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
deleted file mode 100644
index e3f7ef017..000000000
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
+++ /dev/null
@@ -1,92 +0,0 @@
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/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
deleted file mode 100644
index afd775b25..000000000
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
+++ /dev/null
@@ -1,224 +0,0 @@
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/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
deleted file mode 100644
index fad03e045..000000000
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
+++ /dev/null
@@ -1,187 +0,0 @@
1import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { Video } from '@app/shared/video/video.model'
3import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
5import { ActivatedRoute } from '@angular/router'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { VideoService } from '@app/shared/video/video.service'
8import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
9import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
10import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
11import { secondsToTime } from '../../../assets/player/utils'
12import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
13
14@Component({
15 selector: 'my-video-playlist-element-miniature',
16 styleUrls: [ './video-playlist-element-miniature.component.scss' ],
17 templateUrl: './video-playlist-element-miniature.component.html',
18 changeDetection: ChangeDetectionStrategy.OnPush
19})
20export class VideoPlaylistElementMiniatureComponent implements OnInit {
21 @ViewChild('moreDropdown') moreDropdown: NgbDropdown
22
23 @Input() playlist: VideoPlaylist
24 @Input() playlistElement: VideoPlaylistElement
25 @Input() owned = false
26 @Input() playing = false
27 @Input() rowLink = false
28 @Input() accountLink = true
29 @Input() position: number // Keep this property because we're in the OnPush change detection strategy
30 @Input() touchScreenEditButton = false
31
32 @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
33
34 displayTimestampOptions = false
35
36 timestampOptions: {
37 startTimestampEnabled: boolean
38 startTimestamp: number
39 stopTimestampEnabled: boolean
40 stopTimestamp: number
41 } = {} as any
42
43 private serverConfig: ServerConfig
44
45 constructor (
46 private authService: AuthService,
47 private serverService: ServerService,
48 private notifier: Notifier,
49 private confirmService: ConfirmService,
50 private route: ActivatedRoute,
51 private i18n: I18n,
52 private videoService: VideoService,
53 private videoPlaylistService: VideoPlaylistService,
54 private cdr: ChangeDetectorRef
55 ) {}
56
57 ngOnInit (): void {
58 this.serverConfig = this.serverService.getTmpConfig()
59 this.serverService.getConfig()
60 .subscribe(config => {
61 this.serverConfig = config
62 this.cdr.detectChanges()
63 })
64 }
65
66 isUnavailable (e: VideoPlaylistElement) {
67 return e.type === VideoPlaylistElementType.UNAVAILABLE
68 }
69
70 isPrivate (e: VideoPlaylistElement) {
71 return e.type === VideoPlaylistElementType.PRIVATE
72 }
73
74 isDeleted (e: VideoPlaylistElement) {
75 return e.type === VideoPlaylistElementType.DELETED
76 }
77
78 buildRouterLink () {
79 if (!this.playlist) return null
80
81 return [ '/videos/watch/playlist', this.playlist.uuid ]
82 }
83
84 buildRouterQuery () {
85 if (!this.playlistElement || !this.playlistElement.video) return {}
86
87 return {
88 videoId: this.playlistElement.video.uuid,
89 start: this.playlistElement.startTimestamp,
90 stop: this.playlistElement.stopTimestamp,
91 resume: true
92 }
93 }
94
95 isVideoBlur (video: Video) {
96 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig)
97 }
98
99 removeFromPlaylist (playlistElement: VideoPlaylistElement) {
100 const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined
101
102 this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId)
103 .subscribe(
104 () => {
105 this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
106
107 this.elementRemoved.emit(playlistElement)
108 },
109
110 err => this.notifier.error(err.message)
111 )
112
113 this.moreDropdown.close()
114 }
115
116 updateTimestamps (playlistElement: VideoPlaylistElement) {
117 const body: VideoPlaylistElementUpdate = {}
118
119 body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
120 body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
121
122 this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id)
123 .subscribe(
124 () => {
125 this.notifier.success(this.i18n('Timestamps updated'))
126
127 playlistElement.startTimestamp = body.startTimestamp
128 playlistElement.stopTimestamp = body.stopTimestamp
129
130 this.cdr.detectChanges()
131 },
132
133 err => this.notifier.error(err.message)
134 )
135
136 this.moreDropdown.close()
137 }
138
139 formatTimestamp (playlistElement: VideoPlaylistElement) {
140 const start = playlistElement.startTimestamp
141 const stop = playlistElement.stopTimestamp
142
143 const startFormatted = secondsToTime(start, true, ':')
144 const stopFormatted = secondsToTime(stop, true, ':')
145
146 if (start === null && stop === null) return ''
147
148 if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
149 if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
150
151 return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
152 }
153
154 onDropdownOpenChange () {
155 this.displayTimestampOptions = false
156 }
157
158 toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
159 event.preventDefault()
160
161 this.displayTimestampOptions = !this.displayTimestampOptions
162
163 if (this.displayTimestampOptions === true) {
164 this.timestampOptions = {
165 startTimestampEnabled: false,
166 stopTimestampEnabled: false,
167 startTimestamp: 0,
168 stopTimestamp: playlistElement.video.duration
169 }
170
171 if (playlistElement.startTimestamp) {
172 this.timestampOptions.startTimestampEnabled = true
173 this.timestampOptions.startTimestamp = playlistElement.startTimestamp
174 }
175
176 if (playlistElement.stopTimestamp) {
177 this.timestampOptions.stopTimestampEnabled = true
178 this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
179 }
180 }
181
182 // FIXME: why do we have to use setTimeout here?
183 setTimeout(() => {
184 this.cdr.detectChanges()
185 })
186 }
187}
diff --git a/client/src/app/shared/video-playlist/video-playlist-element.model.ts b/client/src/app/shared/video-playlist/video-playlist-element.model.ts
deleted file mode 100644
index f1c46d1eb..000000000
--- a/client/src/app/shared/video-playlist/video-playlist-element.model.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
2import { Video } from '@app/shared/video/video.model'
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/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
deleted file mode 100644
index 86f6664cb..000000000
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
+++ /dev/null
@@ -1,34 +0,0 @@
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/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
deleted file mode 100644
index 1b16dbb01..000000000
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
+++ /dev/null
@@ -1,78 +0,0 @@
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/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
deleted file mode 100644
index 523e96f2a..000000000
--- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
+++ /dev/null
@@ -1,22 +0,0 @@
1import { Component, Input } from '@angular/core'
2import { VideoPlaylist } from '@app/shared/video-playlist/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/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
deleted file mode 100644
index 6f27e7475..000000000
--- a/client/src/app/shared/video-playlist/video-playlist.model.ts
+++ /dev/null
@@ -1,97 +0,0 @@
1import {
2 VideoChannelSummary,
3 VideoConstant,
4 VideoPlaylist as ServerVideoPlaylist,
5 VideoPlaylistPrivacy,
6 VideoPlaylistType
7} from '../../../../../shared/models/videos'
8import { AccountSummary, peertubeTranslate } from '@shared/models'
9import { Actor } from '@app/shared/actor/actor.model'
10import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
11
12export class VideoPlaylist implements ServerVideoPlaylist {
13 id: number
14 uuid: string
15 isLocal: boolean
16
17 displayName: string
18 description: string
19 privacy: VideoConstant<VideoPlaylistPrivacy>
20
21 thumbnailPath: string
22
23 videosLength: number
24
25 type: VideoConstant<VideoPlaylistType>
26
27 createdAt: Date | string
28 updatedAt: Date | string
29
30 ownerAccount: AccountSummary
31 videoChannel?: VideoChannelSummary
32
33 thumbnailUrl: string
34
35 ownerBy: string
36 ownerAvatarUrl: string
37
38 videoChannelBy?: string
39 videoChannelAvatarUrl?: string
40
41 private thumbnailVersion: number
42 private originThumbnailUrl: string
43
44 constructor (hash: ServerVideoPlaylist, translations: {}) {
45 const absoluteAPIUrl = getAbsoluteAPIUrl()
46
47 this.id = hash.id
48 this.uuid = hash.uuid
49 this.isLocal = hash.isLocal
50
51 this.displayName = hash.displayName
52
53 this.description = hash.description
54 this.privacy = hash.privacy
55
56 this.thumbnailPath = hash.thumbnailPath
57
58 if (this.thumbnailPath) {
59 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
60 this.originThumbnailUrl = this.thumbnailUrl
61 } else {
62 this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
63 }
64
65 this.videosLength = hash.videosLength
66
67 this.type = hash.type
68
69 this.createdAt = new Date(hash.createdAt)
70 this.updatedAt = new Date(hash.updatedAt)
71
72 this.ownerAccount = hash.ownerAccount
73 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
74 this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
75
76 if (hash.videoChannel) {
77 this.videoChannel = hash.videoChannel
78 this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
79 this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
80 }
81
82 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
83
84 if (this.type.id === VideoPlaylistType.WATCH_LATER) {
85 this.displayName = peertubeTranslate(this.displayName, translations)
86 }
87 }
88
89 refreshThumbnail () {
90 if (!this.originThumbnailUrl) return
91
92 if (!this.thumbnailVersion) this.thumbnailVersion = 0
93 this.thumbnailVersion++
94
95 this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
96 }
97}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
deleted file mode 100644
index 38d915c6b..000000000
--- a/client/src/app/shared/video-playlist/video-playlist.service.ts
+++ /dev/null
@@ -1,357 +0,0 @@
1import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
2import { Injectable, NgZone } from '@angular/core'
3import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
4import { RestExtractor } from '../rest/rest-extractor.service'
5import { HttpClient, HttpParams } from '@angular/common/http'
6import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
7import { environment } from '../../../environments/environment'
8import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
9import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
10import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
11import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
12import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
13import { objectToFormData } from '@app/shared/misc/utils'
14import { AuthUser, ServerService } from '@app/core'
15import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
16import { AccountService } from '@app/shared/account/account.service'
17import { Account } from '@app/shared/account/account.model'
18import { RestService } from '@app/shared/rest'
19import { VideoExistInPlaylist, VideosExistInPlaylists } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
20import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model'
21import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
22import { VideoPlaylistElement as ServerVideoPlaylistElement } from '@shared/models/videos/playlist/video-playlist-element.model'
23import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
24import { uniq } from 'lodash-es'
25import * as debug from 'debug'
26import { enterZone, leaveZone } from '@app/shared/rxjs/zone'
27
28const logger = debug('peertube:playlists:VideoPlaylistService')
29
30export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string }
31
32@Injectable()
33export class VideoPlaylistService {
34 static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
35 static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
36
37 // Use a replay subject because we "next" a value before subscribing
38 private videoExistsInPlaylistNotifier = new ReplaySubject<number>(1)
39 private videoExistsInPlaylistCacheSubject = new Subject<VideosExistInPlaylists>()
40 private readonly videoExistsInPlaylistObservable: Observable<VideosExistInPlaylists>
41
42 private videoExistsObservableCache: { [ id: number ]: Observable<VideoExistInPlaylist[]> } = {}
43 private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {}
44
45 private myAccountPlaylistCache: ResultList<CachedPlaylist> = undefined
46 private myAccountPlaylistCacheRunning: Observable<ResultList<CachedPlaylist>>
47 private myAccountPlaylistCacheSubject = new Subject<ResultList<CachedPlaylist>>()
48
49 constructor (
50 private authHttp: HttpClient,
51 private serverService: ServerService,
52 private restExtractor: RestExtractor,
53 private restService: RestService,
54 private ngZone: NgZone
55 ) {
56 this.videoExistsInPlaylistObservable = merge(
57 this.videoExistsInPlaylistNotifier.pipe(
58 // We leave Angular zone so Protractor does not get stuck
59 bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
60 filter(videoIds => videoIds.length !== 0),
61 map(videoIds => uniq(videoIds)),
62 observeOn(enterZone(this.ngZone, asyncScheduler)),
63 switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
64 share()
65 ),
66
67 this.videoExistsInPlaylistCacheSubject
68 )
69 }
70
71 listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
72 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
73 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
74
75 let params = new HttpParams()
76 params = this.restService.addRestGetParams(params, pagination)
77
78 return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
79 .pipe(
80 switchMap(res => this.extractPlaylists(res)),
81 catchError(err => this.restExtractor.handleError(err))
82 )
83 }
84
85 listMyPlaylistWithCache (user: AuthUser, search?: string) {
86 if (!search) {
87 if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning
88 if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
89 }
90
91 const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
92 .pipe(
93 tap(result => {
94 if (!search) {
95 this.myAccountPlaylistCacheRunning = undefined
96 this.myAccountPlaylistCache = result
97 }
98 }),
99 share()
100 )
101
102 if (!search) this.myAccountPlaylistCacheRunning = obs
103 return obs
104 }
105
106 listAccountPlaylists (
107 account: Account,
108 componentPagination: ComponentPaginationLight,
109 sort: string,
110 search?: string
111 ): Observable<ResultList<VideoPlaylist>> {
112 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
113 const pagination = componentPagination
114 ? this.restService.componentPaginationToRestPagination(componentPagination)
115 : undefined
116
117 let params = new HttpParams()
118 params = this.restService.addRestGetParams(params, pagination, sort)
119 if (search) params = this.restService.addObjectParams(params, { search })
120
121 return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
122 .pipe(
123 switchMap(res => this.extractPlaylists(res)),
124 catchError(err => this.restExtractor.handleError(err))
125 )
126 }
127
128 getVideoPlaylist (id: string | number) {
129 const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
130
131 return this.authHttp.get<VideoPlaylist>(url)
132 .pipe(
133 switchMap(res => this.extractPlaylist(res)),
134 catchError(err => this.restExtractor.handleError(err))
135 )
136 }
137
138 createVideoPlaylist (body: VideoPlaylistCreate) {
139 const data = objectToFormData(body)
140
141 return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
142 .pipe(
143 tap(res => {
144 if (!this.myAccountPlaylistCache) return
145
146 this.myAccountPlaylistCache.total++
147
148 this.myAccountPlaylistCache.data.push({
149 id: res.videoPlaylist.id,
150 displayName: body.displayName
151 })
152
153 this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
154 }),
155 catchError(err => this.restExtractor.handleError(err))
156 )
157 }
158
159 updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
160 const data = objectToFormData(body)
161
162 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
163 .pipe(
164 map(this.restExtractor.extractDataBool),
165 tap(() => {
166 if (!this.myAccountPlaylistCache) return
167
168 const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id)
169 playlist.displayName = body.displayName
170
171 this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
172 }),
173 catchError(err => this.restExtractor.handleError(err))
174 )
175 }
176
177 removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
178 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
179 .pipe(
180 map(this.restExtractor.extractDataBool),
181 tap(() => {
182 if (!this.myAccountPlaylistCache) return
183
184 this.myAccountPlaylistCache.total--
185 this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data
186 .filter(p => p.id !== videoPlaylist.id)
187
188 this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
189 }),
190 catchError(err => this.restExtractor.handleError(err))
191 )
192 }
193
194 addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
195 const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos'
196
197 return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body)
198 .pipe(
199 tap(res => {
200 const existsResult = this.videoExistsCache[body.videoId]
201 existsResult.push({
202 playlistId,
203 playlistElementId: res.videoPlaylistElement.id,
204 startTimestamp: body.startTimestamp,
205 stopTimestamp: body.stopTimestamp
206 })
207
208 this.runPlaylistCheck(body.videoId)
209 }),
210 catchError(err => this.restExtractor.handleError(err))
211 )
212 }
213
214 updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) {
215 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
216 .pipe(
217 map(this.restExtractor.extractDataBool),
218 tap(() => {
219 const existsResult = this.videoExistsCache[videoId]
220 const elem = existsResult.find(e => e.playlistElementId === playlistElementId)
221
222 elem.startTimestamp = body.startTimestamp
223 elem.stopTimestamp = body.stopTimestamp
224
225 this.runPlaylistCheck(videoId)
226 }),
227 catchError(err => this.restExtractor.handleError(err))
228 )
229 }
230
231 removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) {
232 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
233 .pipe(
234 map(this.restExtractor.extractDataBool),
235 tap(() => {
236 if (!videoId) return
237
238 this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId)
239 this.runPlaylistCheck(videoId)
240 }),
241 catchError(err => this.restExtractor.handleError(err))
242 )
243 }
244
245 reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
246 const body: VideoPlaylistReorder = {
247 startPosition: oldPosition,
248 insertAfterPosition: newPosition
249 }
250
251 return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body)
252 .pipe(
253 map(this.restExtractor.extractDataBool),
254 catchError(err => this.restExtractor.handleError(err))
255 )
256 }
257
258 getPlaylistVideos (
259 videoPlaylistId: number | string,
260 componentPagination: ComponentPaginationLight
261 ): Observable<ResultList<VideoPlaylistElement>> {
262 const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
263 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
264
265 let params = new HttpParams()
266 params = this.restService.addRestGetParams(params, pagination)
267
268 return this.authHttp
269 .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
270 .pipe(
271 switchMap(res => this.extractVideoPlaylistElements(res)),
272 catchError(err => this.restExtractor.handleError(err))
273 )
274 }
275
276 listenToMyAccountPlaylistsChange () {
277 return this.myAccountPlaylistCacheSubject.asObservable()
278 }
279
280 listenToVideoPlaylistChange (videoId: number) {
281 if (this.videoExistsObservableCache[ videoId ]) {
282 return this.videoExistsObservableCache[ videoId ]
283 }
284
285 const obs = this.videoExistsInPlaylistObservable
286 .pipe(
287 map(existsResult => existsResult[ videoId ]),
288 filter(r => !!r),
289 tap(result => this.videoExistsCache[ videoId ] = result)
290 )
291
292 this.videoExistsObservableCache[ videoId ] = obs
293 return obs
294 }
295
296 runPlaylistCheck (videoId: number) {
297 logger('Running playlist check.')
298
299 if (this.videoExistsCache[videoId]) {
300 logger('Found cache for %d.', videoId)
301
302 return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] })
303 }
304
305 logger('Fetching from network for %d.', videoId)
306 return this.videoExistsInPlaylistNotifier.next(videoId)
307 }
308
309 extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
310 return this.serverService.getServerLocale()
311 .pipe(
312 map(translations => {
313 const playlistsJSON = result.data
314 const total = result.total
315 const playlists: VideoPlaylist[] = []
316
317 for (const playlistJSON of playlistsJSON) {
318 playlists.push(new VideoPlaylist(playlistJSON, translations))
319 }
320
321 return { data: playlists, total }
322 })
323 )
324 }
325
326 extractPlaylist (playlist: VideoPlaylistServerModel) {
327 return this.serverService.getServerLocale()
328 .pipe(map(translations => new VideoPlaylist(playlist, translations)))
329 }
330
331 extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
332 return this.serverService.getServerLocale()
333 .pipe(
334 map(translations => {
335 const elementsJson = result.data
336 const total = result.total
337 const elements: VideoPlaylistElement[] = []
338
339 for (const elementJson of elementsJson) {
340 elements.push(new VideoPlaylistElement(elementJson, translations))
341 }
342
343 return { total, data: elements }
344 })
345 )
346 }
347
348 private doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
349 const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
350
351 let params = new HttpParams()
352 params = this.restService.addObjectParams(params, { videoIds })
353
354 return this.authHttp.get<VideoExistInPlaylist>(url, { params, headers: { ignoreLoadingBar: '' } })
355 .pipe(catchError(err => this.restExtractor.handleError(err)))
356 }
357}