Playlist support in watch page

pull/1745/head
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)"
>
<div class="video" *ngFor="let video of videos" cdkDrag (cdkDragMoved)="onDragMove($event)">
<div class="position">{{ video.playlistElement.position }}</div>
<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>
<my-video-playlist-element-miniature [video]="video" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)">
</my-video-playlist-element-miniature>
</div>
</div>

View File

@ -2,100 +2,6 @@
@import '_mixins';
@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
.cdk-drag-preview {
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 { AuthService } from '../../core/auth'
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 { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
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 { throttleTime } from 'rxjs/operators'
@ -22,8 +19,6 @@ import { throttleTime } from 'rxjs/operators'
styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
})
export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
@ViewChild('moreDropdown') moreDropdown: NgbDropdown
videos: Video[] = []
playlist: VideoPlaylist
@ -33,15 +28,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
totalItems: null
}
displayTimestampOptions = false
timestampOptions: {
startTimestampEnabled: boolean
startTimestamp: number
stopTimestampEnabled: boolean
stopTimestamp: number
} = {} as any
private videoPlaylistId: string | number
private paramsSub: Subscription
private dragMoveSubject = new Subject<number>()
@ -124,45 +110,9 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
// }
}
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.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()
onElementRemoved (video: Video) {
this.videos = this.videos.filter(v => v.id !== video.id)
this.reorderClientPositions()
}
onNearOfBottom () {
@ -173,50 +123,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
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 () {
this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
.subscribe(({ totalVideos, videos }) => {

View File

@ -27,7 +27,8 @@ const icons = {
'more-vertical': require('../../../assets/images/global/more-vertical.html'),
'share': require('../../../assets/images/video/share.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

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 { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component'
@NgModule({
imports: [
@ -105,6 +106,7 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo
VideoMiniatureComponent,
VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent,
VideoPlaylistElementMiniatureComponent,
FeedComponent,
@ -163,6 +165,7 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo
VideoMiniatureComponent,
VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent,
VideoPlaylistElementMiniatureComponent,
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() percentLimit = 70
@Input() autoInit = false
@Input() container = document.body
@Output() nearOfBottom = new EventEmitter<void>()
@Output() nearOfTop = new EventEmitter<void>()
@ -48,7 +49,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy {
.pipe(
startWith(null),
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),
share()
)

View File

@ -1,5 +1,5 @@
<a
[routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name"
[routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [attr.title]="video.name"
class="video-thumbnail"
>
<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 {
@Input() video: Video
@Input() nsfw = false
@Input() routerLink: any[]
@Input() queryParams: any[]
constructor (private screenService: ScreenService) {}
constructor (private screenService: ScreenService) {
}
getImageUrl () {
if (!this.video) return ''
@ -30,4 +33,10 @@ export class VideoThumbnailComponent {
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 = [
{
path: 'playlist/:uuid',
path: 'playlist/:playlistId',
component: VideoWatchComponent,
canActivate: [ MetaGuard ]
},
{
path: ':uuid/comments/:commentId',
redirectTo: ':uuid'
path: ':videoId/comments/:commentId',
redirectTo: ':videoId'
},
{
path: ':uuid',
path: ':videoId',
component: VideoWatchComponent,
canActivate: [ MetaGuard ]
}

View File

@ -1,11 +1,39 @@
<div class="root-row row">
<!-- 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">
Sorry, but this video is not available because the remote instance is not responding.
<br />
Please try again later.
</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 i18n class="alert alert-warning" *ngIf="isVideoToImport()">
@ -20,6 +48,10 @@
This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
</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="blacklisted-label" i18n>This video is blacklisted.</div>
{{ video.blacklistedReason }}

View File

@ -1,6 +1,7 @@
@import '_variables';
@import '_mixins';
@import '_bootstrap-variables';
@import '_miniature';
$other-videos-width: 260px;
@ -12,7 +13,7 @@ $other-videos-width: 260px;
font-weight: $font-semibold;
}
#video-element-wrapper {
#video-wrapper {
background-color: #000;
display: flex;
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 {
width: calc(66vh * 1.77);
height: 66vh;

View File

@ -8,7 +8,7 @@ import { MetaService } from '@ngx-meta/core'
import { Notifier, ServerService } from '@app/core'
import { forkJoin, Subscription } from 'rxjs'
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 { RestExtractor, VideoBlacklistService } from '../../shared'
import { VideoDetails } from '../../shared/video/video-details.model'
@ -28,6 +28,10 @@ import {
PeertubePlayerManagerOptions,
PlayerMode
} 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({
selector: 'my-video-watch',
@ -50,6 +54,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
video: VideoDetails = null
descriptionLoading = false
playlist: VideoPlaylist = null
playlistVideos: Video[] = []
playlistPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 10,
totalItems: null
}
noPlaylistVideos = false
currentPlaylistPosition = 1
completeDescriptionShown = false
completeVideoDescription: string
shortVideoDescription: string
@ -61,6 +75,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private currentTime: number
private paramsSub: Subscription
private queryParamsSub: Subscription
constructor (
private elementRef: ElementRef,
@ -68,6 +83,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private videoService: VideoService,
private playlistService: VideoPlaylistService,
private videoBlacklistService: VideoBlacklistService,
private confirmService: ConfirmService,
private metaService: MetaService,
@ -97,31 +113,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
}
this.paramsSub = this.route.params.subscribe(routeParams => {
const uuid = routeParams[ 'uuid' ]
const videoId = routeParams[ 'videoId' ]
if (videoId) this.loadVideo(videoId)
// Video did not change
if (this.video && this.video.uuid === uuid) return
const playlistId = routeParams[ 'playlistId' ]
if (playlistId) this.loadPlaylist(playlistId)
})
if (this.player) this.player.pause()
// Video did change
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.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
const videoId = queryParams[ 'videoId' ]
if (videoId) this.loadVideo(videoId)
})
this.hotkeys = [
@ -147,7 +148,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.flushPlayer()
// Unsubscribe subscriptions
this.paramsSub.unsubscribe()
if (this.paramsSub) this.paramsSub.unsubscribe()
if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
// Unbind hotkeys
if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys)
@ -219,8 +221,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
}
showShareModal () {
const currentTime = this.player ? this.player.currentTime() : undefined
this.videoShareModal.show(this.currentTime)
}
@ -322,6 +322,107 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
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) {
this.video.description = description
this.setVideoDescriptionHTML()
@ -383,11 +484,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.remoteServerDown = false
this.currentTime = undefined
this.updatePlaylistIndex()
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 (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(
this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
this.i18n('Mature or explicit content')
@ -399,7 +502,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.flushPlayer()
// 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.className = 'video-js vjs-peertube-skin'
this.playerElement.setAttribute('playsinline', 'true')
@ -474,6 +577,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.player.on('timeupdate', () => {
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()
@ -528,6 +643,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
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 () {
this.metaService.setTitle(this.video.name)
@ -567,4 +696,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
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 { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
import { Events } from 'p2p-media-loader-core'
import { timeToInt } from '../utils'
// videojs-hlsjs-plugin needs videojs in window
window['videojs'] = videojs
@ -32,6 +33,7 @@ class P2pMediaLoaderPlugin extends Plugin {
totalDownload: 0,
totalUpload: 0
}
private startTime: number
private networkInfoInterval: any
@ -54,12 +56,14 @@ class P2pMediaLoaderPlugin extends Plugin {
initVideoJsContribHlsJsPlayer(player)
this.startTime = timeToInt(options.startTime)
player.src({
type: options.type,
src: options.src
})
player.on('play', () => {
player.one('play', () => {
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.runStats()
this.hlsjs.on('hlsLevelLoaded', () => {
if (this.startTime) this.player.currentTime(this.startTime)
this.hlsjs.off('hlsLevelLoaded', this)
})
}
private runStats () {

View File

@ -83,9 +83,15 @@ class PeerTubePlugin extends Plugin {
if (options.stopTime) {
const stopTime = timeToInt(options.stopTime)
const self = this
this.player.on('timeupdate', () => {
if (this.player.currentTime() > stopTime) this.player.pause()
this.player.on('timeupdate', function onTimeUpdate () {
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-width: 18px;
@mixin miniature-thumbnail($width: $video-thumbnail-width, $height: $video-thumbnail-height) {
@mixin miniature-thumbnail {
@include disable-outline;
display: inline-block;
position: relative;
border-radius: 3px;
overflow: hidden;
width: $width;
height: $height;
width: $video-thumbnail-width;
height: $video-thumbnail-height;
background-color: #ececec;
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 {
display: inline-block;
background-color: rgba(0, 0, 0, 0.7);

View File

@ -63,11 +63,11 @@
@mixin apply-svg-color ($color) {
/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;
}
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;
}
}