Add to playlist dropdown
authorChocobozzz <me@florianbigard.com>
Thu, 7 Mar 2019 16:06:00 +0000 (17:06 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 18 Mar 2019 10:17:59 +0000 (11:17 +0100)
55 files changed:
CREDITS.md
client/src/app/+my-account/my-account-routing.module.ts
client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
client/src/app/+my-account/my-account.module.ts
client/src/app/shared/forms/timestamp-input.component.html [new file with mode: 0644]
client/src/app/shared/forms/timestamp-input.component.scss [new file with mode: 0644]
client/src/app/shared/forms/timestamp-input.component.ts [new file with mode: 0644]
client/src/app/shared/images/global-icon.component.html [deleted file]
client/src/app/shared/images/global-icon.component.ts
client/src/app/shared/shared.module.ts
client/src/app/shared/user-subscription/subscribe-button.component.ts
client/src/app/shared/user-subscription/user-subscription.service.ts
client/src/app/shared/users/user-notifications.component.scss
client/src/app/shared/video-playlist/video-add-to-playlist.component.html [new file with mode: 0644]
client/src/app/shared/video-playlist/video-add-to-playlist.component.scss [new file with mode: 0644]
client/src/app/shared/video-playlist/video-add-to-playlist.component.ts [new file with mode: 0644]
client/src/app/shared/video-playlist/video-playlist-miniature.component.html
client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
client/src/app/shared/video-playlist/video-playlist.model.ts
client/src/app/shared/video-playlist/video-playlist.service.ts
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-watch/modal/video-share.component.html
client/src/app/videos/+video-watch/modal/video-share.component.scss
client/src/app/videos/+video-watch/modal/video-share.component.ts
client/src/app/videos/+video-watch/video-watch-routing.module.ts
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.scss
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/videos-routing.module.ts
client/src/assets/images/global/add.html
client/src/assets/images/video/playlist-add.html [new file with mode: 0644]
client/src/assets/images/video/watch-later.html [new file with mode: 0644]
client/src/assets/player/peertube-player-manager.ts
client/src/assets/player/peertube-plugin.ts
client/src/assets/player/peertube-videojs-typings.ts
client/src/assets/player/utils.ts
client/src/assets/player/webtorrent/webtorrent-plugin.ts
client/src/sass/include/_mixins.scss
client/src/sass/include/_variables.scss
client/src/standalone/videos/embed.ts
server/controllers/api/users/index.ts
server/controllers/api/users/my-video-playlists.ts [new file with mode: 0644]
server/controllers/api/video-playlist.ts
server/helpers/custom-validators/misc.ts
server/initializers/constants.ts
server/middlewares/validators/videos/video-playlists.ts
server/models/video/video-playlist.ts
shared/models/videos/playlist/video-exist-in-playlist.model.ts [new file with mode: 0644]

index 716f3fca2b0fc596fa6d223bd19e96f5b455e122..1f7aaad7aedea7bab7e8281367853a23754e82ff 100644 (file)
 
 # 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
index 0193afff7dee2307cd0fef0797e9edcc6aaef906..3f921b13fa2666c1941a8273e4b8d34787152a40 100644 (file)
@@ -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,
index 6feb16ab1521ee2e6b4f27198495eae9caaa798c..0274f47c52e85d7920c9602af224dab4e2ff105a 100644 (file)
@@ -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;
index b76488c783efe20e1f0bc15b4261c3a2286fe39c..5d1184218ac4c6e5323f0b9a85808869eca01a13 100644 (file)
@@ -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 (file)
index 0000000..28ea7a8
--- /dev/null
@@ -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 (file)
index 0000000..5e67747
--- /dev/null
@@ -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 (file)
index 0000000..8b70a9b
--- /dev/null
@@ -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
+        })
+  }
+}
index ab5d9cc5afdded72ff0efa34ebb6d0762af4c4c5..7d1bed12a89f1696e3dc642350d353a29fc74f49 100644 (file)
@@ -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">
index 761ce90e877fd98e0062af0a755a96c8736ef114..e30656b92cebfe7759416202ab63d44fc97af571 100644 (file)
@@ -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
+        })
+  }
 }
