1
0
Fork 0

Add to playlist dropdown

This commit is contained in:
Chocobozzz 2019-03-07 17:06:00 +01:00 committed by Chocobozzz
parent 830b4faff1
commit f0a3988066
55 changed files with 961 additions and 94 deletions

View File

@ -206,6 +206,9 @@
# Design # 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

View File

@ -22,6 +22,9 @@ import {
import { import {
MyAccountVideoPlaylistUpdateComponent MyAccountVideoPlaylistUpdateComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' } 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 = [ 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', path: 'video-playlists/create',
component: MyAccountVideoPlaylistCreateComponent, component: MyAccountVideoPlaylistCreateComponent,

View File

@ -4,7 +4,7 @@
.custom-row { .custom-row {
display: flex; display: flex;
align-items: center; align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.10); border-bottom: 1px solid $separator-border-color;
&:first-child { &:first-child {
font-size: 16px; font-size: 16px;

View File

@ -60,5 +60,6 @@
</div> </div>
</div> </div>
</div> </div>
<input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</form> </form>

View File

@ -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>

View File

@ -0,0 +1,2 @@
@import '_variables';
@import '_mixins';

View File

@ -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
})
}
}

View File

@ -5,10 +5,10 @@
</a> </a>
</div> </div>
<div class="video-playlists"> <div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
<div *ngFor="let playlist of videoPlaylists" class="video-playlist"> <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
<div class="miniature-wrapper"> <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>
<div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons"> <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">

View File

@ -69,17 +69,20 @@ export class MyAccountVideoPlaylistsComponent implements OnInit {
return playlist.type.id === VideoPlaylistType.REGULAR return playlist.type.id === VideoPlaylistType.REGULAR
} }
private loadVideoPlaylists () { onNearOfBottom () {
this.authService.userInformationLoaded
.pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account)))
.subscribe(res => this.videoPlaylists = res.data)
}
private ofNearOfBottom () {
// Last page // Last page
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
this.pagination.currentPage += 1 this.pagination.currentPage += 1
this.loadVideoPlaylists() 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
})
}
} }

View File

@ -32,6 +32,9 @@ import {
MyAccountVideoPlaylistUpdateComponent MyAccountVideoPlaylistUpdateComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' } 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 { 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({ @NgModule({
imports: [ imports: [
@ -68,7 +71,8 @@ import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-vi
MyAccountVideoPlaylistCreateComponent, MyAccountVideoPlaylistCreateComponent,
MyAccountVideoPlaylistUpdateComponent, MyAccountVideoPlaylistUpdateComponent,
MyAccountVideoPlaylistsComponent MyAccountVideoPlaylistsComponent,
MyAccountVideoPlaylistElementsComponent
], ],
exports: [ exports: [

View File

@ -0,0 +1,4 @@
<p-inputMask
[disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
></p-inputMask>

View File

@ -0,0 +1,8 @@
p-inputmask {
/deep/ input {
width: 80px;
font-size: 15px;
border: none;
}
}

View File

@ -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)
}
}
}

View File

