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>
@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;
-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'
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'
styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
})
export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
- @ViewChild('moreDropdown') moreDropdown: NgbDropdown
-
videos: Video[] = []
playlist: VideoPlaylist
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>()
// }
}
- 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 () {
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 }) => {
'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
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: [
VideoMiniatureComponent,
VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent,
+ VideoPlaylistElementMiniatureComponent,
FeedComponent,
VideoMiniatureComponent,
VideoPlaylistMiniatureComponent,
VideoAddToPlaylistComponent,
+ VideoPlaylistElementMiniatureComponent,
FeedComponent,
--- /dev/null
+<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>
--- /dev/null
+@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;
+ }
+ }
+ }
+}
--- /dev/null
+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
+ }
+ }
+ }
+}
@Input() firstLoadedPage = 1
@Input() percentLimit = 70
@Input() autoInit = false
+ @Input() container = document.body
@Output() nearOfBottom = new EventEmitter<void>()
@Output() nearOfTop = new EventEmitter<void>()
.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()
)
<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 }" />
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 ''
return (currentTime / this.video.duration) * 100
}
+
+ getVideoRouterLink () {
+ if (this.routerLink) return this.routerLink
+
+ return [ '/videos/watch', this.video.uuid ]
+ }
}
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 ]
}
<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()">
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 }}
@import '_variables';
@import '_mixins';
@import '_bootstrap-variables';
+@import '_miniature';
$other-videos-width: 260px;
font-weight: $font-semibold;
}
-#video-element-wrapper {
+#video-wrapper {
background-color: #000;
display: flex;
justify-content: center;
}
}
+ .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;
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'
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',
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
private currentTime: number
private paramsSub: Subscription
+ private queryParamsSub: Subscription
constructor (
private elementRef: ElementRef,
private route: ActivatedRoute,
private router: Router,
private videoService: VideoService,
+ private playlistService: VideoPlaylistService,
private videoBlacklistService: VideoBlacklistService,
private confirmService: ConfirmService,
private metaService: MetaService,
}
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
-
- if (this.player) this.player.pause()
+ const playlistId = routeParams[ 'playlistId' ]
+ if (playlistId) this.loadPlaylist(playlistId)
+ })
- // 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 = [
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)
}
showShareModal () {
- const currentTime = this.player ? this.player.currentTime() : undefined
-
this.videoShareModal.show(this.currentTime)
}
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()
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')
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')
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()
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)
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 } })
+ }
+ }
}
--- /dev/null
+<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>
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
totalDownload: 0,
totalUpload: 0
}
+ private startTime: number
private networkInfoInterval: any
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')
})
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 () {
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)
+ }
})
}
$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;
}
}
+@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);
@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;
}
}