index 3dbce2b92b2ed9fb87ef0131e4b46a9eb35aedd4..ba83001119ae398f56e7c628d95633b2b06d5698 100644 (file)
@@ -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 (file)
index 0000000..c57a4b3
--- /dev/null
@@ -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 (file)
index 0000000..7115777
--- /dev/null
@@ -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 (file)
index 0000000..8d67a96
--- /dev/null
@@ -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 (file)
index e69de29..0000000
index e8ada0324d82baf37038fbbbd1e3fe451ebc75ea..3fda7ee4d8ae1b499e2b158c89dff0531d5928fa 100644 (file)
@@ -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
index 60a7bd6e2b3fe96482c548b90d7bd53d0e00553c..1f9eee0b7ec84fc12800b761056c77b95600916c 100644 (file)
@@ -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,
index 8f1754c7f31ececd86134531f0d391e31b1edf96..ef470ee44cbaf62bb5303fc24cd6db123522fd4f 100644 (file)
@@ -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],
 
index 3d05f071e6a9d42421455abe6274cb491fd1179d..cfd5b100fe7ca500ae34ffcccc3d6c11c0a980ec 100644 (file)
@@ -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()
 
index 315d504c9e6374b51506ddfdb97091466b26f2a7..88f38d9cff671268327d4ad04021ccee254950b8 100644 (file)
@@ -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 (file)
index 0000000..ed3cd8d
--- /dev/null
@@ -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 (file)
index 0000000..68dcda1
--- /dev/null
@@ -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 (file)
index 0000000..c6fb6db
--- /dev/null
@@ -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
+        }
+      )
+  }
+}
index 1a39f5fe578bc1d81f9fb861aaaa683139d08b2d..a136f923377df5726090dd7b792f6a825796ea8e 100644 (file)
@@ -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>
index a472065772c25da1913f5b49ba1893871057b24e..f8cd47f730b1719d179e3a601d7e02ef514eca0a 100644 (file)
@@ -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;
 
index b3bba7c8731cecbd543a81f055476c05ebd5c968..cb58034007146fb49acc17e97549d197c6c84d98 100644 (file)
@@ -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 ]
+  }
 }
index 9d0b0278970d5d08a044923fa68f39deb5a2c303..ec8013e8902fa4a95d06e22907c1b452e7488782 100644 (file)
@@ -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)
+    }
   }
 }
index 8b66e122cb26e7e44e1c25de6ab9e1447a63715e..f7b37f83aabc4491bb7d1cad9da2f2746ae8dd7e 100644 (file)
@@ -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)))
+  }
 }
index 960846e21cf52ed4b5cc51274c1e6b2adc0a90cc..ef489648c6c48f088d7e66a507bd3f4a49909d7f 100644 (file)
@@ -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
index 9f3c37fe86d383e414574151d33e0007296f26f0..955b2b80c4693a0c0ec806a722041c033f254cff 100644 (file)
@@ -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">
index 4937506b9eb88715ba37fcbc832019ae0ca95a0b..472a45920bf5d3945da2ea0d5004860e574d9e16 100644 (file)
@@ -13,4 +13,9 @@
   display: flex;
   justify-content: center;
   margin-top: 10px;
+  align-items: center;
+
+  my-timestamp-input {
+    margin-left: 10px;
+  }
 }
index c6205e355ca53076f14e7eb454a3af96901b8ad5..6565d7f887fc9c556c920efbc46a775b522bf152 100644 (file)
@@ -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
 
index bdd4f945e86798baab1434bcc0b6b8aa6386ebe9..0d78090445f2c1a802d574fb0e55c07db4c3b186 100644 (file)
@@ -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 ]
   }
index fffcc1275e58d0132f849ab39bd6387e8b813fe6..615b88bd66d20d84710c78ed57a36a17ed4f2553 100644 (file)
                     <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>