@ -25,7 +25,8 @@ const icons = {
'like': require('../../../assets/images/video/like.html'), 'like': require('../../../assets/images/video/like.html'),
'more': require('../../../assets/images/video/more.html'), 'more': require('../../../assets/images/video/more.html'),
'share': require('../../../assets/images/video/share.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 export type GlobalIconName = keyof typeof icons

View File

@ -9,6 +9,7 @@ import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.d
import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
import { KeyFilterModule } from 'primeng/keyfilter'
import { AUTH_INTERCEPTOR_PROVIDER } from './auth' import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
import { ButtonComponent } from './buttons/button.component' import { ButtonComponent } from './buttons/button.component'
@ -49,6 +50,7 @@ import {
VideoValidatorsService VideoValidatorsService
} from '@app/shared/forms' } from '@app/shared/forms'
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
import { InputMaskModule } from 'primeng/inputmask'
import { ScreenService } from '@app/shared/misc/screen.service' import { ScreenService } from '@app/shared/misc/screen.service'
import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
import { VideoCaptionService } from '@app/shared/video-caption' 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 { ImageUploadComponent } from '@app/shared/images/image-upload.component'
import { GlobalIconComponent } from '@app/shared/images/global-icon.component' import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.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({ @NgModule({
imports: [ imports: [
@ -90,6 +94,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
NgbTooltipModule, NgbTooltipModule,
PrimeSharedModule, PrimeSharedModule,
InputMaskModule,
KeyFilterModule,
NgPipesModule NgPipesModule
], ],
@ -100,11 +106,14 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
VideoThumbnailComponent, VideoThumbnailComponent,
VideoMiniatureComponent, VideoMiniatureComponent,
VideoPlaylistMiniatureComponent, VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent,
FeedComponent, FeedComponent,
ButtonComponent, ButtonComponent,
DeleteButtonComponent, DeleteButtonComponent,
EditButtonComponent, EditButtonComponent,
ActionDropdownComponent, ActionDropdownComponent,
NumberFormatterPipe, NumberFormatterPipe,
ObjectLengthPipe, ObjectLengthPipe,
@ -113,8 +122,11 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
InfiniteScrollerDirective, InfiniteScrollerDirective,
TextareaAutoResizeDirective, TextareaAutoResizeDirective,
HelpComponent, HelpComponent,
ReactiveFileComponent, ReactiveFileComponent,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
TimestampInputComponent,
SubscribeButtonComponent, SubscribeButtonComponent,
RemoteSubscribeComponent, RemoteSubscribeComponent,
InstanceFeaturesTableComponent, InstanceFeaturesTableComponent,
@ -142,6 +154,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
NgbTooltipModule, NgbTooltipModule,
PrimeSharedModule, PrimeSharedModule,
InputMaskModule,
KeyFilterModule,
BytesPipe, BytesPipe,
KeysPipe, KeysPipe,
@ -151,18 +165,24 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
VideoThumbnailComponent, VideoThumbnailComponent,
VideoMiniatureComponent, VideoMiniatureComponent,
VideoPlaylistMiniatureComponent, VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent,
FeedComponent, FeedComponent,
ButtonComponent, ButtonComponent,
DeleteButtonComponent, DeleteButtonComponent,
EditButtonComponent, EditButtonComponent,
ActionDropdownComponent, ActionDropdownComponent,
MarkdownTextareaComponent, MarkdownTextareaComponent,
InfiniteScrollerDirective, InfiniteScrollerDirective,
TextareaAutoResizeDirective, TextareaAutoResizeDirective,
HelpComponent, HelpComponent,
ReactiveFileComponent, ReactiveFileComponent,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
TimestampInputComponent,
SubscribeButtonComponent, SubscribeButtonComponent,
RemoteSubscribeComponent, RemoteSubscribeComponent,
InstanceFeaturesTableComponent, InstanceFeaturesTableComponent,

View File

@ -38,7 +38,7 @@ export class SubscribeButtonComponent implements OnInit {
ngOnInit () { ngOnInit () {
if (this.isUserLoggedIn()) { if (this.isUserLoggedIn()) {
this.userSubscriptionService.isSubscriptionExists(this.uri) this.userSubscriptionService.doesSubscriptionExist(this.uri)
.subscribe( .subscribe(
res => this.subscribed = res[this.uri], res => this.subscribed = res[this.uri],

View File

@ -28,7 +28,7 @@ export class UserSubscriptionService {
this.existsObservable = this.existsSubject.pipe( this.existsObservable = this.existsSubject.pipe(
bufferTime(500), bufferTime(500),
filter(uris => uris.length !== 0), filter(uris => uris.length !== 0),
switchMap(uris => this.areSubscriptionExist(uris)), switchMap(uris => this.doSubscriptionsExist(uris)),
share() share()
) )
} }
@ -69,13 +69,13 @@ export class UserSubscriptionService {
) )
} }
isSubscriptionExists (nameWithHost: string) { doesSubscriptionExist (nameWithHost: string) {
this.existsSubject.next(nameWithHost) this.existsSubject.next(nameWithHost)
return this.existsObservable.pipe(first()) 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' const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
let params = new HttpParams() let params = new HttpParams()

View File

@ -13,7 +13,7 @@
align-items: center; align-items: center;
font-size: inherit; font-size: inherit;
padding: 15px 5px 15px 10px; padding: 15px 5px 15px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.10); border-bottom: 1px solid $separator-border-color;
&.unread { &.unread {
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.05);

View File

@ -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>

View File

@ -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;
}

View File

@ -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
}
)
}
}

View File

@ -1,6 +1,6 @@
<div class="miniature"> <div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
<a <a
[routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName"
class="miniature-thumbnail" class="miniature-thumbnail"
> >
<img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
@ -15,7 +15,7 @@
</a> </a>
<div class="miniature-bottom"> <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 }} {{ playlist.displayName }}
</a> </a>
</div> </div>

View File

@ -5,6 +5,17 @@
.miniature { .miniature {
display: inline-block; display: inline-block;
&.no-videos:not(.to-manage){
a {
cursor: default !important;
}
}
&.to-manage .play-overlay,
&.no-videos {
display: none;
}
.miniature-thumbnail { .miniature-thumbnail {
@include miniature-thumbnail; @include miniature-thumbnail;

View File

@ -8,4 +8,12 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
}) })
export class VideoPlaylistMiniatureComponent { export class VideoPlaylistMiniatureComponent {
@Input() playlist: VideoPlaylist @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 ]
}
} }

View File

@ -46,6 +46,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
this.isLocal = hash.isLocal this.isLocal = hash.isLocal
this.displayName = hash.displayName this.displayName = hash.displayName
this.description = hash.description this.description = hash.description
this.privacy = hash.privacy this.privacy = hash.privacy
@ -70,5 +71,9 @@ export class VideoPlaylist implements ServerVideoPlaylist {
} }
this.privacy.label = peertubeTranslate(this.privacy.label, translations) this.privacy.label = peertubeTranslate(this.privacy.label, translations)
if (this.type.id === VideoPlaylistType.WATCH_LATER) {
this.displayName = peertubeTranslate(this.displayName, translations)
}
} }
} }

