Add to playlist dropdown
This commit is contained in:
parent
830b4faff1
commit
f0a3988066
|
@ -206,6 +206,9 @@
|
|||
|
||||
# Design
|
||||
|
||||
By [Olivier Massain](https://twitter.com/omassain)
|
||||
* [Olivier Massain](https://twitter.com/omassain)
|
||||
|
||||
Icons from [Robbie Pearce](https://robbiepearce.com/softies/)
|
||||
# Icons
|
||||
|
||||
* [Robbie Pearce](https://robbiepearce.com/softies/)
|
||||
* playlist add by Google
|
||||
|
|
|
@ -22,6 +22,9 @@ import {
|
|||
import {
|
||||
MyAccountVideoPlaylistUpdateComponent
|
||||
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
|
||||
import {
|
||||
MyAccountVideoPlaylistElementsComponent
|
||||
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
|
||||
|
||||
const myAccountRoutes: Routes = [
|
||||
{
|
||||
|
@ -81,6 +84,15 @@ const myAccountRoutes: Routes = [
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'video-playlists/:videoPlaylistId',
|
||||
component: MyAccountVideoPlaylistElementsComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: 'Playlist elements'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'video-playlists/create',
|
||||
component: MyAccountVideoPlaylistCreateComponent,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
.custom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-bottom: 1px solid $separator-border-color;
|
||||
|
||||
&:first-child {
|
||||
font-size: 16px;
|
||||
|
|
|
@ -60,5 +60,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
|
||||
</form>
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,2 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
|
@ -5,10 +5,10 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<div class="video-playlists">
|
||||
<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
|
||||
<div *ngFor="let playlist of videoPlaylists" class="video-playlist">
|
||||
<div class="miniature-wrapper">
|
||||
<my-video-playlist-miniature [playlist]="playlist"></my-video-playlist-miniature>
|
||||
<my-video-playlist-miniature [playlist]="playlist" [toManage]="true"></my-video-playlist-miniature>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
|
||||
|
|
|
@ -69,17 +69,20 @@ export class MyAccountVideoPlaylistsComponent implements OnInit {
|
|||
return playlist.type.id === VideoPlaylistType.REGULAR
|
||||
}
|
||||
|
||||
private loadVideoPlaylists () {
|
||||
this.authService.userInformationLoaded
|
||||
.pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account)))
|
||||
.subscribe(res => this.videoPlaylists = res.data)
|
||||
}
|
||||
|
||||
private ofNearOfBottom () {
|
||||
onNearOfBottom () {
|
||||
// Last page
|
||||
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
|
||||
|
||||
this.pagination.currentPage += 1
|
||||
this.loadVideoPlaylists()
|
||||
}
|
||||
|
||||
private loadVideoPlaylists () {
|
||||
this.authService.userInformationLoaded
|
||||
.pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt')))
|
||||
.subscribe(res => {
|
||||
this.videoPlaylists = this.videoPlaylists.concat(res.data)
|
||||
this.pagination.totalItems = res.total
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,9 @@ import {
|
|||
MyAccountVideoPlaylistUpdateComponent
|
||||
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
|
||||
import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
|
||||
import {
|
||||
MyAccountVideoPlaylistElementsComponent
|
||||
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -68,7 +71,8 @@ import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-vi
|
|||
|
||||
MyAccountVideoPlaylistCreateComponent,
|
||||
MyAccountVideoPlaylistUpdateComponent,
|
||||
MyAccountVideoPlaylistsComponent
|
||||
MyAccountVideoPlaylistsComponent,
|
||||
MyAccountVideoPlaylistElementsComponent
|
||||
],
|
||||
|
||||
exports: [
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<p-inputMask
|
||||
[disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
|
||||
mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
|
||||
></p-inputMask>
|
|
@ -0,0 +1,8 @@
|
|||
p-inputmask {
|
||||
/deep/ input {
|
||||
width: 80px;
|
||||
font-size: 15px;
|
||||
|
||||
border: none;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,7 +25,8 @@ const icons = {
|
|||
'like': require('../../../assets/images/video/like.html'),
|
||||
'more': require('../../../assets/images/video/more.html'),
|
||||
'share': require('../../../assets/images/video/share.html'),
|
||||
'upload': require('../../../assets/images/video/upload.html')
|
||||
'upload': require('../../../assets/images/video/upload.html'),
|
||||
'playlist-add': require('../../../assets/images/video/playlist-add.html')
|
||||
}
|
||||
|
||||
export type GlobalIconName = keyof typeof icons
|
||||
|
|
|
@ -9,6 +9,7 @@ import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.d
|
|||
|
||||
import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
|
||||
import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
|
||||
import { KeyFilterModule } from 'primeng/keyfilter'
|
||||
|
||||
import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
|
||||
import { ButtonComponent } from './buttons/button.component'
|
||||
|
@ -49,6 +50,7 @@ import {
|
|||
VideoValidatorsService
|
||||
} from '@app/shared/forms'
|
||||
import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
|
||||
import { InputMaskModule } from 'primeng/inputmask'
|
||||
import { ScreenService } from '@app/shared/misc/screen.service'
|
||||
import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
|
||||
import { VideoCaptionService } from '@app/shared/video-caption'
|
||||
|
@ -74,6 +76,8 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.
|
|||
import { ImageUploadComponent } from '@app/shared/images/image-upload.component'
|
||||
import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
|
||||
import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
|
||||
import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
|
||||
import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -90,6 +94,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
|
|||
NgbTooltipModule,
|
||||
|
||||
PrimeSharedModule,
|
||||
InputMaskModule,
|
||||
KeyFilterModule,
|
||||
NgPipesModule
|
||||
],
|
||||
|
||||
|
@ -100,11 +106,14 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
|
|||
VideoThumbnailComponent,
|
||||
VideoMiniatureComponent,
|
||||
VideoPlaylistMiniatureComponent,
|
||||
VideoAddToPlaylistComponent,
|
||||
|
||||
FeedComponent,
|
||||
|
||||
ButtonComponent,
|
||||
DeleteButtonComponent,
|
||||
EditButtonComponent,
|
||||
|
||||
ActionDropdownComponent,
|
||||
NumberFormatterPipe,
|
||||
ObjectLengthPipe,
|
||||
|
@ -113,8 +122,11 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
|
|||
InfiniteScrollerDirective,
|
||||
TextareaAutoResizeDirective,
|
||||
HelpComponent,
|
||||
|
||||
ReactiveFileComponent,
|
||||
PeertubeCheckboxComponent,
|
||||
TimestampInputComponent,
|
||||
|
||||
SubscribeButtonComponent,
|
||||
RemoteSubscribeComponent,
|
||||
InstanceFeaturesTableComponent,
|
||||
|
@ -142,6 +154,8 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
|
|||
NgbTooltipModule,
|
||||
|
||||
PrimeSharedModule,
|
||||
InputMaskModule,
|
||||
KeyFilterModule,
|
||||
BytesPipe,
|
||||
KeysPipe,
|
||||
|
||||
|
@ -151,18 +165,24 @@ import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/vide
|
|||
VideoThumbnailComponent,
|
||||
VideoMiniatureComponent,
|
||||
VideoPlaylistMiniatureComponent,
|
||||
VideoAddToPlaylistComponent,
|
||||
|
||||
FeedComponent,
|
||||
|
||||
ButtonComponent,
|
||||
DeleteButtonComponent,
|
||||
EditButtonComponent,
|
||||
|
||||
ActionDropdownComponent,
|
||||
MarkdownTextareaComponent,
|
||||
InfiniteScrollerDirective,
|
||||
TextareaAutoResizeDirective,
|
||||
HelpComponent,
|
||||
|
||||
ReactiveFileComponent,
|
||||
PeertubeCheckboxComponent,
|
||||
TimestampInputComponent,
|
||||
|
||||
SubscribeButtonComponent,
|
||||
RemoteSubscribeComponent,
|
||||
InstanceFeaturesTableComponent,
|
||||
|
|
|
@ -38,7 +38,7 @@ export class SubscribeButtonComponent implements OnInit {
|
|||
|
||||
ngOnInit () {
|
||||
if (this.isUserLoggedIn()) {
|
||||
this.userSubscriptionService.isSubscriptionExists(this.uri)
|
||||
this.userSubscriptionService.doesSubscriptionExist(this.uri)
|
||||
.subscribe(
|
||||
res => this.subscribed = res[this.uri],
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ export class UserSubscriptionService {
|
|||
this.existsObservable = this.existsSubject.pipe(
|
||||
bufferTime(500),
|
||||
filter(uris => uris.length !== 0),
|
||||
switchMap(uris => this.areSubscriptionExist(uris)),
|
||||
switchMap(uris => this.doSubscriptionsExist(uris)),
|
||||
share()
|
||||
)
|
||||
}
|
||||
|
@ -69,13 +69,13 @@ export class UserSubscriptionService {
|
|||
)
|
||||
}
|
||||
|
||||
isSubscriptionExists (nameWithHost: string) {
|
||||
doesSubscriptionExist (nameWithHost: string) {
|
||||
this.existsSubject.next(nameWithHost)
|
||||
|
||||
return this.existsObservable.pipe(first())
|
||||
}
|
||||
|
||||
private areSubscriptionExist (uris: string[]): Observable<SubscriptionExistResult> {
|
||||
private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> {
|
||||
const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
|
||||
let params = new HttpParams()
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
align-items: center;
|
||||
font-size: inherit;
|
||||
padding: 15px 5px 15px 10px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.10);
|
||||
border-bottom: 1px solid $separator-border-color;
|
||||
|
||||
&.unread {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<div class="miniature">
|
||||
<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
|
||||
<a
|
||||
[routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName"
|
||||
[routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName"
|
||||
class="miniature-thumbnail"
|
||||
>
|
||||
<img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
|
||||
|
@ -15,7 +15,7 @@
|
|||
</a>
|
||||
|
||||
<div class="miniature-bottom">
|
||||
<a tabindex="-1" class="miniature-name" [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName">
|
||||
<a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.displayName">
|
||||
{{ playlist.displayName }}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,17 @@
|
|||
.miniature {
|
||||
display: inline-block;
|
||||
|
||||
&.no-videos:not(.to-manage){
|
||||
a {
|
||||
cursor: default !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.to-manage .play-overlay,
|
||||
&.no-videos {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.miniature-thumbnail {
|
||||
@include miniature-thumbnail;
|
||||
|
||||
|
|
|
@ -8,4 +8,12 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
|
|||
})
|
||||
export class VideoPlaylistMiniatureComponent {
|
||||
@Input() playlist: VideoPlaylist
|
||||
@Input() toManage = false
|
||||
|
||||
getPlaylistUrl () {
|
||||
if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
|
||||
if (this.playlist.videosLength === 0) return null
|
||||
|
||||
return [ '/videos/watch/playlist', this.playlist.uuid ]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
|||
this.isLocal = hash.isLocal
|
||||
|
||||
this.displayName = hash.displayName
|
||||
|
||||
this.description = hash.description
|
||||
this.privacy = hash.privacy
|
||||
|
||||
|
@ -70,5 +71,9 @@ export class VideoPlaylist implements ServerVideoPlaylist {
|
|||
}
|
||||
|
||||
this.privacy.label = peertubeTranslate(this.privacy.label, translations)
|
||||
|
||||
if (this.type.id === VideoPlaylistType.WATCH_LATER) {
|
||||
this.displayName = peertubeTranslate(this.displayName, translations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { catchError, map, switchMap } from 'rxjs/operators'
|
||||
import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { Observable, ReplaySubject, Subject } from 'rxjs'
|
||||
import { RestExtractor } from '../rest/rest-extractor.service'
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { ResultList } from '../../../../../shared'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
|
||||
import { environment } from '../../../environments/environment'
|
||||
import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
|
||||
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
|
||||
|
@ -15,16 +15,31 @@ import { ServerService } from '@app/core'
|
|||
import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
|
||||
import { AccountService } from '@app/shared/account/account.service'
|
||||
import { Account } from '@app/shared/account/account.model'
|
||||
import { RestService } from '@app/shared/rest'
|
||||
import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
|
||||
|
||||
@Injectable()
|
||||
export class VideoPlaylistService {
|
||||
static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
|
||||
static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
|
||||
|
||||
// Use a replay subject because we "next" a value before subscribing
|
||||
private videoExistsInPlaylistSubject: Subject<number> = new ReplaySubject(1)
|
||||
private readonly videoExistsInPlaylistObservable: Observable<VideoExistInPlaylist>
|
||||
|
||||
constructor (
|
||||
private authHttp: HttpClient,
|
||||
private serverService: ServerService,
|
||||
private restExtractor: RestExtractor
|
||||
) { }
|
||||
private restExtractor: RestExtractor,
|
||||
private restService: RestService
|
||||
) {
|
||||
this.videoExistsInPlaylistObservable = this.videoExistsInPlaylistSubject.pipe(
|
||||
bufferTime(500),
|
||||
filter(videoIds => videoIds.length !== 0),
|
||||
switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
|
||||
share()
|
||||
)
|
||||
}
|
||||
|
||||
listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> {
|
||||
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
|
||||
|
@ -36,10 +51,13 @@ export class VideoPlaylistService {
|
|||
)
|
||||
}
|
||||
|
||||
listAccountPlaylists (account: Account): Observable<ResultList<VideoPlaylist>> {
|
||||
listAccountPlaylists (account: Account, sort: string): Observable<ResultList<VideoPlaylist>> {
|
||||
const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
|
||||
|
||||
return this.authHttp.get<ResultList<VideoPlaylist>>(url)
|
||||
let params = new HttpParams()
|
||||
params = this.restService.addRestGetParams(params, undefined, sort)
|
||||
|
||||
return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
|
||||
.pipe(
|
||||
switchMap(res => this.extractPlaylists(res)),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
|
@ -59,9 +77,8 @@ export class VideoPlaylistService {
|
|||
createVideoPlaylist (body: VideoPlaylistCreate) {
|
||||
const data = objectToFormData(body)
|
||||
|
||||
return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
|
||||
return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
|
||||
.pipe(
|
||||
map(this.restExtractor.extractDataBool),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
}
|
||||
|
@ -84,6 +101,36 @@ export class VideoPlaylistService {
|
|||
)
|
||||
}
|
||||
|
||||
addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
|
||||
return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos', body)
|
||||
.pipe(
|
||||
map(this.restExtractor.extractDataBool),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
}
|
||||
|
||||
updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) {
|
||||
return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body)
|
||||
.pipe(
|
||||
map(this.restExtractor.extractDataBool),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
}
|
||||
|
||||
removeVideoFromPlaylist (playlistId: number, videoId: number) {
|
||||
return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId)
|
||||
.pipe(
|
||||
map(this.restExtractor.extractDataBool),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
}
|
||||
|
||||
doesVideoExistInPlaylist (videoId: number) {
|
||||
this.videoExistsInPlaylistSubject.next(videoId)
|
||||
|
||||
return this.videoExistsInPlaylistObservable.pipe(first())
|
||||
}
|
||||
|
||||
extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
|
||||
return this.serverService.localeObservable
|
||||
.pipe(
|
||||
|
@ -105,4 +152,14 @@ export class VideoPlaylistService {
|
|||
return this.serverService.localeObservable
|
||||
.pipe(map(translations => new VideoPlaylist(playlist, translations)))
|
||||
}
|
||||
|
||||
private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> {
|
||||
const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
|
||||
let params = new HttpParams()
|
||||
|
||||
params = this.restService.addObjectParams(params, { videoIds })
|
||||
|
||||
return this.authHttp.get<VideoExistInPlaylist>(url, { params })
|
||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,8 @@ import { ServerService } from '@app/core'
|
|||
import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
|
||||
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
|
||||
import { I18n } from '@ngx-translate/i18n-polyfill'
|
||||
import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
|
||||
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
|
||||
|
||||
export interface VideosProvider {
|
||||
getVideos (
|
||||
|
@ -170,6 +172,23 @@ export class VideoService implements VideosProvider {
|
|||
)
|
||||
}
|
||||
|
||||
getPlaylistVideos (
|
||||
videoPlaylistId: number | string,
|
||||
videoPagination: ComponentPagination
|
||||
): Observable<{ videos: Video[], totalVideos: number }> {
|
||||
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
|
||||
|
||||
let params = new HttpParams()
|
||||
params = this.restService.addRestGetParams(params, pagination)
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params })
|
||||
.pipe(
|
||||
switchMap(res => this.extractVideos(res)),
|
||||
catchError(err => this.restExtractor.handleError(err))
|
||||
)
|
||||
}
|
||||
|
||||
getUserSubscriptionVideos (
|
||||
videoPagination: ComponentPagination,
|
||||
sort: VideoSortField
|
||||
|
|
|
@ -6,11 +6,19 @@
|
|||
|
||||
<div class="modal-body">
|
||||
|
||||
<div *ngIf="currentVideoTimestampString" class="start-at">
|
||||
<div class="start-at">
|
||||
<my-peertube-checkbox
|
||||
inputName="startAt" [(ngModel)]="startAtCheckbox"
|
||||
i18n-labelText [labelText]="getStartCheckboxLabel()"
|
||||
i18n-labelText labelText="Start at"
|
||||
></my-peertube-checkbox>
|
||||
|
||||
<my-timestamp-input
|
||||
[timestamp]="currentVideoTimestamp"
|
||||
[maxTimestamp]="video.duration"
|
||||
[disabled]="!startAtCheckbox"
|
||||
[(ngModel)]="currentVideoTimestamp"
|
||||
>
|
||||
</my-timestamp-input>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
|
@ -13,4 +13,9 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
|
||||
my-timestamp-input {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,10 +16,8 @@ export class VideoShareComponent {
|
|||
|
||||
@Input() video: VideoDetails = null
|
||||
|
||||
currentVideoTimestamp: number
|
||||
startAtCheckbox = false
|
||||
currentVideoTimestampString: string
|
||||
|
||||
private currentVideoTimestamp: number
|
||||
|
||||
constructor (
|
||||
private modalService: NgbModal,
|
||||
|
@ -28,8 +26,7 @@ export class VideoShareComponent {
|
|||
) { }
|
||||
|
||||
show (currentVideoTimestamp?: number) {
|
||||
this.currentVideoTimestamp = Math.floor(currentVideoTimestamp)
|
||||
this.currentVideoTimestampString = durationToString(this.currentVideoTimestamp)
|
||||
this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0
|
||||
|
||||
this.modalService.open(this.modal)
|
||||
}
|
||||
|
@ -52,10 +49,6 @@ export class VideoShareComponent {
|
|||
this.notifier.success(this.i18n('Copied'))
|
||||
}
|
||||
|
||||
getStartCheckboxLabel () {
|
||||
return this.i18n('Start at {{timestamp}}', { timestamp: this.currentVideoTimestampString })
|
||||
}
|
||||
|
||||
private getVideoTimestampIfEnabled () {
|
||||
if (this.startAtCheckbox === true) return this.currentVideoTimestamp
|
||||
|
||||
|
|
|
@ -7,7 +7,16 @@ import { VideoWatchComponent } from './video-watch.component'
|
|||
|
||||
const videoWatchRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
path: 'playlist/:uuid',
|
||||
component: VideoWatchComponent,
|
||||
canActivate: [ MetaGuard ]
|
||||
},
|
||||
{
|
||||
path: ':uuid/comments/:commentId',
|
||||
redirectTo: ':uuid'
|
||||
},
|
||||
{
|
||||
path: ':uuid',
|
||||
component: VideoWatchComponent,
|
||||
canActivate: [ MetaGuard ]
|
||||
}
|
||||
|
|
|
@ -65,17 +65,31 @@
|
|||
<my-global-icon iconName="dislike"></my-global-icon>
|
||||
</div>
|
||||
|
||||
<div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support">
|
||||
<div *ngIf="video.support" (click)="showSupportModal()" class="action-button">
|
||||
<my-global-icon iconName="heart"></my-global-icon>
|
||||
<span class="icon-text" i18n>Support</span>
|
||||
</div>
|
||||
|
||||
<div (click)="showShareModal()" class="action-button action-button-share" role="button">
|
||||
<div (click)="showShareModal()" class="action-button" role="button">
|
||||
<my-global-icon iconName="share"></my-global-icon>
|
||||
<span class="icon-text" i18n>Share</span>
|
||||
</div>
|
||||
|
||||
<div class="action-more" ngbDropdown placement="top" role="button">
|
||||
<div
|
||||
class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
|
||||
*ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)"
|
||||
>
|
||||
<div class="action-button action-button-save" ngbDropdownToggle role="button">
|
||||
<my-global-icon iconName="playlist-add"></my-global-icon>
|
||||
<span class="icon-text" i18n>Save</span>
|
||||
</div>
|
||||
|
||||
<div ngbDropdownMenu>
|
||||
<my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-dropdown" ngbDropdown placement="top" role="button">
|
||||
<div class="action-button" ngbDropdownToggle role="button">
|
||||
<my-global-icon class="more-icon" iconName="more"></my-global-icon>
|
||||
</div>
|
||||
|
|
|
@ -176,7 +176,7 @@ $other-videos-width: 260px;
|
|||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.action-button:not(:first-child), .action-more {
|
||||
.action-button:not(:first-child), .action-dropdown {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
|
@ -212,12 +212,19 @@ $other-videos-width: 260px;
|
|||
}
|
||||
}
|
||||
|
||||
&.action-button-save {
|
||||
my-global-icon {
|
||||
top: 0 !important;
|
||||
right: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-more {
|
||||
.action-dropdown {
|
||||
display: inline-block;
|
||||
|
||||
.dropdown-menu .dropdown-item {
|
||||
|
|
|
@ -59,6 +59,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
remoteServerDown = false
|
||||
hotkeys: Hotkey[]
|
||||
|
||||
private currentTime: number
|
||||
private paramsSub: Subscription
|
||||
|
||||
constructor (
|
||||
|
@ -114,10 +115,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
)
|
||||
.subscribe(([ video, captionsResult ]) => {
|
||||
const startTime = this.route.snapshot.queryParams.start
|
||||
const stopTime = this.route.snapshot.queryParams.stop
|
||||
const subtitle = this.route.snapshot.queryParams.subtitle
|
||||
const playerMode = this.route.snapshot.queryParams.mode
|
||||
|
||||
this.onVideoFetched(video, captionsResult.data, { startTime, subtitle, playerMode })
|
||||
this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
|
||||
.catch(err => this.handleError(err))
|
||||
})
|
||||
})
|
||||
|
@ -219,7 +221,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
showShareModal () {
|
||||
const currentTime = this.player ? this.player.currentTime() : undefined
|
||||
|
||||
this.videoShareModal.show(currentTime)
|
||||
this.videoShareModal.show(this.currentTime)
|
||||
}
|
||||
|
||||
showDownloadModal (event: Event) {
|
||||
|
@ -371,7 +373,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
private async onVideoFetched (
|
||||
video: VideoDetails,
|
||||
videoCaptions: VideoCaption[],
|
||||
urlOptions: { startTime?: number, subtitle?: string, playerMode?: string }
|
||||
urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string }
|
||||
) {
|
||||
this.video = video
|
||||
|
||||
|
@ -379,6 +381,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
this.descriptionLoading = false
|
||||
this.completeDescriptionShown = false
|
||||
this.remoteServerDown = false
|
||||
this.currentTime = undefined
|
||||
|
||||
let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
|
||||
// If we are at the end of the video, reset the timer
|
||||
|
@ -420,6 +423,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
inactivityTimeout: 2500,
|
||||
poster: this.video.previewUrl,
|
||||
startTime,
|
||||
stopTime: urlOptions.stopTime,
|
||||
|
||||
theaterMode: true,
|
||||
captions: videoCaptions.length !== 0,
|
||||
|
@ -466,6 +470,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
this.zone.runOutsideAngular(async () => {
|
||||
this.player = await PeertubePlayerManager.initialize(mode, options)
|
||||
this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
|
||||
|
||||
this.player.on('timeupdate', () => {
|
||||
this.currentTime = Math.floor(this.player.currentTime())
|
||||
})
|
||||
})
|
||||
|
||||
this.setVideoDescriptionHTML()
|
||||
|
|
|
@ -78,11 +78,7 @@ const videosRoutes: Routes = [
|
|||
}
|
||||
},
|
||||
{
|
||||
path: 'watch/:uuid/comments/:commentId',
|
||||
redirectTo: 'watch/:uuid'
|
||||
},
|
||||
{
|
||||
path: 'watch/:uuid',
|
||||
path: 'watch',
|
||||
loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule',
|
||||
data: {
|
||||
preload: 3000
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-92.000000, -115.000000)">
|
||||
<g id="2" transform="translate(92.000000, 115.000000)">
|
||||
<circle id="Oval-1" stroke="#ffffff" stroke-width="2" cx="12" cy="12" r="10"></circle>
|
||||
<rect id="Rectangle-1" fill="#ffffff" x="11" y="7" width="2" height="10" rx="1"></rect>
|
||||
<rect id="Rectangle-1" fill="#ffffff" x="7" y="11" width="10" height="2" rx="1"></rect>
|
||||
<circle id="Oval-1" stroke="#000000" stroke-width="2" cx="12" cy="12" r="10"></circle>
|
||||
<rect id="Rectangle-1" fill="#000000" x="11" y="7" width="2" height="10" rx="1"></rect>
|
||||
<rect id="Rectangle-1" fill="#000000" x="7" y="11" width="10" height="2" rx="1"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
|
Before Width: | Height: | Size: 700 B After Width: | Height: | Size: 700 B |
|
@ -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 |
|
@ -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 |
|
@ -49,6 +49,7 @@ export type CommonOptions = {
|
|||
inactivityTimeout: number
|
||||
poster: string
|
||||
startTime: number | string
|
||||
stopTime: number | string
|
||||
|
||||
theaterMode: boolean
|
||||
captions: boolean
|
||||
|
@ -199,10 +200,10 @@ export class PeertubePlayerManager {
|
|||
autoplay, // Use peertube plugin autoplay because we get the file by webtorrent
|
||||
videoViewUrl: commonOptions.videoViewUrl,
|
||||
videoDuration: commonOptions.videoDuration,
|
||||
startTime: commonOptions.startTime,
|
||||
userWatching: commonOptions.userWatching,
|
||||
subtitle: commonOptions.subtitle,
|
||||
videoCaptions: commonOptions.videoCaptions
|
||||
videoCaptions: commonOptions.videoCaptions,
|
||||
stopTime: commonOptions.stopTime
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -210,6 +211,7 @@ export class PeertubePlayerManager {
|
|||
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
|
||||
redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls,
|
||||
type: 'application/x-mpegURL',
|
||||
startTime: commonOptions.startTime,
|
||||
src: p2pMediaLoaderOptions.playlistUrl
|
||||
}
|
||||
|
||||
|
@ -254,7 +256,8 @@ export class PeertubePlayerManager {
|
|||
autoplay,
|
||||
videoDuration: commonOptions.videoDuration,
|
||||
playerElement: commonOptions.playerElement,
|
||||
videoFiles: webtorrentOptions.videoFiles
|
||||
videoFiles: webtorrentOptions.videoFiles,
|
||||
startTime: commonOptions.startTime
|
||||
}
|
||||
Object.assign(plugins, { webtorrent })
|
||||
|
||||
|
|
|
@ -22,7 +22,6 @@ import {
|
|||
|
||||
const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
|
||||
class PeerTubePlugin extends Plugin {
|
||||
private readonly startTime: number = 0
|
||||
private readonly videoViewUrl: string
|
||||
private readonly videoDuration: number
|
||||
private readonly CONSTANTS = {
|
||||
|
@ -35,13 +34,11 @@ class PeerTubePlugin extends Plugin {
|
|||
|
||||
private videoViewInterval: any
|
||||
private userWatchingVideoInterval: any
|
||||
private qualityObservationTimer: any
|
||||
private lastResolutionChange: ResolutionUpdateData
|
||||
|
||||
constructor (player: videojs.Player, options: PeerTubePluginOptions) {
|
||||
super(player, options)
|
||||
|
||||
this.startTime = timeToInt(options.startTime)
|
||||
this.videoViewUrl = options.videoViewUrl
|
||||
this.videoDuration = options.videoDuration
|
||||
this.videoCaptions = options.videoCaptions
|
||||
|
@ -84,6 +81,14 @@ class PeerTubePlugin extends Plugin {
|
|||
saveMuteInStore(this.player.muted())
|
||||
})
|
||||
|
||||
if (options.stopTime) {
|
||||
const stopTime = timeToInt(options.stopTime)
|
||||
|
||||
this.player.on('timeupdate', () => {
|
||||
if (this.player.currentTime() > stopTime) this.player.pause()
|
||||
})
|
||||
}
|
||||
|
||||
this.player.textTracks().on('change', () => {
|
||||
const showing = this.player.textTracks().tracks_.find((t: { kind: string, mode: string }) => {
|
||||
return t.kind === 'captions' && t.mode === 'showing'
|
||||
|
@ -109,10 +114,7 @@ class PeerTubePlugin extends Plugin {
|
|||
}
|
||||
|
||||
dispose () {
|
||||
clearTimeout(this.qualityObservationTimer)
|
||||
|
||||
clearInterval(this.videoViewInterval)
|
||||
|
||||
if (this.videoViewInterval) clearInterval(this.videoViewInterval)
|
||||
if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
|
||||
}
|
||||
|
||||
|
|
|
@ -41,12 +41,13 @@ type PeerTubePluginOptions = {
|
|||
autoplay: boolean
|
||||
videoViewUrl: string
|
||||
videoDuration: number
|
||||
startTime: number | string
|
||||
|
||||
userWatching?: UserWatching
|
||||
subtitle?: string
|
||||
|
||||
videoCaptions: VideoJSCaption[]
|
||||
|
||||
stopTime: number | string
|
||||
}
|
||||
|
||||
type WebtorrentPluginOptions = {
|
||||
|
@ -56,12 +57,16 @@ type WebtorrentPluginOptions = {
|
|||
videoDuration: number
|
||||
|
||||
videoFiles: VideoFile[]
|
||||
|
||||
startTime: number | string
|
||||
}
|
||||
|
||||
type P2PMediaLoaderPluginOptions = {
|
||||
redundancyBaseUrls: string[]
|
||||
type: string
|
||||
src: string
|
||||
|
||||
startTime: number | string
|
||||
}
|
||||
|
||||
type VideoJSPluginOptions = {
|
||||
|
|
|
@ -42,7 +42,7 @@ function timeToInt (time: number | string) {
|
|||
if (!time) return 0
|
||||
if (typeof time === 'number') return time
|
||||
|
||||
const reg = /^((\d+)h)?((\d+)m)?((\d+)s?)?$/
|
||||
const reg = /^((\d+)[h:])?((\d+)[m:])?((\d+)s?)?$/
|
||||
const matches = time.match(reg)
|
||||
|
||||
if (!matches) return 0
|
||||
|
@ -54,18 +54,27 @@ function timeToInt (time: number | string) {
|
|||
return hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
function secondsToTime (seconds: number) {
|
||||
function secondsToTime (seconds: number, full = false, symbol?: string) {
|
||||
let time = ''
|
||||
|
||||
const hourSymbol = (symbol || 'h')
|
||||
const minuteSymbol = (symbol || 'm')
|
||||
const secondsSymbol = full ? '' : 's'
|
||||
|
||||
let hours = Math.floor(seconds / 3600)
|
||||
if (hours >= 1) time = hours + 'h'
|
||||
if (hours >= 1) time = hours + hourSymbol
|
||||
else if (full) time = '0' + hourSymbol
|
||||
|
||||
seconds %= 3600
|
||||
let minutes = Math.floor(seconds / 60)
|
||||
if (minutes >= 1) time += minutes + 'm'
|
||||
if (minutes >= 1 && minutes < 10 && full) time += '0' + minutes + minuteSymbol
|
||||
else if (minutes >= 1) time += minutes + minuteSymbol
|
||||
else if (full) time += '00' + minuteSymbol
|
||||
|
||||
seconds %= 60
|
||||
if (seconds >= 1) time += seconds + 's'
|
||||
if (seconds >= 1 && seconds < 10 && full) time += '0' + seconds + secondsSymbol
|
||||
else if (seconds >= 1) time += seconds + secondsSymbol
|
||||
else if (full) time += '00'
|
||||
|
||||
return time
|
||||
}
|
||||
|
@ -131,6 +140,7 @@ export {
|
|||
getRtcConfig,
|
||||
toTitleCase,
|
||||
timeToInt,
|
||||
secondsToTime,
|
||||
buildVideoLink,
|
||||
buildVideoEmbed,
|
||||
videoFileMaxByResolution,
|
||||
|
|
|
@ -6,7 +6,7 @@ import * as WebTorrent from 'webtorrent'
|
|||
import { VideoFile } from '../../../../../shared/models/videos/video.model'
|
||||
import { renderVideo } from './video-renderer'
|
||||
import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from '../peertube-videojs-typings'
|
||||
import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../utils'
|
||||
import { getRtcConfig, timeToInt, videoFileMaxByResolution, videoFileMinByResolution } from '../utils'
|
||||
import { PeertubeChunkStore } from './peertube-chunk-store'
|
||||
import {
|
||||
getAverageBandwidthInStore,
|
||||
|
@ -73,6 +73,8 @@ class WebTorrentPlugin extends Plugin {
|
|||
constructor (player: videojs.Player, options: WebtorrentPluginOptions) {
|
||||
super(player, options)
|
||||
|
||||
this.startTime = timeToInt(options.startTime)
|
||||
|
||||
// Disable auto play on iOS
|
||||
this.autoplay = options.autoplay && this.isIOS() === false
|
||||
this.playerRefusedP2P = !getStoredWebTorrentEnabled()
|
||||
|
|
|
@ -515,4 +515,3 @@
|
|||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -44,6 +44,8 @@ $footer-margin: 30px;
|
|||
|
||||
$footer-border-color: $header-border-color;
|
||||
|
||||
$separator-border-color: rgba(0, 0, 0, 0.10);
|
||||
|
||||
$video-thumbnail-height: 122px;
|
||||
$video-thumbnail-width: 223px;
|
||||
|
||||
|
|
|
@ -168,6 +168,7 @@ class PeerTubeEmbed {
|
|||
subtitle: string
|
||||
enableApi = false
|
||||
startTime: number | string = 0
|
||||
stopTime: number | string
|
||||
mode: PlayerMode
|
||||
scope = 'peertube'
|
||||
|
||||
|
@ -262,6 +263,7 @@ class PeerTubeEmbed {
|
|||
this.scope = this.getParamString(params, 'scope', this.scope)
|
||||
this.subtitle = this.getParamString(params, 'subtitle')
|
||||
this.startTime = this.getParamString(params, 'start')
|
||||
this.stopTime = this.getParamString(params, 'stop')
|
||||
|
||||
this.mode = this.getParamString(params, 'mode') === 'p2p-media-loader' ? 'p2p-media-loader' : 'webtorrent'
|
||||
} catch (err) {
|
||||
|
@ -306,6 +308,7 @@ class PeerTubeEmbed {
|
|||
loop: this.loop,
|
||||
captions: videoCaptions.length !== 0,
|
||||
startTime: this.startTime,
|
||||
stopTime: this.stopTime,
|
||||
subtitle: this.subtitle,
|
||||
|
||||
videoCaptions,
|
||||
|
|
|
@ -38,6 +38,7 @@ import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../h
|
|||
import { meRouter } from './me'
|
||||
import { deleteUserToken } from '../../../lib/oauth-model'
|
||||
import { myBlocklistRouter } from './my-blocklist'
|
||||
import { myVideoPlaylistsRouter } from './my-video-playlists'
|
||||
import { myVideosHistoryRouter } from './my-history'
|
||||
import { myNotificationsRouter } from './my-notifications'
|
||||
import { Notifier } from '../../../lib/notifier'
|
||||
|
@ -60,6 +61,7 @@ usersRouter.use('/', myNotificationsRouter)
|
|||
usersRouter.use('/', mySubscriptionsRouter)
|
||||
usersRouter.use('/', myBlocklistRouter)
|
||||
usersRouter.use('/', myVideosHistoryRouter)
|
||||
usersRouter.use('/', myVideoPlaylistsRouter)
|
||||
usersRouter.use('/', meRouter)
|
||||
|
||||
usersRouter.get('/autocomplete',
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -291,23 +291,26 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
|
|||
videoId: video.id
|
||||
}, { transaction: t })
|
||||
|
||||
// If the user did not set a thumbnail, automatically take the video thumbnail
|
||||
if (playlistElement.position === 1) {
|
||||
const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
|
||||
|
||||
if (await pathExists(playlistThumbnailPath) === false) {
|
||||
logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
|
||||
|
||||
const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
|
||||
await copy(videoThumbnailPath, playlistThumbnailPath)
|
||||
}
|
||||
}
|
||||
videoPlaylist.updatedAt = new Date()
|
||||
await videoPlaylist.save({ transaction: t })
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
|
||||
return playlistElement
|
||||
})
|
||||
|
||||
// If the user did not set a thumbnail, automatically take the video thumbnail
|
||||
if (playlistElement.position === 1) {
|
||||
const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
|
||||
|
||||
if (await pathExists(playlistThumbnailPath) === false) {
|
||||
logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
|
||||
|
||||
const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
|
||||
await copy(videoThumbnailPath, playlistThumbnailPath)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
|
||||
|
||||
return res.json({
|
||||
|
@ -328,6 +331,9 @@ async function updateVideoPlaylistElement (req: express.Request, res: express.Re
|
|||
|
||||
const element = await videoPlaylistElement.save({ transaction: t })
|
||||
|
||||
videoPlaylist.updatedAt = new Date()
|
||||
await videoPlaylist.save({ transaction: t })
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
|
||||
return element
|
||||
|
@ -349,6 +355,9 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
|
|||
// Decrease position of the next elements
|
||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, positionToDelete, null, -1, t)
|
||||
|
||||
videoPlaylist.updatedAt = new Date()
|
||||
await videoPlaylist.save({ transaction: t })
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
|
||||
logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
|
||||
|
@ -390,6 +399,9 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons
|
|||
// Decrease positions of elements after the old position of our ordered elements (decrease)
|
||||
await VideoPlaylistElementModel.increasePositionOf(videoPlaylist.id, oldPosition, null, -reorderLength, t)
|
||||
|
||||
videoPlaylist.updatedAt = new Date()
|
||||
await videoPlaylist.save({ transaction: t })
|
||||
|
||||
await sendUpdateVideoPlaylist(videoPlaylist, t)
|
||||
})
|
||||
|
||||
|
|
|
@ -49,12 +49,19 @@ function toValueOrNull (value: string) {
|
|||
return value
|
||||
}
|
||||
|
||||
function toArray (value: string) {
|
||||
function toArray (value: any) {
|
||||
if (value && isArray(value) === false) return [ value ]
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function toIntArray (value: any) {
|
||||
if (!value) return []
|
||||
if (isArray(value) === false) return [ validator.toInt(value) ]
|
||||
|
||||
return value.map(v => validator.toInt(v))
|
||||
}
|
||||
|
||||
function isFileValid (
|
||||
files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
|
||||
mimeTypeRegex: string,
|
||||
|
@ -97,5 +104,6 @@ export {
|
|||
isBooleanValid,
|
||||
toIntOrNull,
|
||||
toArray,
|
||||
toIntArray,
|
||||
isFileValid
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ const SORTABLE_COLUMNS = {
|
|||
|
||||
USER_NOTIFICATIONS: [ 'createdAt' ],
|
||||
|
||||
VIDEO_PLAYLISTS: [ 'createdAt' ]
|
||||
VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ]
|
||||
}
|
||||
|
||||
const OAUTH_LIFETIME = {
|
||||
|
|
|
@ -4,9 +4,9 @@ import { UserRight } from '../../../../shared'
|
|||
import { logger } from '../../../helpers/logger'
|
||||
import { UserModel } from '../../../models/account/user'
|
||||
import { areValidationErrors } from '../utils'
|
||||
import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos'
|
||||
import { isVideoExist, isVideoFileInfoHashValid, isVideoImage } from '../../../helpers/custom-validators/videos'
|
||||
import { CONSTRAINTS_FIELDS } from '../../../initializers'
|
||||
import { isIdOrUUIDValid, isUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc'
|
||||
import { isArrayOf, isIdOrUUIDValid, isIdValid, isUUIDValid, toArray, toValueOrNull, toIntArray } from '../../../helpers/custom-validators/misc'
|
||||
import {
|
||||
isVideoPlaylistDescriptionValid,
|
||||
isVideoPlaylistExist,
|
||||
|
@ -23,6 +23,7 @@ import { VideoModel } from '../../../models/video/video'
|
|||
import { authenticatePromiseIfNeeded } from '../../oauth'
|
||||
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
|
||||
import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model'
|
||||
import { areValidActorHandles } from '../../../helpers/custom-validators/activitypub/actor'
|
||||
|
||||
const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
@ -305,6 +306,20 @@ const commonVideoPlaylistFiltersValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const doVideosInPlaylistExistValidator = [
|
||||
query('videoIds')
|
||||
.customSanitizer(toIntArray)
|
||||
.custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
|
||||
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking areVideosInPlaylistExistValidator parameters', { parameters: req.query })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
|
@ -319,7 +334,9 @@ export {
|
|||
|
||||
videoPlaylistElementAPGetValidator,
|
||||
|
||||
commonVideoPlaylistFiltersValidator
|
||||
commonVideoPlaylistFiltersValidator,
|
||||
|
||||
doVideosInPlaylistExistValidator
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -317,6 +317,29 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
|
|||
})
|
||||
}
|
||||
|
||||
static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
|
||||
const query = {
|
||||
attributes: [ 'id' ],
|
||||
where: {
|
||||
ownerAccountId: accountId
|
||||
},
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ],
|
||||
model: VideoPlaylistElementModel.unscoped(),
|
||||
where: {
|
||||
videoId: {
|
||||
[Sequelize.Op.any]: videoIds
|
||||
}
|
||||
},
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return VideoPlaylistModel.findAll(query)
|
||||
}
|
||||
|
||||
static doesPlaylistExist (url: string) {
|
||||
const query = {
|
||||
attributes: [],
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export type VideoExistInPlaylist = {
|
||||
[videoId: number ]: {
|
||||
playlistId: number
|
||||
startTimestamp?: number
|
||||
stopTimestamp?: number
|
||||
}[]
|
||||
}
|
Loading…
Reference in New Issue