From e2f01c47e08d26a30ad47068d195b3d21d0df8a1 Mon Sep 17 00:00:00 2001 From: Chocobozzz <me@florianbigard.com> Date: Wed, 13 Mar 2019 14:18:58 +0100 Subject: [PATCH] Playlist support in watch page --- ...unt-video-playlist-elements.component.html | 57 +---- ...unt-video-playlist-elements.component.scss | 94 --------- ...count-video-playlist-elements.component.ts | 102 +-------- .../shared/images/global-icon.component.ts | 3 +- client/src/app/shared/shared.module.ts | 3 + ...-playlist-element-miniature.component.html | 73 +++++++ ...-playlist-element-miniature.component.scss | 124 +++++++++++ ...eo-playlist-element-miniature.component.ts | 149 +++++++++++++ .../video/infinite-scroller.directive.ts | 3 +- .../video/video-thumbnail.component.html | 2 +- .../shared/video/video-thumbnail.component.ts | 11 +- .../video-watch-routing.module.ts | 8 +- .../+video-watch/video-watch.component.html | 34 ++- .../+video-watch/video-watch.component.scss | 54 ++++- .../+video-watch/video-watch.component.ts | 197 +++++++++++++++--- client/src/assets/images/global/play.html | 9 + .../p2p-media-loader-plugin.ts | 12 +- client/src/assets/player/peertube-plugin.ts | 10 +- client/src/sass/include/_miniature.scss | 13 +- client/src/sass/include/_mixins.scss | 4 +- 20 files changed, 668 insertions(+), 294 deletions(-) create mode 100644 client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html create mode 100644 client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss create mode 100644 client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts create mode 100644 client/src/assets/images/global/play.html diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html index 67a8b1a91..bc26e198e 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html @@ -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> diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss index 4ac89d08f..b05af0490 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss @@ -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; diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts index 4076a3721..dcf470be3 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts @@ -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 }) => { diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts index 093e88033..3fa6fea96 100644 --- a/client/src/app/shared/images/global-icon.component.ts +++ b/client/src/app/shared/images/global-icon.component.ts @@ -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 diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 05da0d829..3647fc786 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -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, diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html new file mode 100644 index 000000000..1f178675f --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html @@ -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> diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss new file mode 100644 index 000000000..eb869f69a --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss @@ -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; + } + } + } +} diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts new file mode 100644 index 000000000..c0cfd855d --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts @@ -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 + } + } + } +} diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts index a02e9444a..186597a3a 100644 --- a/client/src/app/shared/video/infinite-scroller.directive.ts +++ b/client/src/app/shared/video/infinite-scroller.directive.ts @@ -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() ) diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html index a6757fc4a..b302ebd0f 100644 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ b/client/src/app/shared/video/video-thumbnail.component.html @@ -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 }" /> diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts index ca43700c7..fe65ade94 100644 --- a/client/src/app/shared/video/video-thumbnail.component.ts +++ b/client/src/app/shared/video/video-thumbnail.component.ts @@ -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 ] + } } diff --git a/client/src/app/videos/+video-watch/video-watch-routing.module.ts b/client/src/app/videos/+video-watch/video-watch-routing.module.ts index 0d7809044..ce9250bdc 100644 --- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts +++ b/client/src/app/videos/+video-watch/video-watch-routing.module.ts @@ -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 ] } diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index 394c31f23..7f3d1cc2e 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html @@ -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 }} diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss index 44040e90d..e1cb249ef 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ b/client/src/app/videos/+video-watch/video-watch.component.scss @@ -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; diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index 359217f3b..ddd0f1766 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -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 - - 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 = [ @@ -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 } }) + } + } } diff --git a/client/src/assets/images/global/play.html b/client/src/assets/images/global/play.html new file mode 100644 index 000000000..d00122de4 --- /dev/null +++ b/client/src/assets/images/global/play.html @@ -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> diff --git a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts index 4dbfda300..bbd3e008d 100644 --- a/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts +++ b/client/src/assets/player/p2p-media-loader/p2p-media-loader-plugin.ts @@ -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 () { diff --git a/client/src/assets/player/peertube-plugin.ts b/client/src/assets/player/peertube-plugin.ts index 3991e4627..dd9408c8e 100644 --- a/client/src/assets/player/peertube-plugin.ts +++ b/client/src/assets/player/peertube-plugin.ts @@ -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) + } }) } diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss index 25a024aac..95b759225 100644 --- a/client/src/sass/include/_miniature.scss +++ b/client/src/sass/include/_miniature.scss @@ -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); diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 7faeec6bd..9b18f6354 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -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; } } -- 2.25.1