Playlist support in watch page

This commit is contained in:
Chocobozzz 2019-03-13 14:18:58 +01:00 committed by Chocobozzz
parent 15e9d5ca39
commit e2f01c47e0
20 changed files with 668 additions and 294 deletions

View File

@ -5,60 +5,7 @@
cdkDropList (cdkDropListDropped)="drop($event)" cdkDropList (cdkDropListDropped)="drop($event)"
> >
<div class="video" *ngFor="let video of videos" cdkDrag (cdkDragMoved)="onDragMove($event)"> <div class="video" *ngFor="let video of videos" cdkDrag (cdkDragMoved)="onDragMove($event)">
<div class="position">{{ video.playlistElement.position }}</div> <my-video-playlist-element-miniature [video]="video" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)">
</my-video-playlist-element-miniature>
<my-video-thumbnail [video]="video" [nsfw]="isVideoBlur(video)"></my-video-thumbnail>
<div class="video-info">
<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>
<span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video)}}</span>
</div>
<div class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()" autoClose="outside">
<my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more"></my-global-icon>
<div ngbDropdownMenu>
<div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)">
<my-global-icon iconName="edit"></my-global-icon> <ng-container i18n>Edit starts/stops at</ng-container>
</div>
<div class="timestamp-options" *ngIf="displayTimestampOptions">
<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>
<input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)">
</div>
<span class="dropdown-item" (click)="removeFromPlaylist(video)">
<my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{playlist?.displayName}}</ng-container>
</span>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -2,100 +2,6 @@
@import '_mixins'; @import '_mixins';
@import '_miniature'; @import '_miniature';
.video, .cdk-drag-preview {
display: flex;
align-items: center;
background-color: var(--mainBackgroundColor);
cursor: pointer;
padding: 10px;
border-bottom: 1px solid $separator-border-color;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
.more {
display: block;
}
}
.position {
font-weight: $font-semibold;
margin-right: 10px;
color: $grey-foreground-color;
min-width: 20px;
}
my-video-thumbnail {
display: flex; // Avoids an issue with line-height that adds space below the element
margin-right: 10px;
/deep/ .video-thumbnail {
@include miniature-thumbnail(130px, 72px);
}
}
.video-info {
display: flex;
flex-direction: column;
a {
@include disable-default-a-behaviour;
color: var(--mainForegroundColor);
}
.video-info-name {
font-size: 18px;
font-weight: $font-semibold;
}
.video-info-account, .video-info-timestamp {
color: $grey-foreground-color;
}
}
.more {
justify-self: flex-end;
margin-left: auto;
cursor: pointer;
display: none;
&.show {
display: block;
}
.icon-more {
@include apply-svg-color($grey-foreground-color);
&::after {
border: none;
}
}
.dropdown-item {
@include dropdown-with-icon-item;
}
.timestamp-options {
padding-top: 0;
padding-left: 35px;
margin-bottom: 15px;
> div {
display: flex;
align-items: center;
}
input {
@include peertube-button;
@include orange-button;
margin-top: 10px;
}
}
}
}
// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples // Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples
.cdk-drag-preview { .cdk-drag-preview {
box-sizing: border-box; box-sizing: border-box;

View File

@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { AuthService } from '../../core/auth' import { AuthService } from '../../core/auth'
import { ConfirmService } from '../../core/confirm' import { ConfirmService } from '../../core/confirm'
@ -10,9 +10,6 @@ import { VideoService } from '@app/shared/video/video.service'
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { secondsToTime } from '../../../assets/player/utils'
import { VideoPlaylistElementUpdate } from '@shared/models'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop' import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop'
import { throttleTime } from 'rxjs/operators' import { throttleTime } from 'rxjs/operators'
@ -22,8 +19,6 @@ import { throttleTime } from 'rxjs/operators'
styleUrls: [ './my-account-video-playlist-elements.component.scss' ] styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
}) })
export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy { export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
@ViewChild('moreDropdown') moreDropdown: NgbDropdown
videos: Video[] = [] videos: Video[] = []
playlist: VideoPlaylist playlist: VideoPlaylist
@ -33,15 +28,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
totalItems: null totalItems: null
} }
displayTimestampOptions = false
timestampOptions: {
startTimestampEnabled: boolean
startTimestamp: number
stopTimestampEnabled: boolean
stopTimestamp: number
} = {} as any
private videoPlaylistId: string | number private videoPlaylistId: string | number
private paramsSub: Subscription private paramsSub: Subscription
private dragMoveSubject = new Subject<number>() private dragMoveSubject = new Subject<number>()
@ -124,45 +110,9 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
// } // }
} }
isVideoBlur (video: Video) { onElementRemoved (video: Video) {
return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig()) this.videos = this.videos.filter(v => v.id !== video.id)
} this.reorderClientPositions()
removeFromPlaylist (video: Video) {
this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
this.videos = this.videos.filter(v => v.id !== video.id)
this.reorderClientPositions()
},
err => this.notifier.error(err.message)
)
this.moreDropdown.close()
}
updateTimestamps (video: Video) {
const body: VideoPlaylistElementUpdate = {}
body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body)
.subscribe(
() => {
this.notifier.success(this.i18n('Timestamps updated'))
video.playlistElement.startTimestamp = body.startTimestamp
video.playlistElement.stopTimestamp = body.stopTimestamp
},
err => this.notifier.error(err.message)
)
this.moreDropdown.close()
} }
onNearOfBottom () { onNearOfBottom () {
@ -173,50 +123,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
this.loadElements() this.loadElements()
} }
formatTimestamp (video: Video) {
const start = video.playlistElement.startTimestamp
const stop = video.playlistElement.stopTimestamp
const startFormatted = secondsToTime(start, true, ':')
const stopFormatted = secondsToTime(stop, true, ':')
if (start === null && stop === null) return ''
if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
}
onDropdownOpenChange () {
this.displayTimestampOptions = false
}
toggleDisplayTimestampsOptions (event: Event, video: Video) {
event.preventDefault()
this.displayTimestampOptions = !this.displayTimestampOptions
if (this.displayTimestampOptions === true) {
this.timestampOptions = {
startTimestampEnabled: false,
stopTimestampEnabled: false,
startTimestamp: 0,
stopTimestamp: video.duration
}
if (video.playlistElement.startTimestamp) {
this.timestampOptions.startTimestampEnabled = true
this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp
}
if (video.playlistElement.stopTimestamp) {
this.timestampOptions.stopTimestampEnabled = true
this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp
}
}
}
private loadElements () { private loadElements () {
this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination) this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
.subscribe(({ totalVideos, videos }) => { .subscribe(({ totalVideos, videos }) => {

View File

@ -27,7 +27,8 @@ const icons = {
'more-vertical': require('../../../assets/images/global/more-vertical.html'), 'more-vertical': require('../../../assets/images/global/more-vertical.html'),
'share': require('../../../assets/images/video/share.html'), 'share': require('../../../assets/images/video/share.html'),
'upload': require('../../../assets/images/video/upload.html'), 'upload': require('../../../assets/images/video/upload.html'),
'playlist-add': require('../../../assets/images/video/playlist-add.html') 'playlist-add': require('../../../assets/images/video/playlist-add.html'),
'play': require('../../../assets/images/global/play.html')
} }
export type GlobalIconName = keyof typeof icons export type GlobalIconName = keyof typeof icons

View File

@ -77,6 +77,7 @@ import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component'
@NgModule({ @NgModule({
imports: [ imports: [
@ -105,6 +106,7 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo
VideoMiniatureComponent, VideoMiniatureComponent,
VideoPlaylistMiniatureComponent, VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent, VideoAddToPlaylistComponent,
VideoPlaylistElementMiniatureComponent,
FeedComponent, FeedComponent,
@ -163,6 +165,7 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo
VideoMiniatureComponent, VideoMiniatureComponent,
VideoPlaylistMiniatureComponent, VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent, VideoAddToPlaylistComponent,
VideoPlaylistElementMiniatureComponent,
FeedComponent, FeedComponent,

View File

@ -0,0 +1,73 @@
<div class="video" [ngClass]="{ playing: playing }">
<a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
<div class="position">
<my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
<ng-container *ngIf="!playing">{{ video.playlistElement.position }}</ng-container>
</div>
<my-video-thumbnail
[video]="video" [nsfw]="isVideoBlur(video)"
[routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
></my-video-thumbnail>
<div class="video-info">
<a tabindex="-1" class="video-info-name"
[routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
[attr.title]="video.name"
>{{ video.name }}</a>
<a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
<span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ video.byAccount }}</span>
<span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video)}}</span>
</div>
</a>
<div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()"
autoClose="outside">
<my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
<div ngbDropdownMenu>
<div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)">
<my-global-icon iconName="edit"></my-global-icon>
<ng-container i18n>Edit starts/stops at</ng-container>
</div>
<div class="timestamp-options" *ngIf="displayTimestampOptions">
<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>
<input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)">
</div>
<span class="dropdown-item" (click)="removeFromPlaylist(video)">
<my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{playlist?.displayName}}</ng-container>
</span>
</div>
</div>
</div>

View File

@ -0,0 +1,124 @@
@import '_variables';
@import '_mixins';
@import '_miniature';
.video {
display: flex;
align-items: center;
background-color: var(--mainBackgroundColor);
padding: 10px;
border-bottom: 1px solid $separator-border-color;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
.more {
display: block;
}
}
&.playing {
background-color: rgba(0, 0, 0, 0.02);
}
a {
@include disable-default-a-behaviour;
min-width: 0;
display: flex;
align-items: center;
cursor: pointer;
flex-grow: 1;
.position {
font-weight: $font-semibold;
margin-right: 10px;
color: $grey-foreground-color;
min-width: 20px;
my-global-icon {
@include apply-svg-color($grey-foreground-color);
width: 17px;
position: relative;
left: -2px;
}
}
my-video-thumbnail {
@include thumbnail-size-component(130px, 72px);
display: flex; // Avoids an issue with line-height that adds space below the element
margin-right: 10px;
}
.video-info {
display: flex;
flex-direction: column;
min-width: 0;
a {
color: var(--mainForegroundColor);
width: fit-content;
&:hover {
text-decoration: underline !important;
}
}
.video-info-name {
font-size: 18px;
font-weight: $font-semibold;
@include ellipsis;
}
.video-info-account, .video-info-timestamp {
color: $grey-foreground-color;
}
}
}
.more {
justify-self: flex-end;
margin-left: auto;
cursor: pointer;
display: none;
&.show {
display: block;
}
.icon-more {
@include apply-svg-color($grey-foreground-color);
display: flex;
&::after {
border: none;
}
}
.dropdown-item {
@include dropdown-with-icon-item;
}
.timestamp-options {
padding-top: 0;
padding-left: 35px;
margin-bottom: 15px;
> div {
display: flex;
align-items: center;
}
input {
@include peertube-button;
@include orange-button;
margin-top: 10px;
}
}
}
}

View File

@ -0,0 +1,149 @@
import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'
import { Video } from '@app/shared/video/video.model'
import { VideoPlaylistElementUpdate } from '@shared/models'
import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
import { ActivatedRoute } from '@angular/router'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoService } from '@app/shared/video/video.service'
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
import { secondsToTime } from '../../../assets/player/utils'
@Component({
selector: 'my-video-playlist-element-miniature',
styleUrls: [ './video-playlist-element-miniature.component.scss' ],
templateUrl: './video-playlist-element-miniature.component.html'
})
export class VideoPlaylistElementMiniatureComponent {
@ViewChild('moreDropdown') moreDropdown: NgbDropdown
@Input() playlist: VideoPlaylist
@Input() video: Video
@Input() owned = false
@Input() playing = false
@Input() rowLink = false
@Input() accountLink = true
@Output() elementRemoved = new EventEmitter<Video>()
displayTimestampOptions = false
timestampOptions: {
startTimestampEnabled: boolean
startTimestamp: number
stopTimestampEnabled: boolean
stopTimestamp: number
} = {} as any
constructor (
private authService: AuthService,
private serverService: ServerService,
private notifier: Notifier,
private confirmService: ConfirmService,
private route: ActivatedRoute,
private i18n: I18n,
private videoService: VideoService,
private videoPlaylistService: VideoPlaylistService
) {}
buildRouterLink () {
if (!this.playlist) return null
return [ '/videos/watch/playlist', this.playlist.uuid ]
}
buildRouterQuery () {
if (!this.video) return {}
return {
videoId: this.video.uuid,
start: this.video.playlistElement.startTimestamp,
stop: this.video.playlistElement.stopTimestamp
}
}
isVideoBlur (video: Video) {
return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig())
}
removeFromPlaylist (video: Video) {
this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id)
.subscribe(
() => {
this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
this.elementRemoved.emit(this.video)
},
err => this.notifier.error(err.message)
)
this.moreDropdown.close()
}
updateTimestamps (video: Video) {
const body: VideoPlaylistElementUpdate = {}
body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body)
.subscribe(
() => {
this.notifier.success(this.i18n('Timestamps updated'))
video.playlistElement.startTimestamp = body.startTimestamp
video.playlistElement.stopTimestamp = body.stopTimestamp
},
err => this.notifier.error(err.message)
)
this.moreDropdown.close()
}
formatTimestamp (video: Video) {
const start = video.playlistElement.startTimestamp
const stop = video.playlistElement.stopTimestamp
const startFormatted = secondsToTime(start, true, ':')
const stopFormatted = secondsToTime(stop, true, ':')
if (start === null && stop === null) return ''
if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
}
onDropdownOpenChange () {
this.displayTimestampOptions = false
}
toggleDisplayTimestampsOptions (event: Event, video: Video) {
event.preventDefault()
this.displayTimestampOptions = !this.displayTimestampOptions
if (this.displayTimestampOptions === true) {
this.timestampOptions = {
startTimestampEnabled: false,
stopTimestampEnabled: false,
startTimestamp: 0,
stopTimestamp: video.duration
}
if (video.playlistElement.startTimestamp) {
this.timestampOptions.startTimestampEnabled = true
this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp
}
if (video.playlistElement.stopTimestamp) {
this.timestampOptions.stopTimestampEnabled = true
this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp
}
}
}
}

