]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Update playlist add component to accept multiple times the same video
authorChocobozzz <me@florianbigard.com>
Tue, 18 Aug 2020 13:51:51 +0000 (15:51 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 19 Aug 2020 09:30:21 +0000 (11:30 +0200)
client/src/app/shared/shared-forms/timestamp-input.component.ts
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.html
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.scss
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
client/src/app/shared/shared-video-playlist/video-playlist.service.ts

index 8d67a96ac3ca68744a4f94e389967816a92d1f17..0ffd03d02886082da3e8519751a0278997d313a1 100644 (file)
@@ -1,4 +1,4 @@
-import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'
+import { ChangeDetectorRef, Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 import { secondsToTime, timeToInt } from '../../../assets/player/utils'
 
@@ -19,6 +19,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
   @Input() timestamp: number
   @Input() disabled = false
 
+  @Output() inputBlur = new EventEmitter()
+
   timestampString: string
 
   constructor (private changeDetector: ChangeDetectorRef) {}
@@ -57,5 +59,7 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
 
       this.propagateChange(this.timestamp)
     }
+
+    this.inputBlur.emit()
   }
 }
index a40e0699e49895d5d0be36d8654c1375f1c5a587..37d5017cfbc6045010787a57cc24be6f33e40694 100644 (file)
@@ -2,58 +2,60 @@
   <div class="header">
     <div class="first-row">
       <div i18n class="title">Save to</div>
-
-      <div class="options" (click)="displayOptions = !displayOptions">
-        <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
-
-        <span i18n>Options</span>
-      </div>
     </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 class="input-container">
