From f0a3988066f72a28bb44520af072f18d91d77dde Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 7 Mar 2019 17:06:00 +0100 Subject: [PATCH] Add to playlist dropdown --- CREDITS.md | 7 +- .../+my-account/my-account-routing.module.ts | 12 ++ ...nt-notification-preferences.component.scss | 2 +- ...account-video-playlist-edit.component.html | 1 + ...unt-video-playlist-elements.component.html | 16 ++ ...unt-video-playlist-elements.component.scss | 2 + ...count-video-playlist-elements.component.ts | 62 ++++++ .../my-account-video-playlists.component.html | 4 +- .../my-account-video-playlists.component.ts | 17 +- .../src/app/+my-account/my-account.module.ts | 6 +- .../forms/timestamp-input.component.html | 4 + .../forms/timestamp-input.component.scss | 8 + .../shared/forms/timestamp-input.component.ts | 61 ++++++ .../shared/images/global-icon.component.html | 0 .../shared/images/global-icon.component.ts | 3 +- client/src/app/shared/shared.module.ts | 20 ++ .../subscribe-button.component.ts | 2 +- .../user-subscription.service.ts | 6 +- .../users/user-notifications.component.scss | 2 +- .../video-add-to-playlist.component.html | 74 +++++++ .../video-add-to-playlist.component.scss | 98 +++++++++ .../video-add-to-playlist.component.ts | 195 ++++++++++++++++++ .../video-playlist-miniature.component.html | 6 +- .../video-playlist-miniature.component.scss | 11 + .../video-playlist-miniature.component.ts | 8 + .../video-playlist/video-playlist.model.ts | 5 + .../video-playlist/video-playlist.service.ts | 77 ++++++- client/src/app/shared/video/video.service.ts | 19 ++ .../modal/video-share.component.html | 12 +- .../modal/video-share.component.scss | 5 + .../modal/video-share.component.ts | 11 +- .../video-watch-routing.module.ts | 11 +- .../+video-watch/video-watch.component.html | 20 +- .../+video-watch/video-watch.component.scss | 11 +- .../+video-watch/video-watch.component.ts | 14 +- .../src/app/videos/videos-routing.module.ts | 6 +- client/src/assets/images/global/add.html | 6 +- .../src/assets/images/video/playlist-add.html | 10 + .../src/assets/images/video/watch-later.html | 11 + .../assets/player/peertube-player-manager.ts | 9 +- client/src/assets/player/peertube-plugin.ts | 16 +- .../assets/player/peertube-videojs-typings.ts | 7 +- client/src/assets/player/utils.ts | 20 +- .../player/webtorrent/webtorrent-plugin.ts | 4 +- client/src/sass/include/_mixins.scss | 1 - client/src/sass/include/_variables.scss | 2 + client/src/standalone/videos/embed.ts | 3 + server/controllers/api/users/index.ts | 2 + .../api/users/my-video-playlists.ts | 47 +++++ server/controllers/api/video-playlist.ts | 34 ++- server/helpers/custom-validators/misc.ts | 10 +- server/initializers/constants.ts | 2 +- .../validators/videos/video-playlists.ts | 23 ++- server/models/video/video-playlist.ts | 23 +++ .../playlist/video-exist-in-playlist.model.ts | 7 + 55 files changed, 961 insertions(+), 94 deletions(-) create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts create mode 100644 client/src/app/shared/forms/timestamp-input.component.html create mode 100644 client/src/app/shared/forms/timestamp-input.component.scss create mode 100644 client/src/app/shared/forms/timestamp-input.component.ts delete mode 100644 client/src/app/shared/images/global-icon.component.html create mode 100644 client/src/app/shared/video-playlist/video-add-to-playlist.component.html create mode 100644 client/src/app/shared/video-playlist/video-add-to-playlist.component.scss create mode 100644 client/src/app/shared/video-playlist/video-add-to-playlist.component.ts create mode 100644 client/src/assets/images/video/playlist-add.html create mode 100644 client/src/assets/images/video/watch-later.html create mode 100644 server/controllers/api/users/my-video-playlists.ts create mode 100644 shared/models/videos/playlist/video-exist-in-playlist.model.ts diff --git a/CREDITS.md b/CREDITS.md index 716f3fca2..1f7aaad7a 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -206,6 +206,9 @@ # Design -By [Olivier Massain](https://twitter.com/omassain) + * [Olivier Massain](https://twitter.com/omassain) -Icons from [Robbie Pearce](https://robbiepearce.com/softies/) +# Icons + + * [Robbie Pearce](https://robbiepearce.com/softies/) + * playlist add by Google diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index 0193afff7..3f921b13f 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts @@ -22,6 +22,9 @@ import { import { MyAccountVideoPlaylistUpdateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' +import { + MyAccountVideoPlaylistElementsComponent +} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' const myAccountRoutes: Routes = [ { @@ -81,6 +84,15 @@ const myAccountRoutes: Routes = [ } } }, + { + path: 'video-playlists/:videoPlaylistId', + component: MyAccountVideoPlaylistElementsComponent, + data: { + meta: { + title: 'Playlist elements' + } + } + }, { path: 'video-playlists/create', component: MyAccountVideoPlaylistCreateComponent, diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss index 6feb16ab1..0274f47c5 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss @@ -4,7 +4,7 @@ .custom-row { display: flex; align-items: center; - border-bottom: 1px solid rgba(0, 0, 0, 0.10); + border-bottom: 1px solid $separator-border-color; &:first-child { font-size: 16px; diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html index b76488c78..5d1184218 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html @@ -60,5 +60,6 @@ + diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html new file mode 100644 index 000000000..28ea7a857 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html @@ -0,0 +1,16 @@ +
No videos in this playlist.
+ +
+
+ + +
+
{{ video.playlistElement.position }}
+ + {{ video.name }} + + {{ video.name }} + +
+
+
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss new file mode 100644 index 000000000..5e6774739 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss @@ -0,0 +1,2 @@ +@import '_variables'; +@import '_mixins'; diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts new file mode 100644 index 000000000..8b70a9b1a --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts @@ -0,0 +1,62 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { Notifier } from '@app/core' +import { AuthService } from '../../core/auth' +import { ConfirmService } from '../../core/confirm' +import { ComponentPagination } from '@app/shared/rest/component-pagination.model' +import { Video } from '@app/shared/video/video.model' +import { Subscription } from 'rxjs' +import { ActivatedRoute } from '@angular/router' +import { VideoService } from '@app/shared/video/video.service' + +@Component({ + selector: 'my-account-video-playlist-elements', + templateUrl: './my-account-video-playlist-elements.component.html', + styleUrls: [ './my-account-video-playlist-elements.component.scss' ] +}) +export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy { + videos: Video[] = [] + + pagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 10, + totalItems: null + } + + private videoPlaylistId: string | number + private paramsSub: Subscription + + constructor ( + private authService: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private route: ActivatedRoute, + private videoService: VideoService + ) {} + + ngOnInit () { + this.paramsSub = this.route.params.subscribe(routeParams => { + this.videoPlaylistId = routeParams[ 'videoPlaylistId' ] + this.loadElements() + }) + } + + ngOnDestroy () { + if (this.paramsSub) this.paramsSub.unsubscribe() + } + + onNearOfBottom () { + // Last page + if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return + + this.pagination.currentPage += 1 + this.loadElements() + } + + private loadElements () { + this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination) + .subscribe(({ totalVideos, videos }) => { + this.videos = this.videos.concat(videos) + this.pagination.totalItems = totalVideos + }) + } +} diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html index ab5d9cc5a..7d1bed12a 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html @@ -5,10 +5,10 @@ -
+
- +
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts index 761ce90e8..e30656b92 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts @@ -69,17 +69,20 @@ export class MyAccountVideoPlaylistsComponent implements OnInit { return playlist.type.id === VideoPlaylistType.REGULAR } - private loadVideoPlaylists () { - this.authService.userInformationLoaded - .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account))) - .subscribe(res => this.videoPlaylists = res.data) - } - - private ofNearOfBottom () { + onNearOfBottom () { // Last page if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return this.pagination.currentPage += 1 this.loadVideoPlaylists() } + + private loadVideoPlaylists () { + this.authService.userInformationLoaded + .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt'))) + .subscribe(res => { + this.videoPlaylists = this.videoPlaylists.concat(res.data) + this.pagination.totalItems = res.total + }) + } } diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 3dbce2b92..ba8300111 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -32,6 +32,9 @@ import { MyAccountVideoPlaylistUpdateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' +import { + MyAccountVideoPlaylistElementsComponent +} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' @NgModule({ imports: [ @@ -68,7 +71,8 @@ import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-vi MyAccountVideoPlaylistCreateComponent, MyAccountVideoPlaylistUpdateComponent, - MyAccountVideoPlaylistsComponent + MyAccountVideoPlaylistsComponent, + MyAccountVideoPlaylistElementsComponent ], exports: [ diff --git a/client/src/app/shared/forms/timestamp-input.component.html b/client/src/app/shared/forms/timestamp-input.component.html new file mode 100644 index 000000000..c57a4b32c --- /dev/null +++ b/client/src/app/shared/forms/timestamp-input.component.html @@ -0,0 +1,4 @@ + diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss new file mode 100644 index 000000000..7115777fd --- /dev/null +++ b/client/src/app/shared/forms/timestamp-input.component.scss @@ -0,0 +1,8 @@ +p-inputmask { + /deep/ input { + width: 80px; + font-size: 15px; + + border: none; + } +} diff --git a/client/src/app/shared/forms/timestamp-input.component.ts b/client/src/app/shared/forms/timestamp-input.component.ts new file mode 100644 index 000000000..8d67a96ac --- /dev/null +++ b/client/src/app/shared/forms/timestamp-input.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { secondsToTime, timeToInt } from '../../../assets/player/utils' + +@Component({ + selector: 'my-timestamp-input', + styleUrls: [ './timestamp-input.component.scss' ], + templateUrl: './timestamp-input.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimestampInputComponent), + multi: true + } + ] +}) +export class TimestampInputComponent implements ControlValueAccessor, OnInit { + @Input() maxTimestamp: number + @Input() timestamp: number + @Input() disabled = false + + timestampString: string + + constructor (private changeDetector: ChangeDetectorRef) {} + + ngOnInit () { + this.writeValue(this.timestamp || 0) + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (timestamp: number) { + this.timestamp = timestamp + + this.timestampString = secondsToTime(this.timestamp, true, ':') + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.timestamp = timeToInt(this.timestampString) + + this.propagateChange(this.timestamp) + } + + onBlur () { + if (this.maxTimestamp && this.timestamp > this.maxTimestamp) { + this.writeValue(this.maxTimestamp) + + this.changeDetector.detectChanges() + + this.propagateChange(this.timestamp) + } + } +} diff --git a/client/src/app/shared/images/global-icon.component.html b/client/src/app/shared/images/global-icon.component.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts index e8ada0324..3fda7ee4d 100644 --- a/client/src/app/shared/images/global-icon.component.ts +++ b/client/src/app/shared/images/global-icon.component.ts @@ -25,7 +25,8 @@ const icons = { 'like': require('../../../assets/images/video/like.html'), 'more': require('../../../assets/images/video/more.html'), 'share': require('../../../assets/images/video/share.html'), - 'upload': require('../../../assets/images/video/upload.html') + 'upload': require('../../../assets/images/video/upload.html'), + 'playlist-add': require('../../../assets/images/video/playlist-add.html') } export type GlobalIconName = keyof typeof icons diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 60a7bd6e2..1f9eee0b7 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -9,6 +9,7 @@ import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.d import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' +import { KeyFilterModule } from 'primeng/keyfilter' import { AUTH_INTERCEPTOR_PROVIDER } from './auth' import { ButtonComponent } from './buttons/button.component' @@ -49,6 +50,7 @@ import { VideoValidatorsService } from '@app/shared/forms' import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' +import { InputMaskModule } from 'primeng/inputmask' import { ScreenService } from '@app/shared/misc/screen.service' import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' import { VideoCaptionService } from '@app/shared/video-caption' @@ -74,6 +76,8 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist. import { ImageUploadComponent } from '@app/shared/images/image-upload.component' import { GlobalIconComponent } from '@app/shared/images/global-icon.component' import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' +import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' +import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' @NgModule({ imports: [ @@ -90,6 +94,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide NgbTooltipModule, PrimeSharedModule, + InputMaskModule, + KeyFilterModule, NgPipesModule ], @@ -100,11 +106,14 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide VideoThumbnailComponent, VideoMiniatureComponent, VideoPlaylistMiniatureComponent, + VideoAddToPlaylistComponent, FeedComponent, + ButtonComponent, DeleteButtonComponent, EditButtonComponent, + ActionDropdownComponent, NumberFormatterPipe, ObjectLengthPipe, @@ -113,8 +122,11 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide InfiniteScrollerDirective, TextareaAutoResizeDirective, HelpComponent, + ReactiveFileComponent, PeertubeCheckboxComponent, + TimestampInputComponent, + SubscribeButtonComponent, RemoteSubscribeComponent, InstanceFeaturesTableComponent, @@ -142,6 +154,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide NgbTooltipModule, PrimeSharedModule, + InputMaskModule, + KeyFilterModule, BytesPipe, KeysPipe, @@ -151,18 +165,24 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide VideoThumbnailComponent, VideoMiniatureComponent, VideoPlaylistMiniatureComponent, + VideoAddToPlaylistComponent, FeedComponent, + ButtonComponent, DeleteButtonComponent, EditButtonComponent, + ActionDropdownComponent, MarkdownTextareaComponent, InfiniteScrollerDirective, TextareaAutoResizeDirective, HelpComponent, + ReactiveFileComponent, PeertubeCheckboxComponent, + TimestampInputComponent, + SubscribeButtonComponent, RemoteSubscribeComponent, InstanceFeaturesTableComponent, diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts index 8f1754c7f..ef470ee44 100644 --- a/client/src/app/shared/user-subscription/subscribe-button.component.ts +++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts @@ -38,7 +38,7 @@ export class SubscribeButtonComponent implements OnInit { ngOnInit () { if (this.isUserLoggedIn()) { - this.userSubscriptionService.isSubscriptionExists(this.uri) + this.userSubscriptionService.doesSubscriptionExist(this.uri) .subscribe( res => this.subscribed = res[this.uri], diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts index 3d05f071e..cfd5b100f 100644 --- a/client/src/app/shared/user-subscription/user-subscription.service.ts +++ b/client/src/app/shared/user-subscription/user-subscription.service.ts @@ -28,7 +28,7 @@ export class UserSubscriptionService { this.existsObservable = this.existsSubject.pipe( bufferTime(500), filter(uris => uris.length !== 0), - switchMap(uris => this.areSubscriptionExist(uris)), + switchMap(uris => this.doSubscriptionsExist(uris)), share() ) } @@ -69,13 +69,13 @@ export class UserSubscriptionService { ) } - isSubscriptionExists (nameWithHost: string) { + doesSubscriptionExist (nameWithHost: string) { this.existsSubject.next(nameWithHost) return this.existsObservable.pipe(first()) } - private areSubscriptionExist (uris: string[]): Observable { + private doSubscriptionsExist (uris: string[]): Observable { const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' let params = new HttpParams() diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss index 315d504c9..88f38d9cf 100644 --- a/client/src/app/shared/users/user-notifications.component.scss +++ b/client/src/app/shared/users/user-notifications.component.scss @@ -13,7 +13,7 @@ align-items: center; font-size: inherit; padding: 15px 5px 15px 10px; - border-bottom: 1px solid rgba(0, 0, 0, 0.10); + border-bottom: 1px solid $separator-border-color; &.unread { background-color: rgba(0, 0, 0, 0.05); diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html new file mode 100644 index 000000000..ed3cd8dc5 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html @@ -0,0 +1,74 @@ +
+
+
Save to
+ +
+ + + Options +
+
+ +
+
+ + + +
+ +
+ + + +
+
+
+ + + + + + diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss new file mode 100644 index 000000000..68dcda1eb --- /dev/null +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss @@ -0,0 +1,98 @@ +@import '_variables'; +@import '_mixins'; + +.header { + min-width: 240px; + padding: 6px 24px 10px 24px; + + margin-bottom: 10px; + border-bottom: 1px solid $separator-border-color; + + .first-row { + display: flex; + align-items: center; + + .title { + font-size: 18px; + flex-grow: 1; + } + + .options { + font-size: 14px; + cursor: pointer; + + my-global-icon { + @include apply-svg-color(#333); + + width: 16px; + height: 16px; + } + } + } + + .options-row { + margin-top: 10px; + + > div { + display: flex; + align-items: center; + } + } +} + +.dropdown-item { + padding: 6px 24px; +} + +.playlist { + display: flex; + cursor: pointer; + + my-peertube-checkbox { + margin-right: 10px; + } + + .display-name { + display: flex; + align-items: flex-end; + + .timestamp-info { + font-size: 0.9em; + color: $grey-foreground-color; + margin-left: 5px; + } + } +} + +.new-playlist-button, +.new-playlist-block { + padding-top: 10px; + margin-top: 10px; + border-top: 1px solid $separator-border-color; +} + +.new-playlist-button { + cursor: pointer; + + my-global-icon { + @include apply-svg-color(#333); + + position: relative; + left: -1px; + top: -1px; + margin-right: 4px; + width: 21px; + height: 21px; + } +} + +input[type=text] { + @include peertube-input-text(200px); + + display: block; +} + +input[type=submit] { + @include peertube-button; + @include orange-button; +} diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts new file mode 100644 index 000000000..c6fb6dbed --- /dev/null +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts @@ -0,0 +1,195 @@ +import { Component, Input, OnInit } from '@angular/core' +import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' +import { AuthService, Notifier } from '@app/core' +import { forkJoin } from 'rxjs' +import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' +import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { secondsToTime, timeToInt } from '../../../assets/player/utils' + +type PlaylistSummary = { + id: number + inPlaylist: boolean + displayName: string + + startTimestamp?: number + stopTimestamp?: number +} + +@Component({ + selector: 'my-video-add-to-playlist', + styleUrls: [ './video-add-to-playlist.component.scss' ], + templateUrl: './video-add-to-playlist.component.html' +}) +export class VideoAddToPlaylistComponent extends FormReactive implements OnInit { + @Input() video: Video + @Input() currentVideoTimestamp: number + + isNewPlaylistBlockOpened = false + videoPlaylists: PlaylistSummary[] = [] + timestampOptions: { + startTimestampEnabled: boolean + startTimestamp: number + stopTimestampEnabled: boolean + stopTimestamp: number + } + displayOptions = false + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private notifier: Notifier, + private i18n: I18n, + private videoPlaylistService: VideoPlaylistService, + private videoPlaylistValidatorsService: VideoPlaylistValidatorsService + ) { + super() + } + + get user () { + return this.authService.getUser() + } + + ngOnInit () { + this.resetOptions(true) + + this.buildForm({ + 'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME + }) + + forkJoin([ + this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt'), + this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id) + ]) + .subscribe( + ([ playlistsResult, existResult ]) => { + for (const playlist of playlistsResult.data) { + const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id) + + this.videoPlaylists.push({ + id: playlist.id, + displayName: playlist.displayName, + inPlaylist: !!existingPlaylist, + startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined, + stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined + }) + } + } + ) + } + + openChange (opened: boolean) { + if (opened === false) { + this.isNewPlaylistBlockOpened = false + this.displayOptions = false + } + } + + openCreateBlock (event: Event) { + event.preventDefault() + + this.isNewPlaylistBlockOpened = true + } + + togglePlaylist (event: Event, playlist: PlaylistSummary) { + event.preventDefault() + + if (playlist.inPlaylist === true) { + this.removeVideoFromPlaylist(playlist) + } else { + this.addVideoInPlaylist(playlist) + } + + playlist.inPlaylist = !playlist.inPlaylist + this.resetOptions() + } + + createPlaylist () { + const displayName = this.form.value[ 'display-name' ] + + const videoPlaylistCreate: VideoPlaylistCreate = { + displayName, + privacy: VideoPlaylistPrivacy.PRIVATE + } + + this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe( + res => { + this.videoPlaylists.push({ + id: res.videoPlaylist.id, + displayName, + inPlaylist: false + }) + + this.isNewPlaylistBlockOpened = false + }, + + err => this.notifier.error(err.message) + ) + } + + resetOptions (resetTimestamp = false) { + this.displayOptions = false + + this.timestampOptions = {} as any + this.timestampOptions.startTimestampEnabled = false + this.timestampOptions.stopTimestampEnabled = false + + if (resetTimestamp) { + this.timestampOptions.startTimestamp = 0 + this.timestampOptions.stopTimestamp = this.video.duration + } + } + + formatTimestamp (playlist: PlaylistSummary) { + const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : '' + const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : '' + + return `(${start}-${stop})` + } + + private removeVideoFromPlaylist (playlist: PlaylistSummary) { + this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.video.id) + .subscribe( + () => { + this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName })) + + playlist.inPlaylist = false + }, + + err => { + this.notifier.error(err.message) + + playlist.inPlaylist = true + } + ) + } + + private addVideoInPlaylist (playlist: PlaylistSummary) { + const body: VideoPlaylistElementCreate = { videoId: this.video.id } + + if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp + if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp + + this.videoPlaylistService.addVideoInPlaylist(playlist.id, body) + .subscribe( + () => { + playlist.inPlaylist = true + + playlist.startTimestamp = body.startTimestamp + playlist.stopTimestamp = body.stopTimestamp + + const message = body.startTimestamp || body.stopTimestamp + ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) }) + : this.i18n('Video added in {{n}}', { n: playlist.displayName }) + + this.notifier.success(message) + }, + + err => { + this.notifier.error(err.message) + + playlist.inPlaylist = false + } + ) + } +} diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html index 1a39f5fe5..a136f9233 100644 --- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html @@ -1,6 +1,6 @@ -
+
@@ -15,7 +15,7 @@ diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss index a47206577..f8cd47f73 100644 --- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss @@ -5,6 +5,17 @@ .miniature { display: inline-block; + &.no-videos:not(.to-manage){ + a { + cursor: default !important; + } + } + + &.to-manage .play-overlay, + &.no-videos { + display: none; + } + .miniature-thumbnail { @include miniature-thumbnail; diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts index b3bba7c87..cb5803400 100644 --- a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts @@ -8,4 +8,12 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' }) export class VideoPlaylistMiniatureComponent { @Input() playlist: VideoPlaylist + @Input() toManage = false + + getPlaylistUrl () { + if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ] + if (this.playlist.videosLength === 0) return null + + return [ '/videos/watch/playlist', this.playlist.uuid ] + } } diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts index 9d0b02789..ec8013e89 100644 --- a/client/src/app/shared/video-playlist/video-playlist.model.ts +++ b/client/src/app/shared/video-playlist/video-playlist.model.ts @@ -46,6 +46,7 @@ export class VideoPlaylist implements ServerVideoPlaylist { this.isLocal = hash.isLocal this.displayName = hash.displayName + this.description = hash.description this.privacy = hash.privacy @@ -70,5 +71,9 @@ export class VideoPlaylist implements ServerVideoPlaylist { } this.privacy.label = peertubeTranslate(this.privacy.label, translations) + + if (this.type.id === VideoPlaylistType.WATCH_LATER) { + this.displayName = peertubeTranslate(this.displayName, translations) + } } } diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts index 8b66e122c..f7b37f83a 100644 --- a/client/src/app/shared/video-playlist/video-playlist.service.ts +++ b/client/src/app/shared/video-playlist/video-playlist.service.ts @@ -1,9 +1,9 @@ -import { catchError, map, switchMap } from 'rxjs/operators' +import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators' import { Injectable } from '@angular/core' -import { Observable } from 'rxjs' +import { Observable, ReplaySubject, Subject } from 'rxjs' import { RestExtractor } from '../rest/rest-extractor.service' -import { HttpClient } from '@angular/common/http' -import { ResultList } from '../../../../../shared' +import { HttpClient, HttpParams } from '@angular/common/http' +import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared' import { environment } from '../../../environments/environment' import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' @@ -15,16 +15,31 @@ import { ServerService } from '@app/core' import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' import { AccountService } from '@app/shared/account/account.service' import { Account } from '@app/shared/account/account.model' +import { RestService } from '@app/shared/rest' +import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' @Injectable() export class VideoPlaylistService { static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' + static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/' + + // Use a replay subject because we "next" a value before subscribing + private videoExistsInPlaylistSubject: Subject = new ReplaySubject(1) + private readonly videoExistsInPlaylistObservable: Observable constructor ( private authHttp: HttpClient, private serverService: ServerService, - private restExtractor: RestExtractor - ) { } + private restExtractor: RestExtractor, + private restService: RestService + ) { + this.videoExistsInPlaylistObservable = this.videoExistsInPlaylistSubject.pipe( + bufferTime(500), + filter(videoIds => videoIds.length !== 0), + switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)), + share() + ) + } listChannelPlaylists (videoChannel: VideoChannel): Observable> { const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' @@ -36,10 +51,13 @@ export class VideoPlaylistService { ) } - listAccountPlaylists (account: Account): Observable> { + listAccountPlaylists (account: Account, sort: string): Observable> { const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' - return this.authHttp.get>(url) + let params = new HttpParams() + params = this.restService.addRestGetParams(params, undefined, sort) + + return this.authHttp.get>(url, { params }) .pipe( switchMap(res => this.extractPlaylists(res)), catchError(err => this.restExtractor.handleError(err)) @@ -59,9 +77,8 @@ export class VideoPlaylistService { createVideoPlaylist (body: VideoPlaylistCreate) { const data = objectToFormData(body) - return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) + return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) .pipe( - map(this.restExtractor.extractDataBool), catchError(err => this.restExtractor.handleError(err)) ) } @@ -84,6 +101,36 @@ export class VideoPlaylistService { ) } + addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) { + return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos', body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) { + return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + removeVideoFromPlaylist (playlistId: number, videoId: number) { + return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + doesVideoExistInPlaylist (videoId: number) { + this.videoExistsInPlaylistSubject.next(videoId) + + return this.videoExistsInPlaylistObservable.pipe(first()) + } + extractPlaylists (result: ResultList) { return this.serverService.localeObservable .pipe( @@ -105,4 +152,14 @@ export class VideoPlaylistService { return this.serverService.localeObservable .pipe(map(translations => new VideoPlaylist(playlist, translations))) } + + private doVideosExistInPlaylist (videoIds: number[]): Observable { + const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' + let params = new HttpParams() + + params = this.restService.addObjectParams(params, { videoIds }) + + return this.authHttp.get(url, { params }) + .pipe(catchError(err => this.restExtractor.handleError(err))) + } } diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 960846e21..ef489648c 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts @@ -31,6 +31,8 @@ import { ServerService } from '@app/core' import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' +import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' export interface VideosProvider { getVideos ( @@ -170,6 +172,23 @@ export class VideoService implements VideosProvider { ) } + getPlaylistVideos ( + videoPlaylistId: number | string, + videoPagination: ComponentPagination + ): Observable<{ videos: Video[], totalVideos: number }> { + const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + + return this.authHttp + .get>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params }) + .pipe( + switchMap(res => this.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + getUserSubscriptionVideos ( videoPagination: ComponentPagination, sort: VideoSortField diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html index 9f3c37fe8..955b2b80c 100644 --- a/client/src/app/videos/+video-watch/modal/video-share.component.html +++ b/client/src/app/videos/+video-watch/modal/video-share.component.html @@ -6,11 +6,19 @@