diff --git a/CREDITS.md b/CREDITS.md index 716f3fca2b0fc596fa6d223bd19e96f5b455e122..1f7aaad7aedea7bab7e8281367853a23754e82ff 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 0193afff7dee2307cd0fef0797e9edcc6aaef906..3f921b13fa2666c1941a8273e4b8d34787152a40 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 6feb16ab1521ee2e6b4f27198495eae9caaa798c..0274f47c52e85d7920c9602af224dab4e2ff105a 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 b76488c783efe20e1f0bc15b4261c3a2286fe39c..5d1184218ac4c6e5323f0b9a85808869eca01a13 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 @@ </div> </div> </div> + <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> </form> diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html new file mode 100644 index 0000000000000000000000000000000000000000..28ea7a857e5e4f11ffa571fbd2021a7140a7a285 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html @@ -0,0 +1,16 @@ +<div class="no-results">No videos in this playlist.</div> + +<div class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()"> + <div *ngFor="let video of videos" class="video"> + <my-video-thumbnail [video]="video"></my-video-thumbnail> + + <div class="video-info"> + <div class="position">{{ video.playlistElement.position }}</div> + + <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a> + + <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a> + <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a> + </div> + </div> +</div> diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..5e6774739fb643993a1068a8f5cc0ab97b73bdfe --- /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 0000000000000000000000000000000000000000..8b70a9b1abbe94582aaf043816be68398ae77e3e --- /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 ab5d9cc5afdded72ff0efa34ebb6d0762af4c4c5..7d1bed12a89f1696e3dc642350d353a29fc74f49 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 @@ </a> </div> -<div class="video-playlists"> +<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()"> <div *ngFor="let playlist of videoPlaylists" class="video-playlist"> <div class="miniature-wrapper"> - <my-video-playlist-miniature [playlist]="playlist"></my-video-playlist-miniature> + <my-video-playlist-miniature [playlist]="playlist" [toManage]="true"></my-video-playlist-miniature> </div> <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons"> diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts index 761ce90e877fd98e0062af0a755a96c8736ef114..e30656b92cebfe7759416202ab63d44fc97af571 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 3dbce2b92b2ed9fb87ef0131e4b46a9eb35aedd4..ba83001119ae398f56e7c628d95633b2b06d5698 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 0000000000000000000000000000000000000000..c57a4b32c291bf283a149e254deea6fb29ca0c3d --- /dev/null +++ b/client/src/app/shared/forms/timestamp-input.component.html @@ -0,0 +1,4 @@ +<p-inputMask + [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()" + mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" +></p-inputMask> diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..7115777fd3dd9f90ab78520f4b73106029157b85 --- /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 0000000000000000000000000000000000000000..8d67a96ac3ca68744a4f94e389967816a92d1f17 --- /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 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts index e8ada0324d82baf37038fbbbd1e3fe451ebc75ea..3fda7ee4d8ae1b499e2b158c89dff0531d5928fa 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 60a7bd6e2b3fe96482c548b90d7bd53d0e00553c..1f9eee0b7ec84fc12800b761056c77b95600916c 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 8f1754c7f31ececd86134531f0d391e31b1edf96..ef470ee44cbaf62bb5303fc24cd6db123522fd4f 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 3d05f071e6a9d42421455abe6274cb491fd1179d..cfd5b100fe7ca500ae34ffcccc3d6c11c0a980ec 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<SubscriptionExistResult> { + private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> { 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 315d504c9e6374b51506ddfdb97091466b26f2a7..88f38d9cff671268327d4ad04021ccee254950b8 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 0000000000000000000000000000000000000000..ed3cd8dc5bc7043c78bf59f56ee7ffdb0a740382 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html @@ -0,0 +1,74 @@ +<div class="header"> + <div class="first-row"> + <div i18n class="title">Save to</div> + + <div i18n class="options" (click)="displayOptions = !displayOptions"> + <my-global-icon iconName="cog"></my-global-icon> + + Options + </div> + </div> + + <div class="options-row" *ngIf="displayOptions"> + <div> + <my-peertube-checkbox + inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled" + i18n-labelText labelText="Start at" + ></my-peertube-checkbox> + + <my-timestamp-input + [timestamp]="timestampOptions.startTimestamp" + [maxTimestamp]="video.duration" + [disabled]="!timestampOptions.startTimestampEnabled" + [(ngModel)]="timestampOptions.startTimestamp" + ></my-timestamp-input> + </div> + + <div> + <my-peertube-checkbox + inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled" + i18n-labelText labelText="Stop at" + ></my-peertube-checkbox> + + <my-timestamp-input + [timestamp]="timestampOptions.stopTimestamp" + [maxTimestamp]="video.duration" + [disabled]="!timestampOptions.stopTimestampEnabled" + [(ngModel)]="timestampOptions.stopTimestamp" + ></my-timestamp-input> + </div> + </div> +</div> + +<div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)"> + <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist"></my-peertube-checkbox> + + <div class="display-name"> + {{ playlist.displayName }} + + <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info"> + {{ formatTimestamp(playlist) }} + </div> + </div> +</div> + +<div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened"> + <my-global-icon iconName="add"></my-global-icon> + + Create a new playlist +</div> + +<form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form"> + <div class="form-group"> + <label i18n for="display-name">Display name</label> + <input + type="text" id="display-name" + formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" + > + <div *ngIf="formErrors['display-name']" class="form-error"> + {{ formErrors['display-name'] }} + </div> + </div> + + <input type="submit" i18n-value value="Create" [disabled]="!form.valid"> +</form> diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..68dcda1ebf00003de0628b9e9e5d6dcf9a0dda66 --- /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 0000000000000000000000000000000000000000..c6fb6dbedab6bf26c287511eeb0ec86f290e5d57 --- /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 1a39f5fe578bc1d81f9fb861aaaa683139d08b2d..a136f923377df5726090dd7b792f6a825796ea8e 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 @@ -<div class="miniature"> +<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }"> <a - [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName" + [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName" class="miniature-thumbnail" > <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> @@ -15,7 +15,7 @@ </a> <div class="miniature-bottom"> - <a tabindex="-1" class="miniature-name" [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName"> + <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName"> {{ playlist.displayName }} </a> </div> diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss index a472065772c25da1913f5b49ba1893871057b24e..f8cd47f730b1719d179e3a601d7e02ef514eca0a 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 b3bba7c8731cecbd543a81f055476c05ebd5c968..cb58034007146fb49acc17e97549d197c6c84d98 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 9d0b0278970d5d08a044923fa68f39deb5a2c303..ec8013e8902fa4a95d06e22907c1b452e7488782 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 8b66e122cb26e7e44e1c25de6ab9e1447a63715e..f7b37f83aabc4491bb7d1cad9da2f2746ae8dd7e 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<number> = new ReplaySubject(1) + private readonly videoExistsInPlaylistObservable: Observable<VideoExistInPlaylist> 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<ResultList<VideoPlaylist>> { const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' @@ -36,10 +51,13 @@ export class VideoPlaylistService { ) } - listAccountPlaylists (account: Account): Observable<ResultList<VideoPlaylist>> { + listAccountPlaylists (account: Account, sort: string): Observable<ResultList<VideoPlaylist>> { const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' - return this.authHttp.get<ResultList<VideoPlaylist>>(url) + let params = new HttpParams() + params = this.restService.addRestGetParams(params, undefined, sort) + + return this.authHttp.get<ResultList<VideoPlaylist>>(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<VideoPlaylistServerModel>) { 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<VideoExistInPlaylist> { + const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' + let params = new HttpParams() + + params = this.restService.addObjectParams(params, { videoIds }) + + return this.authHttp.get<VideoExistInPlaylist>(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 960846e21cf52ed4b5cc51274c1e6b2adc0a90cc..ef489648c6c48f088d7e66a507bd3f4a49909d7f 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<ResultList<Video>>(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 9f3c37fe86d383e414574151d33e0007296f26f0..955b2b80c4693a0c0ec806a722041c033f254cff 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 @@ <div class="modal-body"> - <div *ngIf="currentVideoTimestampString" class="start-at"> + <div class="start-at"> <my-peertube-checkbox inputName="startAt" [(ngModel)]="startAtCheckbox" - i18n-labelText [labelText]="getStartCheckboxLabel()" + i18n-labelText labelText="Start at" ></my-peertube-checkbox> + + <my-timestamp-input + [timestamp]="currentVideoTimestamp" + [maxTimestamp]="video.duration" + [disabled]="!startAtCheckbox" + [(ngModel)]="currentVideoTimestamp" + > + </my-timestamp-input> </div> <div class="form-group"> diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss index 4937506b9eb88715ba37fcbc832019ae0ca95a0b..472a45920bf5d3945da2ea0d5004860e574d9e16 100644 --- a/client/src/app/videos/+video-watch/modal/video-share.component.scss +++ b/client/src/app/videos/+video-watch/modal/video-share.component.scss @@ -13,4 +13,9 @@ display: flex; justify-content: center; margin-top: 10px; + align-items: center; + + my-timestamp-input { + margin-left: 10px; + } } diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts index c6205e355ca53076f14e7eb454a3af96901b8ad5..6565d7f887fc9c556c920efbc46a775b522bf152 100644 --- a/client/src/app/videos/+video-watch/modal/video-share.component.ts +++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts @@ -16,10 +16,8 @@ export class VideoShareComponent { @Input() video: VideoDetails = null + currentVideoTimestamp: number startAtCheckbox = false - currentVideoTimestampString: string - - private currentVideoTimestamp: number constructor ( private modalService: NgbModal, @@ -28,8 +26,7 @@ export class VideoShareComponent { ) { } show (currentVideoTimestamp?: number) { - this.currentVideoTimestamp = Math.floor(currentVideoTimestamp) - this.currentVideoTimestampString = durationToString(this.currentVideoTimestamp) + this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0 this.modalService.open(this.modal) } @@ -52,10 +49,6 @@ export class VideoShareComponent { this.notifier.success(this.i18n('Copied')) } - getStartCheckboxLabel () { - return this.i18n('Start at {{timestamp}}', { timestamp: this.currentVideoTimestampString }) - } - private getVideoTimestampIfEnabled () { if (this.startAtCheckbox === true) return this.currentVideoTimestamp diff --git a/client/src/app/videos/+video-watch/video-watch-routing.module.ts b/client/src/app/videos/+video-watch/video-watch-routing.module.ts index bdd4f945e86798baab1434bcc0b6b8aa6386ebe9..0d78090445f2c1a802d574fb0e55c07db4c3b186 100644 --- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts +++ b/client/src/app/videos/+video-watch/video-watch-routing.module.ts @@ -7,7 +7,16 @@ import { VideoWatchComponent } from './video-watch.component' const videoWatchRoutes: Routes = [ { - path: '', + path: 'playlist/:uuid', + component: VideoWatchComponent, + canActivate: [ MetaGuard ] + }, + { + path: ':uuid/comments/:commentId', + redirectTo: ':uuid' + }, + { + path: ':uuid', component: VideoWatchComponent, canActivate: [ MetaGuard ] } diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index fffcc1275e58d0132f849ab39bd6387e8b813fe6..615b88bd66d20d84710c78ed57a36a17ed4f2553 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html @@ -65,17 +65,31 @@ <my-global-icon iconName="dislike"></my-global-icon> </div> - <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support"> + <div *ngIf="video.support" (click)="showSupportModal()" class="action-button"> <my-global-icon iconName="heart"></my-global-icon> <span class="icon-text" i18n>Support</span> </div> - <div (click)="showShareModal()" class="action-button action-button-share" role="button"> + <div (click)="showShareModal()" class="action-button" role="button"> <my-global-icon iconName="share"></my-global-icon> <span class="icon-text" i18n>Share</span> </div> - <div class="action-more" ngbDropdown placement="top" role="button"> + <div + class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" + *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)" + > + <div class="action-button action-button-save" ngbDropdownToggle role="button"> + <my-global-icon iconName="playlist-add"></my-global-icon> + <span class="icon-text" i18n>Save</span> + </div> + + <div ngbDropdownMenu> + <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist> + </div> + </div> + + <div class="action-dropdown" ngbDropdown placement="top" role="button"> <div class="action-button" ngbDropdownToggle role="button"> <my-global-icon class="more-icon" iconName="more"></my-global-icon> </div> diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss index 33d77e62c471434ecf97dd384ff121b7923607c7..ff321fdbce7f57f56fb697f468fb5bee58bb8427 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ b/client/src/app/videos/+video-watch/video-watch.component.scss @@ -176,7 +176,7 @@ $other-videos-width: 260px; display: flex; align-items: center; - .action-button:not(:first-child), .action-more { + .action-button:not(:first-child), .action-dropdown { margin-left: 10px; } @@ -212,12 +212,19 @@ $other-videos-width: 260px; } } + &.action-button-save { + my-global-icon { + top: 0 !important; + right: -1px; + } + } + .icon-text { margin-left: 3px; } } - .action-more { + .action-dropdown { display: inline-block; .dropdown-menu .dropdown-item { diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index 0f04441ba02eb66d888bd633cae38751f712209b..359217f3b8d4ed714449c265d1ec6ae9f7aac216 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -59,6 +59,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { remoteServerDown = false hotkeys: Hotkey[] + private currentTime: number private paramsSub: Subscription constructor ( @@ -114,10 +115,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy { ) .subscribe(([ video, captionsResult ]) => { const startTime = this.route.snapshot.queryParams.start + const stopTime = this.route.snapshot.queryParams.stop const subtitle = this.route.snapshot.queryParams.subtitle const playerMode = this.route.snapshot.queryParams.mode - this.onVideoFetched(video, captionsResult.data, { startTime, subtitle, playerMode }) + this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode }) .catch(err => this.handleError(err)) }) }) @@ -219,7 +221,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { showShareModal () { const currentTime = this.player ? this.player.currentTime() : undefined - this.videoShareModal.show(currentTime) + this.videoShareModal.show(this.currentTime) } showDownloadModal (event: Event) { @@ -371,7 +373,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { private async onVideoFetched ( video: VideoDetails, videoCaptions: VideoCaption[], - urlOptions: { startTime?: number, subtitle?: string, playerMode?: string } + urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string } ) { this.video = video @@ -379,6 +381,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.descriptionLoading = false this.completeDescriptionShown = false this.remoteServerDown = false + this.currentTime = undefined let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) // If we are at the end of the video, reset the timer @@ -420,6 +423,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { inactivityTimeout: 2500, poster: this.video.previewUrl, startTime, + stopTime: urlOptions.stopTime, theaterMode: true, captions: videoCaptions.length !== 0, @@ -466,6 +470,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.zone.runOutsideAngular(async () => { this.player = await PeertubePlayerManager.initialize(mode, options) this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) + + this.player.on('timeupdate', () => { + this.currentTime = Math.floor(this.player.currentTime()) + }) }) this.setVideoDescriptionHTML() diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts index 58988ffd1a5c5f59d8726769ba6ea0529ffad36a..69a9232ce52eeab2c879bc6c9170bc1fe2014f73 100644 --- a/client/src/app/videos/videos-routing.module.ts +++ b/client/src/app/videos/videos-routing.module.ts @@ -78,11 +78,7 @@ const videosRoutes: Routes = [ } }, { - path: 'watch/:uuid/comments/:commentId', - redirectTo: 'watch/:uuid' - }, - { - path: 'watch/:uuid', + path: 'watch', loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule', data: { preload: 3000 diff --git a/client/src/assets/images/global/add.html b/client/src/assets/images/global/add.html index bfb0a52bccf57821bdf54c1ceed6e34605f95e04..34f4970566cb3395b939ca740995c2978de47252 100644 --- a/client/src/assets/images/global/add.html +++ b/client/src/assets/images/global/add.html @@ -2,9 +2,9 @@ <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g transform="translate(-92.000000, -115.000000)"> <g id="2" transform="translate(92.000000, 115.000000)"> - <circle id="Oval-1" stroke="#ffffff" stroke-width="2" cx="12" cy="12" r="10"></circle> - <rect id="Rectangle-1" fill="#ffffff" x="11" y="7" width="2" height="10" rx="1"></rect> - <rect id="Rectangle-1" fill="#ffffff" x="7" y="11" width="10" height="2" rx="1"></rect> + <circle id="Oval-1" stroke="#000000" stroke-width="2" cx="12" cy="12" r="10"></circle> + <rect id="Rectangle-1" fill="#000000" x="11" y="7" width="2" height="10" rx="1"></rect> + <rect id="Rectangle-1" fill="#000000" x="7" y="11" width="10" height="2" rx="1"></rect> </g> </g> </g> diff --git a/client/src/assets/images/video/playlist-add.html b/client/src/assets/images/video/playlist-add.html new file mode 100644 index 0000000000000000000000000000000000000000..ada845c758154a33ad17075a762fe3ad479a3dd6 --- /dev/null +++ b/client/src/assets/images/video/playlist-add.html @@ -0,0 +1,10 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 426.667 426.667" xml:space="preserve"> + <g fill="#000000"> + <rect x="0" y="64" width="256" height="42.667"/> + <rect x="0" y="149.333" width="256" height="42.667"/> + <rect x="0" y="234.667" width="170.667" height="42.667"/> + <polygon points="341.333,234.667 341.333,149.333 298.667,149.333 298.667,234.667 213.333,234.667 213.333,277.333 + 298.667,277.333 298.667,362.667 341.333,362.667 341.333,277.333 426.667,277.333 426.667,234.667 "/> + </g> +</svg> diff --git a/client/src/assets/images/video/watch-later.html b/client/src/assets/images/video/watch-later.html new file mode 100644 index 0000000000000000000000000000000000000000..927afebe4f2e30de8ff1a32deb8e177d29f94f41 --- /dev/null +++ b/client/src/assets/images/video/watch-later.html @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 80 100" + enable-background="new 0 0 80 80" xml:space="preserve"><g><path fill="#000000" d="M33.3,51.5L33.3,51.5c-1.8,0-3.3-1.4-3.3-3.1V37.3c0-1.7,1.5-3.1,3.3-3.1c0.5,0,1,0.1,1.5,0.4l10.7,5.5 c1,0.5,1.6,1.5,1.6,2.7c0,1.2-0.6,2.2-1.7,2.8l-10.6,5.6C34.3,51.3,33.8,51.5,33.3,51.5z M33.3,36.2c-0.6,0-1.3,0.4-1.3,1.1v11.1 c0,0.6,0.7,1.1,1.3,1.1l0,0c0.2,0,0.4,0,0.5-0.1l10.6-5.6c0.4-0.2,0.6-0.6,0.6-1c0-0.2-0.1-0.6-0.5-0.9l-10.7-5.5 C33.6,36.2,33.4,36.2,33.3,36.2z"/></g> + <g><path fill="#000000" d="M62.9,65H12.1C10.4,65,9,63.6,9,61.9V22.1c0-1.7,1.4-3.1,3.1-3.1h50.8c1.7,0,3.1,1.4,3.1,3.1v39.8 C66,63.6,64.6,65,62.9,65z M12.1,21c-0.6,0-1.1,0.5-1.1,1.1v39.8c0,0.6,0.5,1.1,1.1,1.1h50.8c0.6,0,1.1-0.5,1.1-1.1V22.1 c0-0.6-0.5-1.1-1.1-1.1H12.1z"/></g> + <g><path fill="#000000" d="M63,16h-2c0-1-0.4-1-0.9-1H14.9c-0.5,0-0.9,0-0.9,1h-2c0-2,1.3-3,2.9-3h45.3C61.7,13,63,14,63,16z"/></g> + <g><path fill="#000000" d="M58,11h-2c0-1-0.4-1-0.5-1H19.5c-0.1,0-0.5,0-0.5,1h-2c0-2,1.1-3,2.5-3h36.1C56.9,8,58,9,58,11z"/></g> + <g><path fill="#000000" d="M68,29v-2c4,0,6.5-2.9,6.5-6.5S72,14,68,14v-2c5,0,8.5,3.8,8.5,8.5S73,29,68,29z"/></g> + <g><polygon fill="#000000" points="71.3,18.7 65.6,13 71.3,7.3 72.7,8.7 68.4,13 72.7,17.3 "/></g> + <text x="0" y="95" fill="#000000" font-size="5px" font-weight="bold" + font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Yaroslav Samoylov</text> + <text x="0" y="100" fill="#000000" font-size="5px" font-weight="bold" + font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg> diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 7631d095fe3e51bdad8ac395037a17f02ecb9b3c..6cdd543725604d32b08ddbb6aee2e6f3bec5bceb 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -49,6 +49,7 @@ export type CommonOptions = { inactivityTimeout: number poster: string startTime: number | string + stopTime: number | string theaterMode: boolean captions: boolean @@ -199,10 +200,10 @@ export class PeertubePlayerManager { autoplay, // Use peertube plugin autoplay because we get the file by webtorrent videoViewUrl: commonOptions.videoViewUrl, videoDuration: commonOptions.videoDuration, - startTime: commonOptions.startTime, userWatching: commonOptions.userWatching, subtitle: commonOptions.subtitle, - videoCaptions: commonOptions.videoCaptions + videoCaptions: commonOptions.videoCaptions, + stopTime: commonOptions.stopTime } } @@ -210,6 +211,7 @@ export class PeertubePlayerManager { const p2pMediaLoader: P2PMediaLoaderPluginOptions = { redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, type: 'application/x-mpegURL', + startTime: commonOptions.startTime, src: p2pMediaLoaderOptions.playlistUrl } @@ -254,7 +256,8 @@ export class PeertubePlayerManager { autoplay, videoDuration: commonOptions.videoDuration, playerElement: commonOptions.playerElement, - videoFiles: webtorrentOptions.videoFiles + videoFiles: webtorrentOptions.videoFiles, + startTime: commonOptions.startTime } Object.assign(plugins, { webtorrent }) diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index 92ac57cf5020fe8dd6242c81939f93ac5f22d812..3991e462712455c8699c67078b5173993d6cc46d 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts @@ -22,7 +22,6 @@ import { const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') class PeerTubePlugin extends Plugin { - private readonly startTime: number = 0 private readonly videoViewUrl: string private readonly videoDuration: number private readonly CONSTANTS = { @@ -35,13 +34,11 @@ class PeerTubePlugin extends Plugin { private videoViewInterval: any private userWatchingVideoInterval: any - private qualityObservationTimer: any private lastResolutionChange: ResolutionUpdateData constructor (player: videojs.Player, options: PeerTubePluginOptions) { super(player, options) - this.startTime = timeToInt(options.startTime) this.videoViewUrl = options.videoViewUrl this.videoDuration = options.videoDuration this.videoCaptions = options.videoCaptions @@ -84,6 +81,14 @@ class PeerTubePlugin extends Plugin { saveMuteInStore(this.player.muted()) }) + if (options.stopTime) { + const stopTime = timeToInt(options.stopTime) + + this.player.on('timeupdate', () => { + if (this.player.currentTime() > stopTime) this.player.pause() + }) + } + this.player.textTracks().on('change', () => { const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { return t.kind === 'captions' && t.mode === 'showing' @@ -109,10 +114,7 @@ class PeerTubePlugin extends Plugin { } dispose () { - clearTimeout(this.qualityObservationTimer) - - clearInterval(this.videoViewInterval) - + if (this.videoViewInterval) clearInterval(this.videoViewInterval) if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) } diff --git a/client/src/assets/player/peertube-videojs-typings.ts b/client/src/assets/player/peertube-videojs-typings.ts index 79a5a6c4d6c7c8bd2aec7baad21d4e4fa5e89ddf..a96b0bc8c4ab07e3889d1b44f50716ceacf9a579 100644 --- a/client/src/assets/player/peertube-videojs-typings.ts +++ b/client/src/assets/player/peertube-videojs-typings.ts @@ -41,12 +41,13 @@ type PeerTubePluginOptions = { autoplay: boolean videoViewUrl: string videoDuration: number - startTime: number | string userWatching?: UserWatching subtitle?: string videoCaptions: VideoJSCaption[] + + stopTime: number | string } type WebtorrentPluginOptions = { @@ -56,12 +57,16 @@ type WebtorrentPluginOptions = { videoDuration: number videoFiles: VideoFile[] + + startTime: number | string } type P2PMediaLoaderPluginOptions = { redundancyBaseUrls: string[] type: string src: string + + startTime: number | string } type VideoJSPluginOptions = { diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index 8d87567c2b53dd75b412f8080c7c6c92d181571f..54f1313105e02562480c24630f2bf4d7f4f99038 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts @@ -42,7 +42,7 @@ function timeToInt (time: number | string) { if (!time) return 0 if (typeof time === 'number') return time - const reg = /^((\d+)h)?((\d+)m)?((\d+)s?)?$/ + const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/ const matches = time.match(reg) if (!matches) return 0 @@ -54,18 +54,27 @@ function timeToInt (time: number | string) { return hours * 3600 + minutes * 60 + seconds } -function secondsToTime (seconds: number) { +function secondsToTime (seconds: number, full = false, symbol?: string) { let time = '' + const hourSymbol = (symbol || 'h') + const minuteSymbol = (symbol || 'm') + const secondsSymbol = full ? '' : 's' + let hours = Math.floor(seconds / 3600) - if (hours >= 1) time = hours + 'h' + if (hours >= 1) time = hours + hourSymbol + else if (full) time = '0' + hourSymbol seconds %= 3600 let minutes = Math.floor(seconds / 60) - if (minutes >= 1) time += minutes + 'm' + if (minutes >= 1 && minutes < 10 && full) time += '0' + minutes + minuteSymbol + else if (minutes >= 1) time += minutes + minuteSymbol + else if (full) time += '00' + minuteSymbol seconds %= 60 - if (seconds >= 1) time += seconds + 's' + if (seconds >= 1 && seconds < 10 && full) time += '0' + seconds + secondsSymbol + else if (seconds >= 1) time += seconds + secondsSymbol + else if (full) time += '00' return time } @@ -131,6 +140,7 @@ export { getRtcConfig, toTitleCase, timeToInt, + secondsToTime, buildVideoLink, buildVideoEmbed, videoFileMaxByResolution, diff --git a/client/src/assets/player/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/webtorrent/webtorrent-plugin.ts index c69bf31fa8d5f56b1cf9473cce45d114c11d0f65..c7182acc94477585c3c8cb9bac46330112def8a8 100644 --- a/client/src/assets/player/webtorrent/webtorrent-plugin.ts +++ b/client/src/assets/player/webtorrent/webtorrent-plugin.ts @@ -6,7 +6,7 @@ import * as WebTorrent from 'webtorrent' import { VideoFile } from '../../../../../shared/models/videos/video.model' import { renderVideo } from './video-renderer' import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' -import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' +import { getRtcConfig, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from '../utils' import { PeertubeChunkStore } from './peertube-chunk-store' import { getAverageBandwidthInStore, @@ -73,6 +73,8 @@ class WebTorrentPlugin extends Plugin { constructor (player: videojs.Player, options: WebtorrentPluginOptions) { super(player, options) + this.startTime = timeToInt(options.startTime) + // Disable auto play on iOS this.autoplay = options.autoplay && this.isIOS() === false this.playerRefusedP2P = !getStoredWebTorrentEnabled() diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 59b2f42a5caa85b2d192a2c8a121580dfd023183..3eefdb6fb054a2807f3ca703ff7869727c8ee319 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -515,4 +515,3 @@ align-items: center; } } - diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss index 56ca4c2d368206cd3b67c289b539f63d23ec60e0..deabbf6d49a92fc6cbb6d8ffd11f13c074d73580 100644 --- a/client/src/sass/include/_variables.scss +++ b/client/src/sass/include/_variables.scss @@ -44,6 +44,8 @@ $footer-margin: 30px; $footer-border-color: $header-border-color; +$separator-border-color: rgba(0, 0, 0, 0.10); + $video-thumbnail-height: 122px; $video-thumbnail-width: 223px; diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts index 32bf42e125a15a2a8f718eae037ce6221ad2e282..28c10c75cd7b3bbb3c3c8bc9d699e26a8ed6d3d9 100644 --- a/client/src/standalone/videos/embed.ts +++ b/client/src/standalone/videos/embed.ts @@ -168,6 +168,7 @@ class PeerTubeEmbed { subtitle: string enableApi = false startTime: number | string = 0 + stopTime: number | string mode: PlayerMode scope = 'peertube' @@ -262,6 +263,7 @@ class PeerTubeEmbed { this.scope = this.getParamString(params, 'scope', this.scope) this.subtitle = this.getParamString(params, 'subtitle') this.startTime = this.getParamString(params, 'start') + this.stopTime = this.getParamString(params, 'stop') this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent' } catch (err) { @@ -306,6 +308,7 @@ class PeerTubeEmbed { loop: this.loop, captions: videoCaptions.length !== 0, startTime: this.startTime, + stopTime: this.stopTime, subtitle: this.subtitle, videoCaptions, diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 5758c822798a7bcf37b51f5b7fa510a022b17b25..f7edbddf3f9516423dd39afccdf57d9ece839084 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts @@ -38,6 +38,7 @@ import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../h import { meRouter } from './me' import { deleteUserToken } from '../../../lib/oauth-model' import { myBlocklistRouter } from './my-blocklist' +import { myVideoPlaylistsRouter } from './my-video-playlists' import { myVideosHistoryRouter } from './my-history' import { myNotificationsRouter } from './my-notifications' import { Notifier } from '../../../lib/notifier' @@ -60,6 +61,7 @@ usersRouter.use('/', myNotificationsRouter) usersRouter.use('/', mySubscriptionsRouter) usersRouter.use('/', myBlocklistRouter) usersRouter.use('/', myVideosHistoryRouter) +usersRouter.use('/', myVideoPlaylistsRouter) usersRouter.use('/', meRouter) usersRouter.get('/autocomplete', diff --git a/server/controllers/api/users/my-video-playlists.ts b/server/controllers/api/users/my-video-playlists.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ec175f6496f4f2b0f83cc7a50b5fff9735d299c --- /dev/null +++ b/server/controllers/api/users/my-video-playlists.ts @@ -0,0 +1,47 @@ +import * as express from 'express' +import { asyncMiddleware, authenticate } from '../../../middlewares' +import { UserModel } from '../../../models/account/user' +import { doVideosInPlaylistExistValidator } from '../../../middlewares/validators/videos/video-playlists' +import { VideoPlaylistModel } from '../../../models/video/video-playlist' +import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model' + +const myVideoPlaylistsRouter = express.Router() + +myVideoPlaylistsRouter.get('/me/video-playlists/videos-exist', + authenticate, + doVideosInPlaylistExistValidator, + asyncMiddleware(doVideosInPlaylistExist) +) + +// --------------------------------------------------------------------------- + +export { + myVideoPlaylistsRouter +} + +// --------------------------------------------------------------------------- + +async function doVideosInPlaylistExist (req: express.Request, res: express.Response) { + const videoIds = req.query.videoIds as number[] + const user = res.locals.oauth.token.User as UserModel + + const results = await VideoPlaylistModel.listPlaylistIdsOf(user.Account.id, videoIds) + + const existObject: VideoExistInPlaylist = {} + + for (const videoId of videoIds) { + existObject[videoId] = [] + } + + for (const result of results) { + for (const element of result.VideoPlaylistElements) { + existObject[element.videoId].push({ + playlistId: result.id, + startTimestamp: element.startTimestamp, + stopTimestamp: element.stopTimestamp + }) + } + } + + return res.json(existObject) +} diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts index 145764d3508570b99da579ec5bf6c4d61756364c..49432d3aa92974291bea6b888a11ec0cc291660d 100644 --- a/server/controllers/api/video-playlist.ts +++ b/server/controllers/api/video-playlist.ts @@ -291,23 +291,26 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response) videoId: video.id }, { transaction: t }) - // If the user did not set a thumbnail, automatically take the video thumbnail - if (playlistElement.position === 1) { - const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()) - - if (await pathExists(playlistThumbnailPath) === false) { - logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url) - - const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) - await copy(videoThumbnailPath, playlistThumbnailPath) - } - } + videoPlaylist.updatedAt = new Date() + await videoPlaylist.save({ transaction: t }) await sendUpdateVideoPlaylist(videoPlaylist, t) return playlistElement }) + // If the user did not set a thumbnail, automatically take the video thumbnail + if (playlistElement.position === 1) { + const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()) + + if (await pathExists(playlistThumbnailPath) === false) { + logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url) + + const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) + await copy(videoThumbnailPath, playlistThumbnailPath) + } + } + logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) return res.json({ @@ -328,6 +331,9 @@ async function updateVideoPlaylistElement (req: express.Request, res: express.Re const element = await videoPlaylistElement.save({ transaction: t }) + videoPlaylist.updatedAt = new Date() + await videoPlaylist.save({ transaction: t }) + await sendUpdateVideoPlaylist(videoPlaylist, t) return element @@ -349,6 +355,9 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo // Decrease position of the next elements await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t) + videoPlaylist.updatedAt = new Date() + await videoPlaylist.save({ transaction: t }) + await sendUpdateVideoPlaylist(videoPlaylist, t) logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) @@ -390,6 +399,9 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons // Decrease positions of elements after the old position of our ordered elements (decrease) await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t) + videoPlaylist.updatedAt = new Date() + await videoPlaylist.save({ transaction: t }) + await sendUpdateVideoPlaylist(videoPlaylist, t) }) diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts index 76647fea2be8264c424d887dd6252afc2ebda3ba..3a3deab0c8f7b152ac56d08348d003b84286c247 100644 --- a/server/helpers/custom-validators/misc.ts +++ b/server/helpers/custom-validators/misc.ts @@ -49,12 +49,19 @@ function toValueOrNull (value: string) { return value } -function toArray (value: string) { +function toArray (value: any) { if (value && isArray(value) === false) return [ value ] return value } +function toIntArray (value: any) { + if (!value) return [] + if (isArray(value) === false) return [ validator.toInt(value) ] + + return value.map(v => validator.toInt(v)) +} + function isFileValid ( files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], mimeTypeRegex: string, @@ -97,5 +104,6 @@ export { isBooleanValid, toIntOrNull, toArray, + toIntArray, isFileValid } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 54c390540c2147736e77f0c64f8e83f233ccb04f..169a98ceb0ed38bb3d73729e5931ab4ec47e8de3 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -56,7 +56,7 @@ const SORTABLE_COLUMNS = { USER_NOTIFICATIONS: [ 'createdAt' ], - VIDEO_PLAYLISTS: [ 'createdAt' ] + VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ] } const OAUTH_LIFETIME = { diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index 22b8b8ff19d6a3861ba2c5ee53282ea061dd6807..87d2c7b5135e15ae2935e8ead06f9c20f49dbf56 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts @@ -4,9 +4,9 @@ import { UserRight } from '../../../../shared' import { logger } from '../../../helpers/logger' import { UserModel } from '../../../models/account/user' import { areValidationErrors } from '../utils' -import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos' +import { isVideoExist, isVideoFileInfoHashValid, isVideoImage } from '../../../helpers/custom-validators/videos' import { CONSTRAINTS_FIELDS } from '../../../initializers' -import { isIdOrUUIDValid, isUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc' +import { isArrayOf, isIdOrUUIDValid, isIdValid, isUUIDValid, toArray, toValueOrNull, toIntArray } from '../../../helpers/custom-validators/misc' import { isVideoPlaylistDescriptionValid, isVideoPlaylistExist, @@ -23,6 +23,7 @@ import { VideoModel } from '../../../models/video/video' import { authenticatePromiseIfNeeded } from '../../oauth' import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' +import { areValidActorHandles } from '../../../helpers/custom-validators/activitypub/actor' const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -305,6 +306,20 @@ const commonVideoPlaylistFiltersValidator = [ } ] +const doVideosInPlaylistExistValidator = [ + query('videoIds') + .customSanitizer(toIntArray) + .custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking areVideosInPlaylistExistValidator parameters', { parameters: req.query }) + + if (areValidationErrors(req, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- export { @@ -319,7 +334,9 @@ export { videoPlaylistElementAPGetValidator, - commonVideoPlaylistFiltersValidator + commonVideoPlaylistFiltersValidator, + + doVideosInPlaylistExistValidator } // --------------------------------------------------------------------------- diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts index 4d2ea0a666ad11600238af46b51f68706bb4e734..aa42687cd131656b90a6340f7f0adf5d4b12a057 100644 --- a/server/models/video/video-playlist.ts +++ b/server/models/video/video-playlist.ts @@ -317,6 +317,29 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> { }) } + static listPlaylistIdsOf (accountId: number, videoIds: number[]) { + const query = { + attributes: [ 'id' ], + where: { + ownerAccountId: accountId + }, + include: [ + { + attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ], + model: VideoPlaylistElementModel.unscoped(), + where: { + videoId: { + [Sequelize.Op.any]: videoIds + } + }, + required: true + } + ] + } + + return VideoPlaylistModel.findAll(query) + } + static doesPlaylistExist (url: string) { const query = { attributes: [], diff --git a/shared/models/videos/playlist/video-exist-in-playlist.model.ts b/shared/models/videos/playlist/video-exist-in-playlist.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..71240f51d4d4a391ac26433ecd6bdbf277df26ee --- /dev/null +++ b/shared/models/videos/playlist/video-exist-in-playlist.model.ts @@ -0,0 +1,7 @@ +export type VideoExistInPlaylist = { + [videoId: number ]: { + playlistId: number + startTimestamp?: number + stopTimestamp?: number + }[] +}