+    <input type="text" placeholder="Search playlists" i18n-placeholder [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
+  </div>
 
-      <div>
+  <div class="playlists">
+    <div
+      *ngFor="let playlist of videoPlaylists"
+      class="playlist dropdown-item" [ngClass]="{ 'has-optional-row': playlist.optionalRowDisplayed }"
+    >
+      <div class="primary-row">
         <my-peertube-checkbox
-          inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
-          i18n-labelText labelText="Stop at"
+          [disabled]="isPresentMultipleTimes(playlist) || playlist.optionalRowDisplayed" [inputName]="getPrimaryInputName(playlist)"
+          [ngModel]="isPrimaryCheckboxChecked(playlist)" [onPushWorkaround]="true"
+          (click)="toggleMainPlaylist($event, playlist)"
         ></my-peertube-checkbox>
 
-        <my-timestamp-input
-          [timestamp]="timestampOptions.stopTimestamp"
-          [maxTimestamp]="video.duration"
-          [disabled]="!timestampOptions.stopTimestampEnabled"
-          [(ngModel)]="timestampOptions.stopTimestamp"
-        ></my-timestamp-input>
+        <label class="display-name" (click)="toggleMainPlaylist($event, playlist)">
+          {{ playlist.displayName }}
+        </label>
+
+        <div class="optional-row-icon" *ngIf="isPrimaryCheckboxChecked(playlist)" (click)="toggleOptionalRow(playlist)">
+          <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+        </div>
       </div>
-    </div>
-  </div>
 
-  <div class="input-container">
-    <input type="text" placeholder="Search playlists" i18n-placeholder [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
-  </div>
+      <div class="optional-rows" *ngIf="playlist.optionalRowDisplayed">
+        <div class="labels">
+          <div i18n>Start at</div>
+          <div i18n>Stop at</div>
+        </div>
 
-  <div class="playlists">
-    <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" [onPushWorkaround]="true"></my-peertube-checkbox>
+        <div *ngFor="let element of buildOptionalRowElements(playlist)">
+          <my-peertube-checkbox
+            [inputName]="getOptionalInputName(playlist, element)"
+            [ngModel]="element.enabled" [onPushWorkaround]="true"
+            (click)="toggleOptionalPlaylist($event, playlist, element, startAt.timestamp, stopAt.timestamp)"
+          ></my-peertube-checkbox>
 
-      <div class="display-name">
-        {{ playlist.displayName }}
+          <my-timestamp-input
+            [maxTimestamp]="video.duration"
+            [(ngModel)]="element.startTimestamp"
+            (inputBlur)="onElementTimestampUpdate(playlist, element)"
+            #startAt
+          ></my-timestamp-input>
 
-        <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
-          {{ formatTimestamp(playlist) }}
+          <my-timestamp-input
+            [maxTimestamp]="video.duration"
+            [(ngModel)]="element.stopTimestamp"
+            (inputBlur)="onElementTimestampUpdate(playlist, element)"
+            #stopAt
+          ></my-timestamp-input>
         </div>
       </div>
     </div>
index cb9ab9a1731bf9c2caaa428acd5e4608df3d41a0..d2c8804e3ad6f4bd461979e71ab613af27b6ab01 100644 (file)
@@ -1,6 +1,10 @@
 @import '_variables';
 @import '_mixins';
 
+$optional-rows-checkbox-width: 34px;
+$timestamp-width: 50px;
+$timestamp-margin-right: 10px;
+
 .header,
 .dropdown-item,
 .input-container {
       font-size: 18px;
       flex-grow: 1;
     }
-
-    .options {
-      display: flex;
-      align-items: center;
-      font-size: 14px;
-      cursor: pointer;
-
-      my-global-icon {
-        @include apply-svg-color(#333);
-
-        width: 16px;
-        height: 23px;
-        margin-right: 3px;
-      }
-    }
-  }
-
-  .options-row {
-    margin-top: 10px;
-    padding-left: 10px;
-
-    > div {
-      display: flex;
-      align-items: center;
-    }
   }
 }
 
 }
 
 .playlist {
-  display: inline-flex;
-  cursor: pointer;
+  padding: 8px 10px 8px 24px;
+
+  &.has-optional-row:hover {
+    background-color: inherit;
+  }
+}
+
+.primary-row,
+.optional-rows > div {
+  display: flex;
 
   my-peertube-checkbox {
     margin-right: 10px;
   .display-name {
     display: flex;
     align-items: flex-end;
+    flex-grow: 1;
+    margin: 0;
+    font-weight: $font-regular;
+    cursor: pointer;
+  }
+
+  .optional-row-icon {
+    display: flex;
+    align-items: center;
+    font-size: 14px;
+    cursor: pointer;
+
+    my-global-icon {
+      @include apply-svg-color(#333);
+
+      width: 19px;
+      height: 19px;
+      margin-right: 0;
+    }
+  }
+
+  my-timestamp-input {
+    margin-right: $timestamp-margin-right;
+
+    ::ng-deep .ui-inputtext {
+      padding: 0;
+      width: $timestamp-width;
+    }
+  }
+}
+
+.optional-rows {
+  > div {
+    padding: 8px 5px 5px 10px;
+  }
+
+  my-peertube-checkbox {
+    display: block;
+    width: $optional-rows-checkbox-width;
+    margin-right: 0 !important;
+  }
+
+  .labels {
+    margin-left: $optional-rows-checkbox-width;
+    font-size: 13px;
+    color: pvar(--greyForegroundColor);
+    padding-top: 5px;
+    padding-bottom: 0;
 
-    .timestamp-info {
-      font-size: 0.9em;
-      color: pvar(--greyForegroundColor);
-      margin-left: 5px;
+    div {
+      margin-right: $timestamp-margin-right;
+      width: $timestamp-width;
     }
   }
 }
index 41f16e0bfe78e173d6935b2e7cc3fffcf58b6952..b6a3408c7ac24614374eac176b474b1b2ab2e2b9 100644 (file)
@@ -4,21 +4,27 @@ import { debounceTime, filter } from 'rxjs/operators'
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
 import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
 import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { Video, VideoExistInPlaylist, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
+import { Video, VideoExistInPlaylist, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy, VideoPlaylistElementUpdate } from '@shared/models'
 import { secondsToTime } from '../../../assets/player/utils'
 import { VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR } from '../form-validators/video-playlist-validators'
 import { CachedPlaylist, VideoPlaylistService } from './video-playlist.service'
+import { invoke, last } from 'lodash'
 
 const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
 
+type PlaylistElement = {
+  enabled: boolean
+  playlistElementId?: number
+  startTimestamp?: number
+  stopTimestamp?: number
+}
+
 type PlaylistSummary = {
   id: number
-  inPlaylist: boolean
   displayName: string
+  optionalRowDisplayed: boolean
 
-  playlistElementId?: number
-  startTimestamp?: number
-  stopTimestamp?: number
+  elements: PlaylistElement[]
 }
 
 @Component({
@@ -33,16 +39,11 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
   @Input() lazyLoad = false
 
   isNewPlaylistBlockOpened = false
+
   videoPlaylistSearch: string
   videoPlaylistSearchChanged = new Subject<string>()
+
   videoPlaylists: PlaylistSummary[] = []
-  timestampOptions: {
-    startTimestampEnabled: boolean
-    startTimestamp: number
-    stopTimestampEnabled: boolean
-    stopTimestamp: number
-  }
-  displayOptions = false
 
   private disabled = false
 
@@ -106,7 +107,6 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
     this.videoPlaylists = []
     this.videoPlaylistSearch = undefined
 
-    this.resetOptions(true)
     this.load()
 
     this.cd.markForCheck()
@@ -115,7 +115,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
   load () {
     logger('Loading component')
 
-    this.listenToPlaylistChanges()
+    this.listenToVideoPlaylistChange()
 
     this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
         .subscribe(playlistsResult => {
@@ -128,7 +128,6 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
   openChange (opened: boolean) {
     if (opened === false) {
       this.isNewPlaylistBlockOpened = false
-      this.displayOptions = false
     }
   }
 
@@ -138,17 +137,49 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
     this.isNewPlaylistBlockOpened = true
   }
 
-  togglePlaylist (event: Event, playlist: PlaylistSummary) {
-    event.preventDefault()
+  toggleMainPlaylist (e: Event, playlist: PlaylistSummary) {
+    e.preventDefault()
+
+    if (this.isPresentMultipleTimes(playlist) || playlist.optionalRowDisplayed) return
 
-    if (playlist.inPlaylist === true) {
-      this.removeVideoFromPlaylist(playlist)
+    if (playlist.elements.length === 0) {
+      const element: PlaylistElement = {
+        enabled: true,
+        playlistElementId: undefined,
+        startTimestamp: 0,
+        stopTimestamp: this.video.duration
+      }
+
+      this.addVideoInPlaylist(playlist, element)
     } else {
-      this.addVideoInPlaylist(playlist)
+      this.removeVideoFromPlaylist(playlist, playlist.elements[0].playlistElementId)
+      playlist.elements = []
     }
 
-    playlist.inPlaylist = !playlist.inPlaylist
-    this.resetOptions()
+    this.cd.markForCheck()
+  }
+
+  toggleOptionalPlaylist (e: Event, playlist: PlaylistSummary, element: PlaylistElement, startTimestamp: number, stopTimestamp: number) {
+    e.preventDefault()
+
+    if (element.enabled) {
+      this.removeVideoFromPlaylist(playlist, element.playlistElementId)
+      element.enabled = false
+
+      // Hide optional rows pane when the user unchecked all the playlists
+      if (this.isPrimaryCheckboxChecked(playlist) === false) {
+        playlist.optionalRowDisplayed = false
+      }
+    } else {
+      const element: PlaylistElement = {
+        enabled: true,
+        playlistElementId: undefined,
+        startTimestamp,
+        stopTimestamp
+      }
+
+      this.addVideoInPlaylist(playlist, element)
+    }
 
     this.cd.markForCheck()
   }
@@ -172,34 +203,99 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
     )
   }
 
-  resetOptions (resetTimestamp = false) {
-    this.displayOptions = false
+  onVideoPlaylistSearchChanged () {
+    this.videoPlaylistSearchChanged.next()
+  }
 
-    this.timestampOptions = {} as any
-    this.timestampOptions.startTimestampEnabled = false
-    this.timestampOptions.stopTimestampEnabled = false
+  isPrimaryCheckboxChecked (playlist: PlaylistSummary) {
+    return playlist.elements.filter(e => e.enabled)
+                            .length !== 0
+  }
 
-    if (resetTimestamp) {
-      this.timestampOptions.startTimestamp = 0
-      this.timestampOptions.stopTimestamp = this.video.duration
-    }
+  toggleOptionalRow (playlist: PlaylistSummary) {
+    playlist.optionalRowDisplayed = !playlist.optionalRowDisplayed
+
+    this.cd.markForCheck()
   }
 
-  formatTimestamp (playlist: PlaylistSummary) {
-    const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
-    const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
+  getPrimaryInputName (playlist: PlaylistSummary) {
+    return 'in-playlist-primary-' + playlist.id
+  }
 
-    return `(${start}-${stop})`
+  getOptionalInputName (playlist: PlaylistSummary, element?: PlaylistElement) {
+    const suffix = element
+      ? '-' + element.playlistElementId
+      : ''
+
+    return 'in-playlist-optional-' + playlist.id + suffix
   }
 
-  onVideoPlaylistSearchChanged () {
-    this.videoPlaylistSearchChanged.next()
+  buildOptionalRowElements (playlist: PlaylistSummary) {
+    const elements = playlist.elements
+
+    const lastElement = elements.length === 0
+      ? undefined
+      : elements[elements.length - 1]
+
+    // Build an empty last element
+    if (!lastElement || lastElement.enabled === true) {
+      elements.push({
+        enabled: false,
+        startTimestamp: 0,
+        stopTimestamp: this.video.duration
+      })
+    }
+
+    return elements
+  }
+
+  isPresentMultipleTimes (playlist: PlaylistSummary) {
+    return playlist.elements.filter(e => e.enabled === true).length > 1
+  }
+
+  onElementTimestampUpdate (playlist: PlaylistSummary, element: PlaylistElement) {
+    if (!element.playlistElementId || element.enabled === false) return
+
+    const body: VideoPlaylistElementUpdate = {
+      startTimestamp: element.startTimestamp,
+      stopTimestamp: element.stopTimestamp
+    }
+
+    this.videoPlaylistService.updateVideoOfPlaylist(playlist.id, element.playlistElementId, body, this.video.id)
+        .subscribe(
+          () => {
+            this.notifier.success($localize`Timestamps updated`)
+          },
+
+          err => {
+            this.notifier.error(err.message)
+          },
+
+          () => this.cd.markForCheck()
+        )
   }
 
-  private removeVideoFromPlaylist (playlist: PlaylistSummary) {
-    if (!playlist.playlistElementId) return
+  private isOptionalRowDisplayed (playlist: PlaylistSummary) {
+    const elements = playlist.elements.filter(e => e.enabled)
+
+    if (elements.length > 1) return true
 
-    this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id)
+    if (elements.length === 1) {
+      const element = elements[0]
+
+      if (
+        (element.startTimestamp && element.startTimestamp !== 0) ||
+        (element.stopTimestamp && element.stopTimestamp !== this.video.duration)
+      ) {
+        return true
+      }
+    }
+
+    return false
+  }
+
+  private removeVideoFromPlaylist (playlist: PlaylistSummary, elementId: number) {
+    this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, elementId, this.video.id)
         .subscribe(
           () => {
             this.notifier.success($localize`Video removed from ${playlist.displayName}`)
@@ -213,7 +309,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
         )
   }
 
-  private listenToPlaylistChanges () {
+  private listenToVideoPlaylistChange () {
     this.unsubscribePlaylistChanges()
 
     this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
@@ -231,18 +327,30 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
   private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
     logger('Got existing results for %d.', this.video.id, existResult)
 
+    const oldPlaylists = this.videoPlaylists
+
     this.videoPlaylists = []
     for (const playlist of this.playlistsData) {
-      const existingPlaylist = existResult.find(p => p.playlistId === playlist.id)
+      const existingPlaylists = existResult.filter(p => p.playlistId === playlist.id)
 
-      this.videoPlaylists.push({
+      const playlistSummary = {
         id: playlist.id,
+        optionalRowDisplayed: false,
         displayName: playlist.displayName,
-        inPlaylist: !!existingPlaylist,
-        playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined,
-        startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
-        stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
-      })
+        elements: existingPlaylists.map(e => ({
+          enabled: true,
+          playlistElementId: e.playlistElementId,
+          startTimestamp: e.startTimestamp || 0,
+          stopTimestamp: e.stopTimestamp || this.video.duration
+        }))
+      }
+
+      const oldPlaylist = oldPlaylists.find(p => p.id === playlist.id)
+      playlistSummary.optionalRowDisplayed = oldPlaylist
+        ? oldPlaylist.optionalRowDisplayed
+        : this.isOptionalRowDisplayed(playlistSummary)
+
+      this.videoPlaylists.push(playlistSummary)
     }
 
     logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists)
@@ -250,20 +358,22 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
     this.cd.markForCheck()
   }
 
-  private addVideoInPlaylist (playlist: PlaylistSummary) {
+  private addVideoInPlaylist (playlist: PlaylistSummary, element: PlaylistElement) {
     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
+    if (element.startTimestamp) body.startTimestamp = element.startTimestamp
+    if (element.stopTimestamp && element.stopTimestamp !== this.video.duration) body.stopTimestamp = element.stopTimestamp
 
     this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
       .subscribe(
-        () => {
+        res => {
           const message = body.startTimestamp || body.stopTimestamp
-            ? $localize`Video added in ${playlist.displayName} at timestamps ${this.formatTimestamp(playlist)}`
+            ? $localize`Video added in ${playlist.displayName} at timestamps ${this.formatTimestamp(element)}`
             : $localize`Video added in ${playlist.displayName}`
 
           this.notifier.success(message)
+
+          if (element) element.playlistElementId = res.videoPlaylistElement.id
         },
 
         err => {
@@ -273,4 +383,11 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
         () => this.cd.markForCheck()
       )
   }
+
+  private formatTimestamp (element: PlaylistElement) {
+    const start = element.startTimestamp ? secondsToTime(element.startTimestamp) : ''
+    const stop = element.stopTimestamp ? secondsToTime(element.stopTimestamp) : ''
+
+    return `(${start}-${stop})`
+  }
 }
index dc1b56129067e06b7c7476428fc5918b94c0a3b5..1b87e0b2add12e743f2493265988e5334322b7ae 100644 (file)
@@ -1,7 +1,7 @@
 import * as debug from 'debug'
 import { uniq } from 'lodash-es'
 import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
-import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
+import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap, distinctUntilChanged } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable, NgZone } from '@angular/core'
 import { AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core'
@@ -53,6 +53,7 @@ export class VideoPlaylistService {
   ) {
     this.videoExistsInPlaylistObservable = merge(
       this.videoExistsInPlaylistNotifier.pipe(
+        distinctUntilChanged(),
         // We leave Angular zone so Protractor does not get stuck
         bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
         filter(videoIds => videoIds.length !== 0),