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
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 {
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,

View file

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

View file

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

View file

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

View file

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

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'),
'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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -8,4 +8,12 @@ import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
})
export class VideoPlaylistMiniatureComponent {
@Input() playlist: VideoPlaylist
@Input() toManage = false
getPlaylistUrl () {
if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
if (this.playlist.videosLength === 0) return null
return [ '/videos/watch/playlist', this.playlist.uuid ]
}
}

View file

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

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

View file

@ -31,6 +31,8 @@ import { ServerService } from '@app/core'
import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
export interface VideosProvider {
getVideos (
@ -170,6 +172,23 @@ export class VideoService implements VideosProvider {
)
}
getPlaylistVideos (
videoPlaylistId: number | string,
videoPagination: ComponentPagination
): Observable<{ videos: Video[], totalVideos: number }> {
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination)
return this.authHttp
.get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params })
.pipe(
switchMap(res => this.extractVideos(res)),
catchError(err => this.restExtractor.handleError(err))
)
}
getUserSubscriptionVideos (
videoPagination: ComponentPagination,
sort: VideoSortField

View file

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

View file

@ -13,4 +13,9 @@
display: flex;
justify-content: center;
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
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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
}, { 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)
})

View file

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

View file

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

View file

@ -4,9 +4,9 @@ import { UserRight } from '../../../../shared'
import { logger } from '../../../helpers/logger'
import { UserModel } from '../../../models/account/user'
import { areValidationErrors } from '../utils'
import { isVideoExist, isVideoImage } from '../../../helpers/custom-validators/videos'
import { isVideoExist, isVideoFileInfoHashValid, isVideoImage } from '../../../helpers/custom-validators/videos'
import { CONSTRAINTS_FIELDS } from '../../../initializers'
import { isIdOrUUIDValid, isUUIDValid, toValueOrNull } from '../../../helpers/custom-validators/misc'
import { isArrayOf, isIdOrUUIDValid, isIdValid, isUUIDValid, toArray, toValueOrNull, toIntArray } from '../../../helpers/custom-validators/misc'
import {
isVideoPlaylistDescriptionValid,
isVideoPlaylistExist,
@ -23,6 +23,7 @@ import { VideoModel } from '../../../models/video/video'
import { authenticatePromiseIfNeeded } from '../../oauth'
import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model'
import { areValidActorHandles } from '../../../helpers/custom-validators/activitypub/actor'
const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@ -305,6 +306,20 @@ const commonVideoPlaylistFiltersValidator = [
}
]
const doVideosInPlaylistExistValidator = [
query('videoIds')
.customSanitizer(toIntArray)
.custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking areVideosInPlaylistExistValidator parameters', { parameters: req.query })
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
@ -319,7 +334,9 @@ export {
videoPlaylistElementAPGetValidator,
commonVideoPlaylistFiltersValidator
commonVideoPlaylistFiltersValidator,
doVideosInPlaylistExistValidator
}
// ---------------------------------------------------------------------------

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) {
const query = {
attributes: [],

View file

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