# 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
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 = [
{
}
}
},
+ {
+ path: 'video-playlists/:videoPlaylistId',
+ component: MyAccountVideoPlaylistElementsComponent,
+ data: {
+ meta: {
+ title: 'Playlist elements'
+ }
+ }
+ },
{
path: 'video-playlists/create',
component: MyAccountVideoPlaylistCreateComponent,
.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;
</div>
</div>
</div>
+
<input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
</form>
--- /dev/null
+<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>
--- /dev/null
+@import '_variables';
+@import '_mixins';
--- /dev/null
+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
+ })
+ }
+}
</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">
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
+ })
+ }
}
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: [
MyAccountVideoPlaylistCreateComponent,
MyAccountVideoPlaylistUpdateComponent,
- MyAccountVideoPlaylistsComponent
+ MyAccountVideoPlaylistsComponent,
+ MyAccountVideoPlaylistElementsComponent
],
exports: [
--- /dev/null
+<p-inputMask
+ [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
+ mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
+></p-inputMask>
--- /dev/null
+p-inputmask {
+ /deep/ input {
+ width: 80px;
+ font-size: 15px;
+
+ border: none;
+ }
+}
--- /dev/null
+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)
+ }
+ }
+}
'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
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'
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'
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: [
NgbTooltipModule,
PrimeSharedModule,
+ InputMaskModule,
+ KeyFilterModule,
NgPipesModule
],
VideoThumbnailComponent,
VideoMiniatureComponent,
VideoPlaylistMiniatureComponent,
+ VideoAddToPlaylistComponent,
FeedComponent,
+
ButtonComponent,
DeleteButtonComponent,
EditButtonComponent,
+
ActionDropdownComponent,
NumberFormatterPipe,
ObjectLengthPipe,
InfiniteScrollerDirective,
TextareaAutoResizeDirective,
HelpComponent,
+
ReactiveFileComponent,
PeertubeCheckboxComponent,
+ TimestampInputComponent,
+
SubscribeButtonComponent,
RemoteSubscribeComponent,
InstanceFeaturesTableComponent,
NgbTooltipModule,
PrimeSharedModule,
+ InputMaskModule,
+ KeyFilterModule,
BytesPipe,
KeysPipe,
VideoThumbnailComponent,
VideoMiniatureComponent,
VideoPlaylistMiniatureComponent,
+ VideoAddToPlaylistComponent,
FeedComponent,
+
ButtonComponent,
DeleteButtonComponent,
EditButtonComponent,
+
ActionDropdownComponent,
MarkdownTextareaComponent,
InfiniteScrollerDirective,
TextareaAutoResizeDirective,
HelpComponent,
+
ReactiveFileComponent,
PeertubeCheckboxComponent,
+ TimestampInputComponent,
+
SubscribeButtonComponent,
RemoteSubscribeComponent,
InstanceFeaturesTableComponent,
ngOnInit () {
if (this.isUserLoggedIn()) {
- this.userSubscriptionService.isSubscriptionExists(this.uri)
+ this.userSubscriptionService.doesSubscriptionExist(this.uri)
.subscribe(
res => this.subscribed = res[this.uri],
this.existsObservable = this.existsSubject.pipe(
bufferTime(500),
filter(uris => uris.length !== 0),
- switchMap(uris => this.areSubscriptionExist(uris)),
+ switchMap(uris => this.doSubscriptionsExist(uris)),
share()
)
}
)
}
- 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()
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);
--- /dev/null
+<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>
--- /dev/null
+@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;
+}
--- /dev/null
+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
+ }
+ )
+ }
+}
-<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" />
</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>
.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;
})
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 ]
+ }
}
this.isLocal = hash.isLocal
this.displayName = hash.displayName
+
this.description = hash.description
this.privacy = hash.privacy
}
this.privacy.label = peertubeTranslate(this.privacy.label, translations)
+
+ if (this.type.id === VideoPlaylistType.WATCH_LATER) {
+ this.displayName = peertubeTranslate(this.displayName, translations)
+ }
}
}
-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'
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'
)
}
- 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))
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))
)
}
)
}
+ 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(
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)))
+ }
}
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 (
)
}
+ 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
<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">
display: flex;
justify-content: center;
margin-top: 10px;
+ align-items: center;
+
+ my-timestamp-input {
+ margin-left: 10px;
+ }
}
@Input() video: VideoDetails = null
+ currentVideoTimestamp: number
startAtCheckbox = false
- currentVideoTimestampString: string
-
- private currentVideoTimestamp: number
constructor (
private modalService: NgbModal,
) { }
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)
}
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
const videoWatchRoutes: Routes = [
{
- path: '',
+ path: 'playlist/:uuid',
+ component: VideoWatchComponent,
+ canActivate: [ MetaGuard ]
+ },
+ {
+ path: ':uuid/comments/:commentId',
+ redirectTo: ':uuid'
+ },
+ {
+ path: ':uuid',
component: VideoWatchComponent,
canActivate: [ MetaGuard ]
}
<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>
display: flex;
align-items: center;
- .action-button:not(:first-child), .action-more {
+ .action-button:not(:first-child), .action-dropdown {
margin-left: 10px;
}
}
}
+ &.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 {
remoteServerDown = false
hotkeys: Hotkey[]
+ private currentTime: number
private paramsSub: Subscription
constructor (
)
.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))
})
})
showShareModal () {
const currentTime = this.player ? this.player.currentTime() : undefined
- this.videoShareModal.show(currentTime)
+ this.videoShareModal.show(this.currentTime)
}
showDownloadModal (event: Event) {
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
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
inactivityTimeout: 2500,
poster: this.video.previewUrl,
startTime,
+ stopTime: urlOptions.stopTime,
theaterMode: true,
captions: videoCaptions.length !== 0,
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()
}
},
{
- 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
<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
inactivityTimeout: number
poster: string
startTime: number | string
+ stopTime: number | string
theaterMode: boolean
captions: boolean
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
}
}
const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
redundancyBaseUrls: options.p2pMediaLoader.redundancyBaseUrls,
type: 'application/x-mpegURL',
+ startTime: commonOptions.startTime,
src: p2pMediaLoaderOptions.playlistUrl
}
autoplay,
videoDuration: commonOptions.videoDuration,
playerElement: commonOptions.playerElement,
- videoFiles: webtorrentOptions.videoFiles
+ videoFiles: webtorrentOptions.videoFiles,
+ startTime: commonOptions.startTime
}
Object.assign(plugins, { webtorrent })
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 = {
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
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'
}
dispose () {
- clearTimeout(this.qualityObservationTimer)
-
- clearInterval(this.videoViewInterval)
-
+ if (this.videoViewInterval) clearInterval(this.videoViewInterval)
if (this.userWatchingVideoInterval) clearInterval(this.userWatchingVideoInterval)
}
autoplay: boolean
videoViewUrl: string
videoDuration: number
- startTime: number | string
userWatching?: UserWatching
subtitle?: string
videoCaptions: VideoJSCaption[]
+
+ stopTime: number | string
}
type WebtorrentPluginOptions = {
videoDuration: number
videoFiles: VideoFile[]
+
+ startTime: number | string
}
type P2PMediaLoaderPluginOptions = {
redundancyBaseUrls: string[]
type: string
src: string
+
+ startTime: number | string
}
type VideoJSPluginOptions = {
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
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
}
getRtcConfig,
toTitleCase,
timeToInt,
+ secondsToTime,
buildVideoLink,
buildVideoEmbed,
videoFileMaxByResolution,
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,
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()
align-items: center;
}
}
-
$footer-border-color: $header-border-color;
+$separator-border-color: rgba(0, 0, 0, 0.10);
+
$video-thumbnail-height: 122px;
$video-thumbnail-width: 223px;
subtitle: string
enableApi = false
startTime: number | string = 0
+ stopTime: number | string
mode: PlayerMode
scope = 'peertube'
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) {
loop: this.loop,
captions: videoCaptions.length !== 0,
startTime: this.startTime,
+ stopTime: this.stopTime,
subtitle: this.subtitle,
videoCaptions,
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'
usersRouter.use('/', mySubscriptionsRouter)
usersRouter.use('/', myBlocklistRouter)
usersRouter.use('/', myVideosHistoryRouter)
+usersRouter.use('/', myVideoPlaylistsRouter)
usersRouter.use('/', meRouter)
usersRouter.get('/autocomplete',
--- /dev/null
+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)
+}
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({
const element = await videoPlaylistElement.save({ transaction: t })
+ videoPlaylist.updatedAt = new Date()
+ await videoPlaylist.save({ transaction: t })
+
await sendUpdateVideoPlaylist(videoPlaylist, t)
return element
// 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)
// 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)
})
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,
isBooleanValid,
toIntOrNull,
toArray,
+ toIntArray,
isFileValid
}
USER_NOTIFICATIONS: [ 'createdAt' ],
- VIDEO_PLAYLISTS: [ 'createdAt' ]
+ VIDEO_PLAYLISTS: [ 'displayName', 'createdAt', 'updatedAt' ]
}
const OAUTH_LIFETIME = {
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,
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) => {
}
]
+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 {
videoPlaylistElementAPGetValidator,
- commonVideoPlaylistFiltersValidator
+ commonVideoPlaylistFiltersValidator,
+
+ doVideosInPlaylistExistValidator
}
// ---------------------------------------------------------------------------
})
}
+ 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: [],
--- /dev/null
+export type VideoExistInPlaylist = {
+ [videoId: number ]: {
+ playlistId: number
+ startTimestamp?: number
+ stopTimestamp?: number
+ }[]
+}