index 33d77e62c471434ecf97dd384ff121b7923607c7..ff321fdbce7f57f56fb697f468fb5bee58bb8427 100644 (file)
@@ -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 {
index 0f04441ba02eb66d888bd633cae38751f712209b..359217f3b8d4ed714449c265d1ec6ae9f7aac216 100644 (file)
@@ -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()
index 58988ffd1a5c5f59d8726769ba6ea0529ffad36a..69a9232ce52eeab2c879bc6c9170bc1fe2014f73 100644 (file)
@@ -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
index bfb0a52bccf57821bdf54c1ceed6e34605f95e04..34f4970566cb3395b939ca740995c2978de47252 100644 (file)
@@ -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 (file)
index 0000000..ada845c
--- /dev/null
@@ -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 (file)
index 0000000..927afeb
--- /dev/null
@@ -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>
index 7631d095fe3e51bdad8ac395037a17f02ecb9b3c..6cdd543725604d32b08ddbb6aee2e6f3bec5bceb 100644 (file)
@@ -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 })
 
index 92ac57cf5020fe8dd6242c81939f93ac5f22d812..3991e462712455c8699c67078b5173993d6cc46d 100644 (file)
@@ -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)
   }
 
index 79a5a6c4d6c7c8bd2aec7baad21d4e4fa5e89ddf..a96b0bc8c4ab07e3889d1b44f50716ceacf9a579 100644 (file)
@@ -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 = {
index 8d87567c2b53dd75b412f8080c7c6c92d181571f..54f1313105e02562480c24630f2bf4d7f4f99038 100644 (file)
@@ -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,
index c69bf31fa8d5f56b1cf9473cce45d114c11d0f65..c7182acc94477585c3c8cb9bac46330112def8a8 100644 (file)
@@ -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()
index 59b2f42a5caa85b2d192a2c8a121580dfd023183..3eefdb6fb054a2807f3ca703ff7869727c8ee319 100644 (file)
     align-items: center;
   }
 }
-
index 56ca4c2d368206cd3b67c289b539f63d23ec60e0..deabbf6d49a92fc6cbb6d8ffd11f13c074d73580 100644 (file)
@@ -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;
 
index 32bf42e125a15a2a8f718eae037ce6221ad2e282..28c10c75cd7b3bbb3c3c8bc9d699e26a8ed6d3d9 100644 (file)
@@ -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,
index 5758c822798a7bcf37b51f5b7fa510a022b17b25..f7edbddf3f9516423dd39afccdf57d9ece839084 100644 (file)
@@ -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 (file)
index 0000000..1ec175f
--- /dev/null
@@ -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)
+}
index 145764d3508570b99da579ec5bf6c4d61756364c..49432d3aa92974291bea6b888a11ec0cc291660d 100644 (file)
@@ -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)
   })
 
index 76647fea2be8264c424d887dd6252afc2ebda3ba..3a3deab0c8f7b152ac56d08348d003b84286c247 100644 (file)
@@ -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
 }
index 54c390540c2147736e77f0c64f8e83f233ccb04f..169a98ceb0ed38bb3d73729e5931ab4ec47e8de3 100644 (file)
@@ -56,7 +56,7 @@ const SORTABLE_COLUMNS = {
 
   USER_NOTIFICATIONS: [ 'createdAt' ],
 
-  VIDEO_PLAYLISTS: [ 'createdAt' ]
+  VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ]
 }
 
 const OAUTH_LIFETIME = {
index 22b8b8ff19d6a3861ba2c5ee53282ea061dd6807..87d2c7b5135e15ae2935e8ead06f9c20f49dbf56 100644 (file)
@@ -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
 }
 
 // ---------------------------------------------------------------------------
index 4d2ea0a666ad11600238af46b51f68706bb4e734..aa42687cd131656b90a6340f7f0adf5d4b12a057 100644 (file)
@@ -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 (file)
index 0000000..71240f5
--- /dev/null
@@ -0,0 +1,7 @@
+export type VideoExistInPlaylist = {
+  [videoId: number ]: {
+    playlistId: number
+    startTimestamp?: number
+    stopTimestamp?: number
+  }[]
+}