View File

@ -11,6 +11,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
@Input() firstLoadedPage = 1 @Input() firstLoadedPage = 1
@Input() percentLimit = 70 @Input() percentLimit = 70
@Input() autoInit = false @Input() autoInit = false
@Input() container = document.body
@Output() nearOfBottom = new EventEmitter<void>() @Output() nearOfBottom = new EventEmitter<void>()
@Output() nearOfTop = new EventEmitter<void>() @Output() nearOfTop = new EventEmitter<void>()
@ -48,7 +49,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
.pipe( .pipe(
startWith(null), startWith(null),
throttleTime(200, undefined, throttleOptions), throttleTime(200, undefined, throttleOptions),
map(() => ({ current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight })), map(() => ({ current: window.scrollY, maximumScroll: this.container.clientHeight - window.innerHeight })),
distinctUntilChanged((o1, o2) => o1.current === o2.current), distinctUntilChanged((o1, o2) => o1.current === o2.current),
share() share()
) )

View File

@ -1,5 +1,5 @@
<a <a
[routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [attr.title]="video.name"
class="video-thumbnail" class="video-thumbnail"
> >
<img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />

View File

@ -10,8 +10,11 @@ import { ScreenService } from '@app/shared/misc/screen.service'
export class VideoThumbnailComponent { export class VideoThumbnailComponent {
@Input() video: Video @Input() video: Video
@Input() nsfw = false @Input() nsfw = false
@Input() routerLink: any[]
@Input() queryParams: any[]
constructor (private screenService: ScreenService) {} constructor (private screenService: ScreenService) {
}
getImageUrl () { getImageUrl () {
if (!this.video) return '' if (!this.video) return ''
@ -30,4 +33,10 @@ export class VideoThumbnailComponent {
return (currentTime / this.video.duration) * 100 return (currentTime / this.video.duration) * 100
} }
getVideoRouterLink () {
if (this.routerLink) return this.routerLink
return [ '/videos/watch', this.video.uuid ]
}
} }

View File

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

View File

@ -1,11 +1,39 @@
<div class="root-row row"> <div class="root-row row">
<!-- We need the video container for videojs so we just hide it --> <!-- We need the video container for videojs so we just hide it -->
<div id="video-element-wrapper"> <div id="video-wrapper">
<div *ngIf="remoteServerDown" class="remote-server-down"> <div *ngIf="remoteServerDown" class="remote-server-down">
Sorry, but this video is not available because the remote instance is not responding. Sorry, but this video is not available because the remote instance is not responding.
<br /> <br />
Please try again later. Please try again later.
</div> </div>
<div id="videojs-wrapper"></div>
<div *ngIf="playlist && video" class="playlist">
<div class="playlist-info">
<div class="playlist-display-name">
{{ playlist.displayName }}
<span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span>
<span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span>
<span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span>
</div>
<div class="playlist-by-index">
<div class="playlist-by">{{ playlist.ownerBy }}</div>
<div class="playlist-index">
<span>{{currentPlaylistPosition}}</span><span>{{playlistPagination.totalItems}}</span>
</div>
</div>
</div>
<div *ngFor="let playlistVideo of playlistVideos" myInfiniteScroller [autoInit]="true" #elem [container]="elem" (nearOfBottom)="onPlaylistVideosNearOfBottom()">
<my-video-playlist-element-miniature
[video]="playlistVideo" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
[playing]="currentPlaylistPosition === playlistVideo.playlistElement.position" [accountLink]="false"
></my-video-playlist-element-miniature>
</div>
</div>
</div> </div>
<div i18n class="alert alert-warning" *ngIf="isVideoToImport()"> <div i18n class="alert alert-warning" *ngIf="isVideoToImport()">
@ -20,6 +48,10 @@
This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
</div> </div>
<div i18n class="alert alert-info" *ngIf="noPlaylistVideos">
This playlist does not have videos.
</div>
<div class="alert alert-danger" *ngIf="video?.blacklisted"> <div class="alert alert-danger" *ngIf="video?.blacklisted">
<div class="blacklisted-label" i18n>This video is blacklisted.</div> <div class="blacklisted-label" i18n>This video is blacklisted.</div>
{{ video.blacklistedReason }} {{ video.blacklistedReason }}

View File

@ -1,6 +1,7 @@
@import '_variables'; @import '_variables';
@import '_mixins'; @import '_mixins';
@import '_bootstrap-variables'; @import '_bootstrap-variables';
@import '_miniature';
$other-videos-width: 260px; $other-videos-width: 260px;
@ -12,7 +13,7 @@ $other-videos-width: 260px;
font-weight: $font-semibold; font-weight: $font-semibold;
} }
#video-element-wrapper { #video-wrapper {
background-color: #000; background-color: #000;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -39,6 +40,57 @@ $other-videos-width: 260px;
} }
} }
.playlist {
width: 400px;
height: 66vh;
background-color: #e4e4e4;
overflow-y: auto;
.playlist-info {
padding: 5px 30px;
.playlist-display-name {
font-size: 18px;
font-weight: $font-semibold;
margin-bottom: 5px;
}
.playlist-by-index {
color: $grey-foreground-color;
display: flex;
.playlist-by {
margin-right: 5px;
}
.playlist-index span:first-child::after {
content: '/';
margin: 0 3px;
}
}
}
my-video-playlist-element-miniature {
/deep/ {
.video {
.position {
margin-right: 0;
}
.video-info {
.video-info-name {
font-size: 15px;
}
}
}
my-video-thumbnail {
@include thumbnail-size-component(90px, 50px);
}
}
}
}
/deep/ .video-js { /deep/ .video-js {
width: calc(66vh * 1.77); width: calc(66vh * 1.77);
height: 66vh; height: 66vh;

View File

@ -8,7 +8,7 @@ import { MetaService } from '@ngx-meta/core'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { forkJoin, Subscription } from 'rxjs' import { forkJoin, Subscription } from 'rxjs'
import { Hotkey, HotkeysService } from 'angular2-hotkeys' import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' import { UserVideoRateType, VideoCaption, VideoPlaylistPrivacy, VideoPrivacy, VideoState } from '../../../../../shared'
import { AuthService, ConfirmService } from '../../core' import { AuthService, ConfirmService } from '../../core'
import { RestExtractor, VideoBlacklistService } from '../../shared' import { RestExtractor, VideoBlacklistService } from '../../shared'
import { VideoDetails } from '../../shared/video/video-details.model' import { VideoDetails } from '../../shared/video/video-details.model'
@ -28,6 +28,10 @@ import {
PeertubePlayerManagerOptions, PeertubePlayerManagerOptions,
PlayerMode PlayerMode
} from '../../../assets/player/peertube-player-manager' } from '../../../assets/player/peertube-player-manager'
import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { Video } from '@app/shared/video/video.model'
@Component({ @Component({
selector: 'my-video-watch', selector: 'my-video-watch',
@ -50,6 +54,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
video: VideoDetails = null video: VideoDetails = null
descriptionLoading = false descriptionLoading = false
playlist: VideoPlaylist = null
playlistVideos: Video[] = []
playlistPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 10,
totalItems: null
}
noPlaylistVideos = false
currentPlaylistPosition = 1
completeDescriptionShown = false completeDescriptionShown = false
completeVideoDescription: string completeVideoDescription: string
shortVideoDescription: string shortVideoDescription: string
@ -61,6 +75,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private currentTime: number private currentTime: number
private paramsSub: Subscription private paramsSub: Subscription
private queryParamsSub: Subscription
constructor ( constructor (
private elementRef: ElementRef, private elementRef: ElementRef,
@ -68,6 +83,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private videoService: VideoService, private videoService: VideoService,
private playlistService: VideoPlaylistService,
private videoBlacklistService: VideoBlacklistService, private videoBlacklistService: VideoBlacklistService,
private confirmService: ConfirmService, private confirmService: ConfirmService,
private metaService: MetaService, private metaService: MetaService,
@ -97,31 +113,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
} }
this.paramsSub = this.route.params.subscribe(routeParams => { this.paramsSub = this.route.params.subscribe(routeParams => {
const uuid = routeParams[ 'uuid' ] const videoId = routeParams[ 'videoId' ]
if (videoId) this.loadVideo(videoId)
// Video did not change const playlistId = routeParams[ 'playlistId' ]
if (this.video && this.video.uuid === uuid) return if (playlistId) this.loadPlaylist(playlistId)
})
if (this.player) this.player.pause() this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
const videoId = queryParams[ 'videoId' ]
// Video did change if (videoId) this.loadVideo(videoId)
forkJoin(
this.videoService.getVideo(uuid),
this.videoCaptionService.listCaptions(uuid)
)
.pipe(
// If 401, the video is private or blacklisted so redirect to 404
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
)
.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, stopTime, subtitle, playerMode })
.catch(err => this.handleError(err))
})
}) })
this.hotkeys = [ this.hotkeys = [
@ -147,7 +148,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.flushPlayer() this.flushPlayer()
// Unsubscribe subscriptions // Unsubscribe subscriptions
this.paramsSub.unsubscribe() if (this.paramsSub) this.paramsSub.unsubscribe()
if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
// Unbind hotkeys // Unbind hotkeys
if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys)
@ -219,8 +221,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
} }
showShareModal () { showShareModal () {
const currentTime = this.player ? this.player.currentTime() : undefined
this.videoShareModal.show(this.currentTime) this.videoShareModal.show(this.currentTime)
} }
@ -322,6 +322,107 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.video && this.video.scheduledUpdate !== undefined return this.video && this.video.scheduledUpdate !== undefined
} }
isVideoBlur (video: Video) {
return video.isVideoNSFWForUser(this.user, this.serverService.getConfig())
}
isPlaylistOwned () {
return this.playlist.isLocal === true && this.playlist.ownerAccount.name === this.user.username
}
isUnlistedPlaylist () {
return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED
}
isPrivatePlaylist () {
return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
}
isPublicPlaylist () {
return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC
}
onPlaylistVideosNearOfBottom () {
// Last page
if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return
this.playlistPagination.currentPage += 1
this.loadPlaylistElements(false)
}
onElementRemoved (video: Video) {
this.playlistVideos = this.playlistVideos.filter(v => v.id !== video.id)
this.playlistPagination.totalItems--
}
private loadVideo (videoId: string) {
// Video did not change
if (this.video && this.video.uuid === videoId) return
if (this.player) this.player.pause()
// Video did change
forkJoin(
this.videoService.getVideo(videoId),
this.videoCaptionService.listCaptions(videoId)
)
.pipe(
// If 401, the video is private or blacklisted so redirect to 404
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
)
.subscribe(([ video, captionsResult ]) => {
const queryParams = this.route.snapshot.queryParams
const startTime = queryParams.start
const stopTime = queryParams.stop
const subtitle = queryParams.subtitle
const playerMode = queryParams.mode
this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode })
.catch(err => this.handleError(err))
})
}
private loadPlaylist (playlistId: string) {
// Playlist did not change
if (this.playlist && this.playlist.uuid === playlistId) return
this.playlistService.getVideoPlaylist(playlistId)
.pipe(
// If 401, the video is private or blacklisted so redirect to 404
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
)
.subscribe(playlist => {
this.playlist = playlist
const videoId = this.route.snapshot.queryParams['videoId']
this.loadPlaylistElements(!videoId)
})
}
private loadPlaylistElements (redirectToFirst = false) {
this.videoService.getPlaylistVideos(this.playlist.id, this.playlistPagination)
.subscribe(({ totalVideos, videos }) => {
this.playlistVideos = this.playlistVideos.concat(videos)
this.playlistPagination.totalItems = totalVideos
if (totalVideos === 0) {
this.noPlaylistVideos = true
return
}
this.updatePlaylistIndex()
if (redirectToFirst) {
const extras = {
queryParams: { videoId: this.playlistVideos[ 0 ].uuid },
replaceUrl: true
}
this.router.navigate([], extras)
}
})
}
private updateVideoDescription (description: string) { private updateVideoDescription (description: string) {
this.video.description = description this.video.description = description
this.setVideoDescriptionHTML() this.setVideoDescriptionHTML()
@ -383,11 +484,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.remoteServerDown = false this.remoteServerDown = false
this.currentTime = undefined this.currentTime = undefined
this.updatePlaylistIndex()
let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0)
// If we are at the end of the video, reset the timer // If we are at the end of the video, reset the timer
if (this.video.duration - startTime <= 1) startTime = 0 if (this.video.duration - startTime <= 1) startTime = 0
if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { if (this.isVideoBlur(this.video)) {
const res = await this.confirmService.confirm( const res = await this.confirmService.confirm(
this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
this.i18n('Mature or explicit content') this.i18n('Mature or explicit content')
@ -399,7 +502,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.flushPlayer() this.flushPlayer()
// Build video element, because videojs remove it on dispose // Build video element, because videojs remove it on dispose
const playerElementWrapper = this.elementRef.nativeElement.querySelector('#video-element-wrapper') const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
this.playerElement = document.createElement('video') this.playerElement = document.createElement('video')
this.playerElement.className = 'video-js vjs-peertube-skin' this.playerElement.className = 'video-js vjs-peertube-skin'
this.playerElement.setAttribute('playsinline', 'true') this.playerElement.setAttribute('playsinline', 'true')
@ -474,6 +577,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.player.on('timeupdate', () => { this.player.on('timeupdate', () => {
this.currentTime = Math.floor(this.player.currentTime()) this.currentTime = Math.floor(this.player.currentTime())
}) })
this.player.one('ended', () => {
if (this.playlist) {
this.zone.run(() => this.navigateToNextPlaylistVideo())
}
})
this.player.one('stopped', () => {
if (this.playlist) {
this.zone.run(() => this.navigateToNextPlaylistVideo())
}
})
}) })
this.setVideoDescriptionHTML() this.setVideoDescriptionHTML()
@ -528,6 +643,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.setVideoLikesBarTooltipText() this.setVideoLikesBarTooltipText()
} }
private updatePlaylistIndex () {
if (this.playlistVideos.length === 0 || !this.video) return
for (const video of this.playlistVideos) {
if (video.id === this.video.id) {
this.currentPlaylistPosition = video.playlistElement.position
return
}
}
// Load more videos to find our video
this.onPlaylistVideosNearOfBottom()
}
private setOpenGraphTags () { private setOpenGraphTags () {
this.metaService.setTitle(this.video.name) this.metaService.setTitle(this.video.name)
@ -567,4 +696,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.player = undefined this.player = undefined
} }
} }
private navigateToNextPlaylistVideo () {
if (this.currentPlaylistPosition < this.playlistPagination.totalItems) {
const next = this.playlistVideos.find(v => v.playlistElement.position === this.currentPlaylistPosition + 1)
const start = next.playlistElement.startTimestamp
const stop = next.playlistElement.stopTimestamp
this.router.navigate([],{ queryParams: { videoId: next.uuid, start, stop } })
}
}
} }