View 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 { Injectable } from '@angular/core'
import { Observable } from 'rxjs' import { Observable, ReplaySubject, Subject } from 'rxjs'
import { RestExtractor } from '../rest/rest-extractor.service' import { RestExtractor } from '../rest/rest-extractor.service'
import { HttpClient } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { ResultList } from '../../../../../shared' import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 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 { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
import { AccountService } from '@app/shared/account/account.service' import { AccountService } from '@app/shared/account/account.service'
import { Account } from '@app/shared/account/account.model' 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() @Injectable()
export class VideoPlaylistService { export class VideoPlaylistService {
static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' 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 ( constructor (
private authHttp: HttpClient, private authHttp: HttpClient,
private serverService: ServerService, 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>> { listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' 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' 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( .pipe(
switchMap(res => this.extractPlaylists(res)), switchMap(res => this.extractPlaylists(res)),
catchError(err => this.restExtractor.handleError(err)) catchError(err => this.restExtractor.handleError(err))
@ -59,9 +77,8 @@ export class VideoPlaylistService {
createVideoPlaylist (body: VideoPlaylistCreate) { createVideoPlaylist (body: VideoPlaylistCreate) {
const data = objectToFormData(body) 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( .pipe(
map(this.restExtractor.extractDataBool),
catchError(err => this.restExtractor.handleError(err)) 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>) { extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
return this.serverService.localeObservable return this.serverService.localeObservable
.pipe( .pipe(
@ -105,4 +152,14 @@ export class VideoPlaylistService {
return this.serverService.localeObservable return this.serverService.localeObservable
.pipe(map(translations => new VideoPlaylist(playlist, translations))) .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)))
}
} }

View File

@ -31,6 +31,8 @@ import { ServerService } from '@app/core'
import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { I18n } from '@ngx-translate/i18n-polyfill' 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 { export interface VideosProvider {
getVideos ( 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 ( getUserSubscriptionVideos (
videoPagination: ComponentPagination, videoPagination: ComponentPagination,
sort: VideoSortField sort: VideoSortField

View File

@ -6,11 +6,19 @@
<div class="modal-body"> <div class="modal-body">
<div *ngIf="currentVideoTimestampString" class="start-at"> <div class="start-at">
<my-peertube-checkbox <my-peertube-checkbox
inputName="startAt" [(ngModel)]="startAtCheckbox" inputName="startAt" [(ngModel)]="startAtCheckbox"
i18n-labelText [labelText]="getStartCheckboxLabel()" i18n-labelText labelText="Start at"
></my-peertube-checkbox> ></my-peertube-checkbox>
<my-timestamp-input
[timestamp]="currentVideoTimestamp"
[maxTimestamp]="video.duration"
[disabled]="!startAtCheckbox"
[(ngModel)]="currentVideoTimestamp"
>
</my-timestamp-input>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -13,4 +13,9 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-top: 10px; margin-top: 10px;
align-items: center;
my-timestamp-input {
margin-left: 10px;
}
} }

View File

@ -16,10 +16,8 @@ export class VideoShareComponent {
@Input() video: VideoDetails = null @Input() video: VideoDetails = null
currentVideoTimestamp: number
startAtCheckbox = false startAtCheckbox = false
currentVideoTimestampString: string
private currentVideoTimestamp: number
constructor ( constructor (
private modalService: NgbModal, private modalService: NgbModal,
@ -28,8 +26,7 @@ export class VideoShareComponent {
) { } ) { }
show (currentVideoTimestamp?: number) { show (currentVideoTimestamp?: number) {
this.currentVideoTimestamp = Math.floor(currentVideoTimestamp) this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0
this.currentVideoTimestampString = durationToString(this.currentVideoTimestamp)
this.modalService.open(this.modal) this.modalService.open(this.modal)
} }
@ -52,10 +49,6 @@ export class VideoShareComponent {
this.notifier.success(this.i18n('Copied')) this.notifier.success(this.i18n('Copied'))
} }
getStartCheckboxLabel () {
return this.i18n('Start at {{timestamp}}', { timestamp: this.currentVideoTimestampString })
}
private getVideoTimestampIfEnabled () { private getVideoTimestampIfEnabled () {
if (this.startAtCheckbox === true) return this.currentVideoTimestamp if (this.startAtCheckbox === true) return this.currentVideoTimestamp

View File

@ -7,7 +7,16 @@ import { VideoWatchComponent } from './video-watch.component'
const videoWatchRoutes: Routes = [ const videoWatchRoutes: Routes = [
{ {
path: '', path: 'playlist/:uuid',
component: VideoWatchComponent,
canActivate: [ MetaGuard ]
},
{
path: ':uuid/comments/:commentId',
redirectTo: ':uuid'
},
{
path: ':uuid',
component: VideoWatchComponent, component: VideoWatchComponent,
canActivate: [ MetaGuard ] canActivate: [ MetaGuard ]
} }

View File

@ -65,17 +65,31 @@
<my-global-icon iconName="dislike"></my-global-icon> <my-global-icon iconName="dislike"></my-global-icon>
</div> </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> <my-global-icon iconName="heart"></my-global-icon>
<span class="icon-text" i18n>Support</span> <span class="icon-text" i18n>Support</span>
</div> </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> <my-global-icon iconName="share"></my-global-icon>
<span class="icon-text" i18n>Share</span> <span class="icon-text" i18n>Share</span>
</div> </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"> <div class="action-button" ngbDropdownToggle role="button">
<my-global-icon class="more-icon" iconName="more"></my-global-icon> <my-global-icon class="more-icon" iconName="more"></my-global-icon>
</div> </div>

View File

@ -176,7 +176,7 @@ $other-videos-width: 260px;
display: flex; display: flex;
align-items: center; align-items: center;
.action-button:not(:first-child), .action-more { .action-button:not(:first-child), .action-dropdown {
margin-left: 10px; margin-left: 10px;
} }
@ -212,12 +212,19 @@ $other-videos-width: 260px;
} }
} }
&.action-button-save {
my-global-icon {
top: 0 !important;
right: -1px;
}
}
.icon-text { .icon-text {
margin-left: 3px; margin-left: 3px;
} }
} }
.action-more { .action-dropdown {
display: inline-block; display: inline-block;
.dropdown-menu .dropdown-item { .dropdown-menu .dropdown-item {

View File

@ -59,6 +59,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
remoteServerDown = false remoteServerDown = false
hotkeys: Hotkey[] hotkeys: Hotkey[]
private currentTime: number
private paramsSub: Subscription private paramsSub: Subscription
constructor ( constructor (
@ -114,10 +115,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
) )
.subscribe(([ video, captionsResult ]) => { .subscribe(([ video, captionsResult ]) => {
const startTime = this.route.snapshot.queryParams.start const startTime = this.route.snapshot.queryParams.start
const stopTime = this.route.snapshot.queryParams.stop
const subtitle = this.route.snapshot.queryParams.subtitle const subtitle = this.route.snapshot.queryParams.subtitle
const playerMode = this.route.snapshot.queryParams.mode 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)) .catch(err => this.handleError(err))
}) })
}) })
@ -219,7 +221,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
showShareModal () { showShareModal () {
const currentTime = this.player ? this.player.currentTime() : undefined const currentTime = this.player ? this.player.currentTime() : undefined
this.videoShareModal.show(currentTime) this.videoShareModal.show(this.currentTime)
} }
showDownloadModal (event: Event) { showDownloadModal (event: Event) {
@ -371,7 +373,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private async onVideoFetched ( private async onVideoFetched (
video: VideoDetails, video: VideoDetails,
videoCaptions: VideoCaption[], videoCaptions: VideoCaption[],
urlOptions: { startTime?: number, subtitle?: string, playerMode?: string } urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string }
) { ) {
this.video = video this.video = video
@ -379,6 +381,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.descriptionLoading = false this.descriptionLoading = false
this.completeDescriptionShown = false this.completeDescriptionShown = false
this.remoteServerDown = false this.remoteServerDown = false
this.currentTime = undefined
let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
// If we are at the end of the video, reset the timer // If we are at the end of the video, reset the timer
@ -420,6 +423,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
inactivityTimeout: 2500, inactivityTimeout: 2500,
poster: this.video.previewUrl, poster: this.video.previewUrl,
startTime, startTime,
stopTime: urlOptions.stopTime,
theaterMode: true, theaterMode: true,
captions: videoCaptions.length !== 0, captions: videoCaptions.length !== 0,
@ -466,6 +470,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.zone.runOutsideAngular(async () => { this.zone.runOutsideAngular(async () => {
this.player = await PeertubePlayerManager.initialize(mode, options) this.player = await PeertubePlayerManager.initialize(mode, options)
this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
this.player.on('timeupdate', () => {
this.currentTime = Math.floor(this.player.currentTime())
})
}) })
this.setVideoDescriptionHTML() this.setVideoDescriptionHTML()

View File

@ -78,11 +78,7 @@ const videosRoutes: Routes = [
} }
}, },
{ {
path: 'watch/:uuid/comments/:commentId', path: 'watch',
redirectTo: 'watch/:uuid'
},
{
path: 'watch/:uuid',
loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule', loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule',
data: { data: {
preload: 3000 preload: 3000

View File

@ -2,9 +2,9 @@
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-92.000000, -115.000000)"> <g transform="translate(-92.000000, -115.000000)">
<g id="2" 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> <circle id="Oval-1" stroke="#000000" 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="#000000" 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> <rect id="Rectangle-1" fill="#000000" x="7" y="11" width="10" height="2" rx="1"></rect>
</g> </g>
</g> </g>
</g> </g>

Before

Width:  |  Height:  |  Size: 700 B

After

Width:  |  Height:  |  Size: 700 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 604 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -49,6 +49,7 @@ export type CommonOptions = {
inactivityTimeout: number inactivityTimeout: number
poster: string poster: string
startTime: number | string startTime: number | string
stopTime: number | string
theaterMode: boolean theaterMode: boolean
captions: boolean captions: boolean
@ -199,10 +200,10 @@ export class PeertubePlayerManager {
autoplay, // Use peertube plugin autoplay because we get the file by webtorrent autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
videoViewUrl: commonOptions.videoViewUrl, videoViewUrl: commonOptions.videoViewUrl,
videoDuration: commonOptions.videoDuration, videoDuration: commonOptions.videoDuration,
startTime: commonOptions.startTime,
userWatching: commonOptions.userWatching, userWatching: commonOptions.userWatching,
subtitle: commonOptions.subtitle, subtitle: commonOptions.subtitle,
videoCaptions: commonOptions.videoCaptions videoCaptions: commonOptions.videoCaptions,
stopTime: commonOptions.stopTime
} }
} }
@ -210,6 +211,7 @@ export class PeertubePlayerManager {
const p2pMediaLoader: P2PMediaLoaderPluginOptions = { const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls, redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls,
type: 'application/x-mpegURL', type: 'application/x-mpegURL',
startTime: commonOptions.startTime,
src: p2pMediaLoaderOptions.playlistUrl src: p2pMediaLoaderOptions.playlistUrl
} }
@ -254,7 +256,8 @@ export class PeertubePlayerManager {
autoplay, autoplay,
videoDuration: commonOptions.videoDuration, videoDuration: commonOptions.videoDuration,
playerElement: commonOptions.playerElement, playerElement: commonOptions.playerElement,
videoFiles: webtorrentOptions.videoFiles videoFiles: webtorrentOptions.videoFiles,
startTime: commonOptions.startTime
} }
Object.assign(plugins, { webtorrent }) Object.assign(plugins, { webtorrent })

View File

@ -22,7 +22,6 @@ import {
const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin') const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
class PeerTubePlugin extends Plugin { class PeerTubePlugin extends Plugin {
private readonly startTime: number = 0
private readonly videoViewUrl: string private readonly videoViewUrl: string
private readonly videoDuration: number private readonly videoDuration: number
private readonly CONSTANTS = { private readonly CONSTANTS = {
@ -35,13 +34,11 @@ class PeerTubePlugin extends Plugin {
private videoViewInterval: any private videoViewInterval: any
private userWatchingVideoInterval: any private userWatchingVideoInterval: any
private qualityObservationTimer: any
private lastResolutionChange: ResolutionUpdateData private lastResolutionChange: ResolutionUpdateData
constructor (player: videojs.Player, options: PeerTubePluginOptions) { constructor (player: videojs.Player, options: PeerTubePluginOptions) {
super(player, options) super(player, options)
this.startTime = timeToInt(options.startTime)
this.videoViewUrl = options.videoViewUrl this.videoViewUrl = options.videoViewUrl
this.videoDuration = options.videoDuration this.videoDuration = options.videoDuration
this.videoCaptions = options.videoCaptions this.videoCaptions = options.videoCaptions
@ -84,6 +81,14 @@ class PeerTubePlugin extends Plugin {
saveMuteInStore(this.player.muted()) 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', () => { this.player.textTracks().on('change', () => {
const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => { const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
return t.kind === 'captions' && t.mode === 'showing' return t.kind === 'captions' && t.mode === 'showing'
@ -109,10 +114,7 @@ class PeerTubePlugin extends Plugin {
} }
dispose () { dispose () {
clearTimeout(this.qualityObservationTimer) if (this.videoViewInterval) clearInterval(this.videoViewInterval)
clearInterval(this.videoViewInterval)
if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval) if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
} }

View File

@ -41,12 +41,13 @@ type PeerTubePluginOptions = {
autoplay: boolean autoplay: boolean
videoViewUrl: string videoViewUrl: string
videoDuration: number videoDuration: number
startTime: number | string
userWatching?: UserWatching userWatching?: UserWatching
subtitle?: string subtitle?: string
videoCaptions: VideoJSCaption[] videoCaptions: VideoJSCaption[]
stopTime: number | string
} }
type WebtorrentPluginOptions = { type WebtorrentPluginOptions = {
@ -56,12 +57,16 @@ type WebtorrentPluginOptions = {
videoDuration: number videoDuration: number
videoFiles: VideoFile[] videoFiles: VideoFile[]
startTime: number | string
} }
type P2PMediaLoaderPluginOptions = { type P2PMediaLoaderPluginOptions = {
redundancyBaseUrls: string[] redundancyBaseUrls: string[]
type: string type: string
src: string src: string
startTime: number | string
} }
type VideoJSPluginOptions = { type VideoJSPluginOptions = {

View File

@ -42,7 +42,7 @@ function timeToInt (time: number | string) {
if (!time) return 0 if (!time) return 0
if (typeof time === 'number') return time 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) const matches = time.match(reg)
if (!matches) return 0 if (!matches) return 0
@ -54,18 +54,27 @@ function timeToInt (time: number | string) {
return hours * 3600 + minutes * 60 + seconds return hours * 3600 + minutes * 60 + seconds
} }
function secondsToTime (seconds: number) { function secondsToTime (seconds: number, full = false, symbol?: string) {
let time = '' let time = ''
const hourSymbol = (symbol || 'h')
const minuteSymbol = (symbol || 'm')
const secondsSymbol = full ? '' : 's'
let hours = Math.floor(seconds / 3600) 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 seconds %= 3600
let minutes = Math.floor(seconds / 60) 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 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 return time
} }
@ -131,6 +140,7 @@ export {
getRtcConfig, getRtcConfig,
toTitleCase, toTitleCase,
timeToInt, timeToInt,
secondsToTime,
buildVideoLink, buildVideoLink,
buildVideoEmbed, buildVideoEmbed,
videoFileMaxByResolution, videoFileMaxByResolution,

View File

@ -6,7 +6,7 @@ import * as WebTorrent from 'webtorrent'
import { VideoFile } from '../../../../../shared/models/videos/video.model' import { VideoFile } from '../../../../../shared/models/videos/video.model'
import { renderVideo } from './video-renderer' import { renderVideo } from './video-renderer'
import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings' 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 { PeertubeChunkStore } from './peertube-chunk-store'
import { import {
getAverageBandwidthInStore, getAverageBandwidthInStore,
@ -73,6 +73,8 @@ class WebTorrentPlugin extends Plugin {
constructor (player: videojs.Player, options: WebtorrentPluginOptions) { constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
super(player, options) super(player, options)
this.startTime = timeToInt(options.startTime)
// Disable auto play on iOS // Disable auto play on iOS
this.autoplay = options.autoplay && this.isIOS() === false this.autoplay = options.autoplay && this.isIOS() === false
this.playerRefusedP2P = !getStoredWebTorrentEnabled() this.playerRefusedP2P = !getStoredWebTorrentEnabled()

View File

@ -515,4 +515,3 @@
align-items: center; align-items: center;
} }
} }

View File

@ -44,6 +44,8 @@ $footer-margin: 30px;
$footer-border-color: $header-border-color; $footer-border-color: $header-border-color;
$separator-border-color: rgba(0, 0, 0, 0.10);
$video-thumbnail-height: 122px; $video-thumbnail-height: 122px;
$video-thumbnail-width: 223px; $video-thumbnail-width: 223px;

View File

@ -168,6 +168,7 @@ class PeerTubeEmbed {
subtitle: string subtitle: string
enableApi = false enableApi = false
startTime: number | string = 0 startTime: number | string = 0
stopTime: number | string
mode: PlayerMode mode: PlayerMode
scope = 'peertube' scope = 'peertube'
@ -262,6 +263,7 @@ class PeerTubeEmbed {
this.scope = this.getParamString(params, 'scope', this.scope) this.scope = this.getParamString(params, 'scope', this.scope)
this.subtitle = this.getParamString(params, 'subtitle') this.subtitle = this.getParamString(params, 'subtitle')
this.startTime = this.getParamString(params, 'start') 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' this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent'
} catch (err) { } catch (err) {
@ -306,6 +308,7 @@ class PeerTubeEmbed {
loop: this.loop, loop: this.loop,
captions: videoCaptions.length !== 0, captions: videoCaptions.length !== 0,
startTime: this.startTime, startTime: this.startTime,
stopTime: this.stopTime,
subtitle: this.subtitle, subtitle: this.subtitle,
videoCaptions, videoCaptions,

View File

@ -38,6 +38,7 @@ import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../h
import { meRouter } from './me' import { meRouter } from './me'
import { deleteUserToken } from '../../../lib/oauth-model' import { deleteUserToken } from '../../../lib/oauth-model'
import { myBlocklistRouter } from './my-blocklist' import { myBlocklistRouter } from './my-blocklist'
import { myVideoPlaylistsRouter } from './my-video-playlists'
import { myVideosHistoryRouter } from './my-history' import { myVideosHistoryRouter } from './my-history'
import { myNotificationsRouter } from './my-notifications' import { myNotificationsRouter } from './my-notifications'
import { Notifier } from '../../../lib/notifier' import { Notifier } from '../../../lib/notifier'
@ -60,6 +61,7 @@ usersRouter.use('/', myNotificationsRouter)
usersRouter.use('/', mySubscriptionsRouter) usersRouter.use('/', mySubscriptionsRouter)
usersRouter.use('/', myBlocklistRouter) usersRouter.use('/', myBlocklistRouter)
usersRouter.use('/', myVideosHistoryRouter) usersRouter.use('/', myVideosHistoryRouter)
usersRouter.use('/', myVideoPlaylistsRouter)
usersRouter.use('/', meRouter) usersRouter.use('/', meRouter)
usersRouter.get('/autocomplete', usersRouter.get('/autocomplete',

View File

@ -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)
}

View File

@ -291,23 +291,26 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
videoId: video.id videoId: video.id
}, { transaction: t }) }, { transaction: t })
// If the user did not set a thumbnail, automatically take the video thumbnail videoPlaylist.updatedAt = new Date()
if (playlistElement.position === 1) { await videoPlaylist.save({ transaction: t })
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)
}
}
await sendUpdateVideoPlaylist(videoPlaylist, t) await sendUpdateVideoPlaylist(videoPlaylist, t)
return playlistElement 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) logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
return res.json({ return res.json({
@ -328,6 +331,9 @@ async function updateVideoPlaylistElement (req: express.Request, res: express.Re
const element = await videoPlaylistElement.save({ transaction: t }) const element = await videoPlaylistElement.save({ transaction: t })
videoPlaylist.updatedAt = new Date()
await videoPlaylist.save({ transaction: t })
await sendUpdateVideoPlaylist(videoPlaylist, t) await sendUpdateVideoPlaylist(videoPlaylist, t)
return element return element
@ -349,6 +355,9 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
// Decrease position of the next elements // Decrease position of the next elements
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t) await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t)
videoPlaylist.updatedAt = new Date()
await videoPlaylist.save({ transaction: t })
await sendUpdateVideoPlaylist(videoPlaylist, t) await sendUpdateVideoPlaylist(videoPlaylist, t)
logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) 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) // Decrease positions of elements after the old position of our ordered elements (decrease)
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t) await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t)
videoPlaylist.updatedAt = new Date()
await videoPlaylist.save({ transaction: t })
await sendUpdateVideoPlaylist(videoPlaylist, t) await sendUpdateVideoPlaylist(videoPlaylist, t)
}) })

View File

@ -49,12 +49,19 @@ function toValueOrNull (value: string) {
return value return value
} }
function toArray (value: string) { function toArray (value: any) {
if (value && isArray(value) === false) return [ value ] if (value && isArray(value) === false) return [ value ]
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 ( function isFileValid (
files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
mimeTypeRegex: string, mimeTypeRegex: string,
@ -97,5 +104,6 @@ export {
isBooleanValid, isBooleanValid,
toIntOrNull, toIntOrNull,
toArray, toArray,
toIntArray,
isFileValid isFileValid
} }

View File

@ -56,7 +56,7 @@ const SORTABLE_COLUMNS = {
USER_NOTIFICATIONS: [ 'createdAt' ], USER_NOTIFICATIONS: [ 'createdAt' ],
VIDEO_PLAYLISTS: [ 'createdAt' ] VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ]
} }
const OAUTH_LIFETIME = { const OAUTH_LIFETIME = {

View File

@ -4,9 +4,9 @@ import { UserRight } from '../../../../shared'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { UserModel } from '../../../models/account/user' import { UserModel } from '../../../models/account/user'
import { areValidationErrors } from '../utils' 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 { 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 { import {
isVideoPlaylistDescriptionValid, isVideoPlaylistDescriptionValid,
isVideoPlaylistExist, isVideoPlaylistExist,
@ -23,6 +23,7 @@ import { VideoModel } from '../../../models/video/video'
import { authenticatePromiseIfNeeded } from '../../oauth' import { authenticatePromiseIfNeeded } from '../../oauth'
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model'
import { areValidActorHandles } from '../../../helpers/custom-validators/activitypub/actor'
const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
async (req: express.Request, res: express.Response, next: express.NextFunction) => { 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 { export {
@ -319,7 +334,9 @@ export {
videoPlaylistElementAPGetValidator, videoPlaylistElementAPGetValidator,
commonVideoPlaylistFiltersValidator commonVideoPlaylistFiltersValidator,
doVideosInPlaylistExistValidator
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View 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) { static doesPlaylistExist (url: string) {
const query = { const query = {
attributes: [], attributes: [],

View File

@ -0,0 +1,7 @@
export type VideoExistInPlaylist = {
[videoId: number ]: {
playlistId: number
startTimestamp?: number
stopTimestamp?: number
}[]
}