Update playlist add component to accept multiple times the same video

pull/3108/head
Chocobozzz 2020-08-18 15:51:51 +02:00 committed by Chocobozzz
parent cbb513e737
commit e79df4eefb
5 changed files with 292 additions and 134 deletions

View 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 { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { secondsToTime, timeToInt } from '../../../assets/player/utils' import { secondsToTime, timeToInt } from '../../../assets/player/utils'
@ -19,6 +19,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
@Input() timestamp: number @Input() timestamp: number
@Input() disabled = false @Input() disabled = false
@Output() inputBlur = new EventEmitter()
timestampString: string timestampString: string
constructor (private changeDetector: ChangeDetectorRef) {} constructor (private changeDetector: ChangeDetectorRef) {}
@ -57,5 +59,7 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
this.propagateChange(this.timestamp) this.propagateChange(this.timestamp)
} }
this.inputBlur.emit()
} }
} }

View File

@ -2,42 +2,6 @@
<div class="header"> <div class="header">
<div class="first-row"> <div class="first-row">
<div i18n class="title">Save to</div> <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 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> </div>
@ -46,14 +10,52 @@
</div> </div>
<div class="playlists"> <div class="playlists">
<div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)"> <div
<my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox> *ngFor="let playlist of videoPlaylists"
class="playlist dropdown-item" [ngClass]="{ 'has-optional-row': playlist.optionalRowDisplayed }"
>
<div class="primary-row">
<my-peertube-checkbox
[disabled]="isPresentMultipleTimes(playlist) || playlist.optionalRowDisplayed" [inputName]="getPrimaryInputName(playlist)"
[ngModel]="isPrimaryCheckboxChecked(playlist)" [onPushWorkaround]="true"
(click)="toggleMainPlaylist($event, playlist)"
></my-peertube-checkbox>
<div class="display-name"> <label class="display-name" (click)="toggleMainPlaylist($event, playlist)">
{{ playlist.displayName }} {{ playlist.displayName }}
</label>
<div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info"> <div class="optional-row-icon" *ngIf="isPrimaryCheckboxChecked(playlist)" (click)="toggleOptionalRow(playlist)">
{{ formatTimestamp(playlist) }} <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
</div>
</div>
<div class="optional-rows" *ngIf="playlist.optionalRowDisplayed">
<div class="labels">
<div i18n>Start at</div>
<div i18n>Stop at</div>
</div>
<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>
<my-timestamp-input
[maxTimestamp]="video.duration"
[(ngModel)]="element.startTimestamp"
(inputBlur)="onElementTimestampUpdate(playlist, element)"
#startAt
></my-timestamp-input>
<my-timestamp-input
[maxTimestamp]="video.duration"
[(ngModel)]="element.stopTimestamp"
(inputBlur)="onElementTimestampUpdate(playlist, element)"
#stopAt
></my-timestamp-input>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,10 @@
@import '_variables'; @import '_variables';
@import '_mixins'; @import '_mixins';
$optional-rows-checkbox-width: 34px;
$timestamp-width: 50px;
$timestamp-margin-right: 10px;
.header, .header,
.dropdown-item, .dropdown-item,
.input-container { .input-container {
@ -24,31 +28,6 @@
font-size: 18px; font-size: 18px;
flex-grow: 1; 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;
}
} }
} }
@ -58,8 +37,16 @@
} }
.playlist { .playlist {
display: inline-flex; padding: 8px 10px 8px 24px;
cursor: pointer;
&.has-optional-row:hover {
background-color: inherit;
}
}
.primary-row,
.optional-rows > div {
display: flex;
my-peertube-checkbox { my-peertube-checkbox {
margin-right: 10px; margin-right: 10px;
@ -69,11 +56,58 @@
.display-name { .display-name {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
flex-grow: 1;
margin: 0;
font-weight: $font-regular;
cursor: pointer;
}
.timestamp-info { .optional-row-icon {
font-size: 0.9em; display: flex;
color: pvar(--greyForegroundColor); align-items: center;
margin-left: 5px; 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;
div {
margin-right: $timestamp-margin-right;
width: $timestamp-width;
} }
} }
} }

View File

@ -4,23 +4,29 @@ import { debounceTime, filter } from 'rxjs/operators'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core' import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
import { AuthService, DisableForReuseHook, Notifier } from '@app/core' import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 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 { secondsToTime } from '../../../assets/player/utils'
import { VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR } from '../form-validators/video-playlist-validators' import { VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR } from '../form-validators/video-playlist-validators'
import { CachedPlaylist, VideoPlaylistService } from './video-playlist.service' import { CachedPlaylist, VideoPlaylistService } from './video-playlist.service'
import { invoke, last } from 'lodash'
const logger = debug('peertube:playlists:VideoAddToPlaylistComponent') const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
type PlaylistSummary = { type PlaylistElement = {
id: number enabled: boolean
inPlaylist: boolean
displayName: string
playlistElementId?: number playlistElementId?: number
startTimestamp?: number startTimestamp?: number
stopTimestamp?: number stopTimestamp?: number
} }
type PlaylistSummary = {
id: number
displayName: string
optionalRowDisplayed: boolean
elements: PlaylistElement[]
}
@Component({ @Component({
selector: 'my-video-add-to-playlist', selector: 'my-video-add-to-playlist',
styleUrls: [ './video-add-to-playlist.component.scss' ], styleUrls: [ './video-add-to-playlist.component.scss' ],
@ -33,16 +39,11 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
@Input() lazyLoad = false @Input() lazyLoad = false
isNewPlaylistBlockOpened = false isNewPlaylistBlockOpened = false
videoPlaylistSearch: string videoPlaylistSearch: string
videoPlaylistSearchChanged = new Subject<string>() videoPlaylistSearchChanged = new Subject<string>()
videoPlaylists: PlaylistSummary[] = [] videoPlaylists: PlaylistSummary[] = []
timestampOptions: {
startTimestampEnabled: boolean
startTimestamp: number
stopTimestampEnabled: boolean
stopTimestamp: number
}
displayOptions = false
private disabled = false private disabled = false
@ -106,7 +107,6 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
this.videoPlaylists = [] this.videoPlaylists = []
this.videoPlaylistSearch = undefined this.videoPlaylistSearch = undefined
this.resetOptions(true)
this.load() this.load()
this.cd.markForCheck() this.cd.markForCheck()
@ -115,7 +115,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
load () { load () {
logger('Loading component') logger('Loading component')
this.listenToPlaylistChanges() this.listenToVideoPlaylistChange()
this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch) this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
.subscribe(playlistsResult => { .subscribe(playlistsResult => {
@ -128,7 +128,6 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
openChange (opened: boolean) { openChange (opened: boolean) {
if (opened === false) { if (opened === false) {
this.isNewPlaylistBlockOpened = false this.isNewPlaylistBlockOpened = false
this.displayOptions = false
} }
} }
@ -138,17 +137,49 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
this.isNewPlaylistBlockOpened = true this.isNewPlaylistBlockOpened = true
} }
togglePlaylist (event: Event, playlist: PlaylistSummary) { toggleMainPlaylist (e: Event, playlist: PlaylistSummary) {
event.preventDefault() e.preventDefault()
if (playlist.inPlaylist === true) { if (this.isPresentMultipleTimes(playlist) || playlist.optionalRowDisplayed) return
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 { } else {
this.addVideoInPlaylist(playlist) this.removeVideoFromPlaylist(playlist, playlist.elements[0].playlistElementId)
playlist.elements = []
} }
playlist.inPlaylist = !playlist.inPlaylist this.cd.markForCheck()
this.resetOptions() }
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() this.cd.markForCheck()
} }
@ -172,34 +203,99 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
) )
} }
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})`
}
onVideoPlaylistSearchChanged () { onVideoPlaylistSearchChanged () {
this.videoPlaylistSearchChanged.next() this.videoPlaylistSearchChanged.next()
} }
private removeVideoFromPlaylist (playlist: PlaylistSummary) { isPrimaryCheckboxChecked (playlist: PlaylistSummary) {
if (!playlist.playlistElementId) return return playlist.elements.filter(e => e.enabled)
.length !== 0
}
this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id) toggleOptionalRow (playlist: PlaylistSummary) {
playlist.optionalRowDisplayed = !playlist.optionalRowDisplayed
this.cd.markForCheck()
}
getPrimaryInputName (playlist: PlaylistSummary) {
return 'in-playlist-primary-' + playlist.id
}
getOptionalInputName (playlist: PlaylistSummary, element?: PlaylistElement) {
const suffix = element
? '-' + element.playlistElementId
: ''
return 'in-playlist-optional-' + playlist.id + suffix
}
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 isOptionalRowDisplayed (playlist: PlaylistSummary) {
const elements = playlist.elements.filter(e => e.enabled)
if (elements.length > 1) return true
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( .subscribe(
() => { () => {
this.notifier.success($localize`Video removed from ${playlist.displayName}`) 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.unsubscribePlaylistChanges()
this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id) this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
@ -231,18 +327,30 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
private rebuildPlaylists (existResult: VideoExistInPlaylist[]) { private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
logger('Got existing results for %d.', this.video.id, existResult) logger('Got existing results for %d.', this.video.id, existResult)
const oldPlaylists = this.videoPlaylists
this.videoPlaylists = [] this.videoPlaylists = []
for (const playlist of this.playlistsData) { 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, id: playlist.id,
optionalRowDisplayed: false,
displayName: playlist.displayName, displayName: playlist.displayName,
inPlaylist: !!existingPlaylist, elements: existingPlaylists.map(e => ({
playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined, enabled: true,
startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined, playlistElementId: e.playlistElementId,
stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined 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) 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() this.cd.markForCheck()
} }
private addVideoInPlaylist (playlist: PlaylistSummary) { private addVideoInPlaylist (playlist: PlaylistSummary, element: PlaylistElement) {
const body: VideoPlaylistElementCreate = { videoId: this.video.id } const body: VideoPlaylistElementCreate = { videoId: this.video.id }
if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp if (element.startTimestamp) body.startTimestamp = element.startTimestamp
if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp if (element.stopTimestamp && element.stopTimestamp !== this.video.duration) body.stopTimestamp = element.stopTimestamp
this.videoPlaylistService.addVideoInPlaylist(playlist.id, body) this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
.subscribe( .subscribe(
() => { res => {
const message = body.startTimestamp || body.stopTimestamp 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}` : $localize`Video added in ${playlist.displayName}`
this.notifier.success(message) this.notifier.success(message)
if (element) element.playlistElementId = res.videoPlaylistElement.id
}, },
err => { err => {
@ -273,4 +383,11 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
() => this.cd.markForCheck() () => this.cd.markForCheck()
) )
} }
private formatTimestamp (element: PlaylistElement) {
const start = element.startTimestamp ? secondsToTime(element.startTimestamp) : ''
const stop = element.stopTimestamp ? secondsToTime(element.stopTimestamp) : ''
return `(${start}-${stop})`
}
} }

View File

@ -1,7 +1,7 @@
import * as debug from 'debug' import * as debug from 'debug'
import { uniq } from 'lodash-es' import { uniq } from 'lodash-es'
import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs' 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 { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable, NgZone } from '@angular/core' import { Injectable, NgZone } from '@angular/core'
import { AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core' import { AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core'
@ -53,6 +53,7 @@ export class VideoPlaylistService {
) { ) {
this.videoExistsInPlaylistObservable = merge( this.videoExistsInPlaylistObservable = merge(
this.videoExistsInPlaylistNotifier.pipe( this.videoExistsInPlaylistNotifier.pipe(
distinctUntilChanged(),
// We leave Angular zone so Protractor does not get stuck // We leave Angular zone so Protractor does not get stuck
bufferTime(500, leaveZone(this.ngZone, asyncScheduler)), bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
filter(videoIds => videoIds.length !== 0), filter(videoIds => videoIds.length !== 0),