View File

@ -0,0 +1,9 @@
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
<g id="Artboard-4" transform="translate(-532.000000, -115.000000)" stroke-width="2" stroke="#000000">
<g id="12" transform="translate(532.000000, 115.000000)">
<polygon id="Triangle-1" points="5 21 5 3 21 12" fill="#000000"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 528 B

View File

@ -4,6 +4,7 @@ import * as videojs from 'video.js'
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings' import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from '../peertube-videojs-typings'
import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs' import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
import { Events } from 'p2p-media-loader-core' import { Events } from 'p2p-media-loader-core'
import { timeToInt } from '../utils'
// videojs-hlsjs-plugin needs videojs in window // videojs-hlsjs-plugin needs videojs in window
window['videojs'] = videojs window['videojs'] = videojs
@ -32,6 +33,7 @@ class P2pMediaLoaderPlugin extends Plugin {
totalDownload: 0, totalDownload: 0,
totalUpload: 0 totalUpload: 0
} }
private startTime: number
private networkInfoInterval: any private networkInfoInterval: any
@ -54,12 +56,14 @@ class P2pMediaLoaderPlugin extends Plugin {
initVideoJsContribHlsJsPlayer(player) initVideoJsContribHlsJsPlayer(player)
this.startTime = timeToInt(options.startTime)
player.src({ player.src({
type: options.type, type: options.type,
src: options.src src: options.src
}) })
player.on('play', () => { player.one('play', () => {
player.addClass('vjs-has-big-play-button-clicked') player.addClass('vjs-has-big-play-button-clicked')
}) })
@ -92,6 +96,12 @@ class P2pMediaLoaderPlugin extends Plugin {
this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length this.statsP2PBytes.numPeers = 1 + this.options.redundancyBaseUrls.length
this.runStats() this.runStats()
this.hlsjs.on('hlsLevelLoaded', () => {
if (this.startTime) this.player.currentTime(this.startTime)
this.hlsjs.off('hlsLevelLoaded', this)
})
} }
private runStats () { private runStats () {

View File

@ -83,9 +83,15 @@ class PeerTubePlugin extends Plugin {
if (options.stopTime) { if (options.stopTime) {
const stopTime = timeToInt(options.stopTime) const stopTime = timeToInt(options.stopTime)
const self = this
this.player.on('timeupdate', () => { this.player.on('timeupdate', function onTimeUpdate () {
if (this.player.currentTime() > stopTime) this.player.pause() if (self.player.currentTime() > stopTime) {
self.player.pause()
self.player.trigger('stopped')
self.player.off('timeupdate', onTimeUpdate)
}
}) })
} }

View File

@ -28,15 +28,15 @@ $play-overlay-transition: 0.2s ease;
$play-overlay-height: 26px; $play-overlay-height: 26px;
$play-overlay-width: 18px; $play-overlay-width: 18px;
@mixin miniature-thumbnail($width: $video-thumbnail-width, $height: $video-thumbnail-height) { @mixin miniature-thumbnail {
@include disable-outline; @include disable-outline;
display: inline-block; display: inline-block;
position: relative; position: relative;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
width: $width; width: $video-thumbnail-width;
height: $height; height: $video-thumbnail-height;
background-color: #ececec; background-color: #ececec;
transition: filter $play-overlay-transition; transition: filter $play-overlay-transition;
@ -97,6 +97,13 @@ $play-overlay-width: 18px;
} }
} }
@mixin thumbnail-size-component ($width, $height) {
/deep/ .video-thumbnail {
width: $width;
height: $height;
}
}
@mixin static-thumbnail-overlay { @mixin static-thumbnail-overlay {
display: inline-block; display: inline-block;
background-color: rgba(0, 0, 0, 0.7); background-color: rgba(0, 0, 0, 0.7);

View File

@ -63,11 +63,11 @@
@mixin apply-svg-color ($color) { @mixin apply-svg-color ($color) {
/deep/ svg { /deep/ svg {
path[fill="#000000"], g[fill="#000000"], rect[fill="#000000"], circle[fill="#000000"] { path[fill="#000000"], g[fill="#000000"], rect[fill="#000000"], circle[fill="#000000"], polygon[fill="#000000"] {
fill: $color; fill: $color;
} }
path[stroke="#000000"], g[stroke="#000000"], rect[stroke="#000000"], circle[stroke="#000000"] { path[stroke="#000000"], g[stroke="#000000"], rect[stroke="#000000"], circle[stroke="#000000"], polygon[stroke="#000000"] {
stroke: $color; stroke: $color;
} }
} }