From 1942f11d5ee6926ad93dc1b79fae18325ba5de18 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 23 Jun 2020 14:49:20 +0200 Subject: Lazy load all routes --- .../comment/video-comment-add.component.html | 56 ++ .../comment/video-comment-add.component.scss | 82 +++ .../comment/video-comment-add.component.ts | 149 ++++ .../comment/video-comment-thread-tree.model.ts | 7 + .../comment/video-comment.component.html | 95 +++ .../comment/video-comment.component.scss | 189 +++++ .../comment/video-comment.component.ts | 131 ++++ .../+video-watch/comment/video-comment.model.ts | 48 ++ .../+video-watch/comment/video-comment.service.ts | 149 ++++ .../comment/video-comments.component.html | 98 +++ .../comment/video-comments.component.scss | 53 ++ .../comment/video-comments.component.ts | 232 ++++++ .../+video-watch/modal/video-share.component.html | 187 +++++ .../+video-watch/modal/video-share.component.scss | 79 +++ .../+video-watch/modal/video-share.component.ts | 126 ++++ .../modal/video-support.component.html | 15 + .../modal/video-support.component.scss | 3 + .../+video-watch/modal/video-support.component.ts | 29 + .../recent-videos-recommendation.service.ts | 81 +++ .../recommendations/recommendation-info.model.ts | 4 + .../recommendations/recommendations.module.ts | 34 + .../recommendations/recommendations.service.ts | 7 + .../recommended-videos.component.html | 24 + .../recommended-videos.component.scss | 31 + .../recommended-videos.component.ts | 91 +++ .../recommendations/recommended-videos.store.ts | 37 + .../timestamp-route-transformer.directive.ts | 39 + .../+video-watch/video-duration-formatter.pipe.ts | 28 + .../video-watch-playlist.component.html | 46 ++ .../video-watch-playlist.component.scss | 83 +++ .../+video-watch/video-watch-playlist.component.ts | 201 ++++++ .../+video-watch/video-watch-routing.module.ts | 27 + .../+video-watch/video-watch.component.html | 277 ++++++++ .../+video-watch/video-watch.component.scss | 607 ++++++++++++++++ .../+videos/+video-watch/video-watch.component.ts | 782 +++++++++++++++++++++ .../app/+videos/+video-watch/video-watch.module.ts | 65 ++ 36 files changed, 4192 insertions(+) create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.html create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.html create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.scss create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.model.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.service.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.html create mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.scss create mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.ts create mode 100644 client/src/app/+videos/+video-watch/modal/video-share.component.html create mode 100644 client/src/app/+videos/+video-watch/modal/video-share.component.scss create mode 100644 client/src/app/+videos/+video-watch/modal/video-share.component.ts create mode 100644 client/src/app/+videos/+video-watch/modal/video-support.component.html create mode 100644 client/src/app/+videos/+video-watch/modal/video-support.component.scss create mode 100644 client/src/app/+videos/+video-watch/modal/video-support.component.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts create mode 100644 client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts create mode 100644 client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.html create mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.scss create mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch-routing.module.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch.component.html create mode 100644 client/src/app/+videos/+video-watch/video-watch.component.scss create mode 100644 client/src/app/+videos/+video-watch/video-watch.component.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch.module.ts (limited to 'client/src/app/+videos/+video-watch') diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html new file mode 100644 index 000000000..9b43d91da --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html @@ -0,0 +1,56 @@ +
+
+ Avatar + +
+ +
+ {{ formErrors.text }} +
+
+
+ +
+ + +
+
+ + + + + + diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss new file mode 100644 index 000000000..b3725ab94 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss @@ -0,0 +1,82 @@ +@import '_variables'; +@import '_mixins'; + +form { + margin-bottom: 30px; +} + +.avatar-and-textarea { + display: flex; + margin-bottom: 10px; + + img { + @include avatar(25px); + + vertical-align: top; + margin-right: 10px; + } + + .form-group { + flex-grow: 1; + margin: 0; + + textarea { + @include peertube-textarea(100%, 60px); + + &:focus::placeholder { + opacity: 0; + } + } + } +} + +.comment-buttons { + display: flex; + justify-content: flex-end; + + button { + @include peertube-button; + @include disable-outline; + @include disable-default-a-behaviour; + + &:not(:last-child) { + margin-right: .5rem; + } + + &:last-child { + @include orange-button; + } + } + + .cancel-button { + @include tertiary-button; + + font-weight: $font-semibold; + display: inline-block; + padding: 0 10px 0 10px; + white-space: nowrap; + background: transparent; + } +} + +@media screen and (max-width: 600px) { + textarea, .comment-buttons button { + font-size: 14px !important; + } + + textarea { + padding: 5px !important; + } +} + +.modal-body { + .btn { + @include peertube-button; + @include orange-button; + } + + span { + float: left; + margin-bottom: 20px; + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts new file mode 100644 index 000000000..79505c779 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts @@ -0,0 +1,149 @@ +import { Observable } from 'rxjs' +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { Router } from '@angular/router' +import { Notifier, User } from '@app/core' +import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms' +import { Video } from '@app/shared/shared-main' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { VideoCommentCreate } from '@shared/models' +import { VideoComment } from './video-comment.model' +import { VideoCommentService } from './video-comment.service' + +@Component({ + selector: 'my-video-comment-add', + templateUrl: './video-comment-add.component.html', + styleUrls: ['./video-comment-add.component.scss'] +}) +export class VideoCommentAddComponent extends FormReactive implements OnInit { + @Input() user: User + @Input() video: Video + @Input() parentComment: VideoComment + @Input() parentComments: VideoComment[] + @Input() focusOnInit = false + + @Output() commentCreated = new EventEmitter() + @Output() cancel = new EventEmitter() + + @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal + @ViewChild('textarea', { static: true }) textareaElement: ElementRef + + addingComment = false + + constructor ( + protected formValidatorService: FormValidatorService, + private videoCommentValidatorsService: VideoCommentValidatorsService, + private notifier: Notifier, + private videoCommentService: VideoCommentService, + private modalService: NgbModal, + private router: Router + ) { + super() + } + + ngOnInit () { + this.buildForm({ + text: this.videoCommentValidatorsService.VIDEO_COMMENT_TEXT + }) + + if (this.user) { + if (this.focusOnInit === true) { + this.textareaElement.nativeElement.focus() + } + + if (this.parentComment) { + const mentions = this.parentComments + .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves + .map(c => '@' + c.by) + + const mentionsSet = new Set(mentions) + const mentionsText = Array.from(mentionsSet).join(' ') + ' ' + + this.form.patchValue({ text: mentionsText }) + } + } + } + + onValidKey () { + this.check() + if (!this.form.valid) return + + this.formValidated() + } + + openVisitorModal (event: any) { + if (this.user === null) { // we only open it for visitors + // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error + event.srcElement.blur() + event.preventDefault() + + this.modalService.open(this.visitorModal) + } + } + + hideVisitorModal () { + this.modalService.dismissAll() + } + + formValidated () { + // If we validate very quickly the comment form, we might comment twice + if (this.addingComment) return + + this.addingComment = true + + const commentCreate: VideoCommentCreate = this.form.value + let obs: Observable + + if (this.parentComment) { + obs = this.addCommentReply(commentCreate) + } else { + obs = this.addCommentThread(commentCreate) + } + + obs.subscribe( + comment => { + this.addingComment = false + this.commentCreated.emit(comment) + this.form.reset() + }, + + err => { + this.addingComment = false + + this.notifier.error(err.text) + } + ) + } + + isAddButtonDisplayed () { + return this.form.value['text'] + } + + getUri () { + return window.location.href + } + + getAvatarUrl () { + if (this.user) return this.user.accountAvatarUrl + return window.location.origin + '/client/assets/images/default-avatar.png' + } + + gotoLogin () { + this.hideVisitorModal() + this.router.navigate([ '/login' ]) + } + + cancelCommentReply () { + this.cancel.emit(null) + this.form.value['text'] = this.textareaElement.nativeElement.value = '' + } + + private addCommentReply (commentCreate: VideoCommentCreate) { + return this.videoCommentService + .addCommentReply(this.video.id, this.parentComment.id, commentCreate) + } + + private addCommentThread (commentCreate: VideoCommentCreate) { + return this.videoCommentService + .addCommentThread(this.video.id, commentCreate) + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts new file mode 100644 index 000000000..7c2aaeadd --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts @@ -0,0 +1,7 @@ +import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models' +import { VideoComment } from './video-comment.model' + +export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { + comment: VideoComment + children: VideoCommentThreadTree[] +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/comment/video-comment.component.html new file mode 100644 index 000000000..002de57e4 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.html @@ -0,0 +1,95 @@ +
+
+ + Avatar + + +
+
+ +
+ + +
+ +
Highlighted comment
+ + +
+ +
+
Reply
+
Delete
+ + +
+
+ + + + +
+ This comment has been deleted +
+
+ + + +
+
+ +
+
+ + +
+
+
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss new file mode 100644 index 000000000..e7ef79561 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss @@ -0,0 +1,189 @@ +@import '_variables'; +@import '_mixins'; + +.root-comment { + font-size: 15px; + display: flex; + + .left { + display: flex; + flex-direction: column; + align-items: center; + margin-right: 10px; + + .vertical-border { + width: 2px; + height: 100%; + background-color: rgba(0, 0, 0, 0.05); + margin: 10px calc(1rem + 1px); + } + } + + .right { + width: 100%; + } + + .comment-avatar { + @include avatar(36px); + } + + .comment { + flex-grow: 1; + // Fix word-wrap with flex + min-width: 1px; + + .highlighted-comment { + display: inline-block; + background-color: #F5F5F5; + color: #3d3d3d; + padding: 0 5px; + font-size: 13px; + margin-bottom: 5px; + font-weight: $font-semibold; + border-radius: 3px; + } + + .comment-account-date { + display: flex; + margin-bottom: 4px; + + .video-author { + height: 20px; + background-color: #888888; + border-radius: 12px; + margin-bottom: 2px; + max-width: 100%; + box-sizing: border-box; + flex-direction: row; + align-items: center; + display: inline-flex; + padding-right: 6px; + padding-left: 6px; + color: white !important; + } + + .comment-account { + word-break: break-all; + font-weight: 600; + font-size: 90%; + + a { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + } + + .comment-account-fid { + opacity: .6; + } + } + + .comment-date { + font-size: 90%; + color: pvar(--greyForegroundColor); + margin-left: 5px; + text-decoration: none; + } + } + + .comment-html { + @include peertube-word-wrap; + + // Mentions + ::ng-deep a { + + &:not(.linkified-url) { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + + font-weight: $font-semibold; + } + + } + + // Paragraphs + ::ng-deep p { + margin-bottom: .3rem; + } + + &.comment-html-deleted { + color: pvar(--greyForegroundColor); + margin-bottom: 1rem; + } + } + + .comment-actions { + margin-bottom: 10px; + display: flex; + + ::ng-deep .dropdown-toggle, + .comment-action-reply, + .comment-action-delete { + color: pvar(--greyForegroundColor); + cursor: pointer; + margin-right: 10px; + + &:hover { + color: pvar(--mainForegroundColor); + } + } + + ::ng-deep .action-button { + background-color: transparent; + padding: 0; + font-weight: unset; + } + } + + my-video-comment-add { + ::ng-deep form { + margin-top: 1rem; + margin-bottom: 0; + } + } + } + + .children { + // Reduce avatars size for replies + .comment-avatar { + @include avatar(25px); + } + + .left { + margin-right: 6px; + } + } +} + +@media screen and (max-width: 1200px) { + .children { + margin-left: -10px; + } +} + +@media screen and (max-width: 600px) { + .root-comment { + .children { + margin-left: -20px; + + .left { + align-items: flex-start; + + .vertical-border { + margin-left: 2px; + } + } + } + + .comment { + .comment-account-date { + flex-direction: column; + + .comment-date { + margin-left: 0; + } + } + } + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts new file mode 100644 index 000000000..27846c1ad --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts @@ -0,0 +1,131 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' +import { MarkdownService, Notifier, UserService } from '@app/core' +import { AuthService } from '@app/core/auth' +import { Account, Actor, Video } from '@app/shared/shared-main' +import { User, UserRight } from '@shared/models' +import { VideoCommentThreadTree } from './video-comment-thread-tree.model' +import { VideoComment } from './video-comment.model' + +@Component({ + selector: 'my-video-comment', + templateUrl: './video-comment.component.html', + styleUrls: ['./video-comment.component.scss'] +}) +export class VideoCommentComponent implements OnInit, OnChanges { + @Input() video: Video + @Input() comment: VideoComment + @Input() parentComments: VideoComment[] = [] + @Input() commentTree: VideoCommentThreadTree + @Input() inReplyToCommentId: number + @Input() highlightedComment = false + @Input() firstInThread = false + + @Output() wantedToDelete = new EventEmitter() + @Output() wantedToReply = new EventEmitter() + @Output() threadCreated = new EventEmitter() + @Output() resetReply = new EventEmitter() + @Output() timestampClicked = new EventEmitter() + + sanitizedCommentHTML = '' + newParentComments: VideoComment[] = [] + + commentAccount: Account + commentUser: User + + constructor ( + private markdownService: MarkdownService, + private authService: AuthService, + private userService: UserService, + private notifier: Notifier + ) {} + + get user () { + return this.authService.getUser() + } + + ngOnInit () { + this.init() + } + + ngOnChanges () { + this.init() + } + + onCommentReplyCreated (createdComment: VideoComment) { + if (!this.commentTree) { + this.commentTree = { + comment: this.comment, + children: [] + } + + this.threadCreated.emit(this.commentTree) + } + + this.commentTree.children.unshift({ + comment: createdComment, + children: [] + }) + this.resetReply.emit() + } + + onWantToReply (comment?: VideoComment) { + this.wantedToReply.emit(comment || this.comment) + } + + onWantToDelete (comment?: VideoComment) { + this.wantedToDelete.emit(comment || this.comment) + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + onResetReply () { + this.resetReply.emit() + } + + handleTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + + isRemovableByUser () { + return this.comment.account && this.isUserLoggedIn() && + ( + this.user.account.id === this.comment.account.id || + this.user.account.id === this.video.account.id || + this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) + ) + } + + switchToDefaultAvatar ($event: Event) { + ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() + } + + private getUserIfNeeded (account: Account) { + if (!account.userId) return + if (!this.authService.isLoggedIn()) return + + const user = this.authService.getUser() + if (user.hasRight(UserRight.MANAGE_USERS)) { + this.userService.getUserWithCache(account.userId) + .subscribe( + user => this.commentUser = user, + + err => this.notifier.error(err.message) + ) + } + } + + private async init () { + const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) + this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) + this.newParentComments = this.parentComments.concat([ this.comment ]) + + if (this.comment.account) { + this.commentAccount = new Account(this.comment.account) + this.getUserIfNeeded(this.commentAccount) + } else { + this.comment.account = null + } + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts new file mode 100644 index 000000000..e85443196 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts @@ -0,0 +1,48 @@ +import { getAbsoluteAPIUrl } from '@app/helpers' +import { Actor } from '@app/shared/shared-main' +import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models' + +export class VideoComment implements VideoCommentServerModel { + id: number + url: string + text: string + threadId: number + inReplyToCommentId: number + videoId: number + createdAt: Date | string + updatedAt: Date | string + deletedAt: Date | string + isDeleted: boolean + account: AccountInterface + totalRepliesFromVideoAuthor: number + totalReplies: number + by: string + accountAvatarUrl: string + + isLocal: boolean + + constructor (hash: VideoCommentServerModel) { + this.id = hash.id + this.url = hash.url + this.text = hash.text + this.threadId = hash.threadId + this.inReplyToCommentId = hash.inReplyToCommentId + this.videoId = hash.videoId + this.createdAt = new Date(hash.createdAt.toString()) + this.updatedAt = new Date(hash.updatedAt.toString()) + this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null + this.isDeleted = hash.isDeleted + this.account = hash.account + this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor + this.totalReplies = hash.totalReplies + + if (this.account) { + this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) + this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) + + const absoluteAPIUrl = getAbsoluteAPIUrl() + const thisHost = new URL(absoluteAPIUrl).host + this.isLocal = this.account.host.trim() === thisHost + } + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts new file mode 100644 index 000000000..a73fb9ca8 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts @@ -0,0 +1,149 @@ +import { Observable } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' +import { objectLineFeedToHtml } from '@app/helpers' +import { + FeedFormat, + ResultList, + VideoComment as VideoCommentServerModel, + VideoCommentCreate, + VideoCommentThreadTree as VideoCommentThreadTreeServerModel +} from '@shared/models' +import { environment } from '../../../../environments/environment' +import { VideoCommentThreadTree } from './video-comment-thread-tree.model' +import { VideoComment } from './video-comment.model' + +@Injectable() +export class VideoCommentService { + private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' + private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) {} + + addCommentThread (videoId: number | string, comment: VideoCommentCreate) { + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' + const normalizedComment = objectLineFeedToHtml(comment, 'text') + + return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) + .pipe( + map(data => this.extractVideoComment(data.comment)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) { + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId + const normalizedComment = objectLineFeedToHtml(comment, 'text') + + return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) + .pipe( + map(data => this.extractVideoComment(data.comment)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoCommentThreads (parameters: { + videoId: number | string, + componentPagination: ComponentPaginationLight, + sort: string + }): Observable> { + const { videoId, componentPagination, sort } = parameters + + const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' + return this.authHttp.get>(url, { params }) + .pipe( + map(result => this.extractVideoComments(result)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoThreadComments (parameters: { + videoId: number | string, + threadId: number + }): Observable { + const { videoId, threadId } = parameters + const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` + + return this.authHttp + .get(url) + .pipe( + map(tree => this.extractVideoCommentTree(tree)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + deleteVideoComment (videoId: number | string, commentId: number) { + const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` + + return this.authHttp + .delete(url) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoCommentsFeeds (videoUUID?: string) { + const feeds = [ + { + format: FeedFormat.RSS, + label: 'rss 2.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() + }, + { + format: FeedFormat.ATOM, + label: 'atom 1.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() + }, + { + format: FeedFormat.JSON, + label: 'json 1.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() + } + ] + + if (videoUUID !== undefined) { + for (const feed of feeds) { + feed.url += '?videoId=' + videoUUID + } + } + + return feeds + } + + private extractVideoComment (videoComment: VideoCommentServerModel) { + return new VideoComment(videoComment) + } + + private extractVideoComments (result: ResultList) { + const videoCommentsJson = result.data + const totalComments = result.total + const comments: VideoComment[] = [] + + for (const videoCommentJson of videoCommentsJson) { + comments.push(new VideoComment(videoCommentJson)) + } + + return { data: comments, total: totalComments } + } + + private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { + if (!tree) return tree as VideoCommentThreadTree + + tree.comment = new VideoComment(tree.comment) + tree.children.forEach(c => this.extractVideoCommentTree(c)) + + return tree as VideoCommentThreadTree + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/comment/video-comments.component.html new file mode 100644 index 000000000..dd1d43560 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.html @@ -0,0 +1,98 @@ +
+
+

+ + + 1 Comment + {{ componentPagination.totalItems }} Comments + + Comments +

+ + + +
+ +
+ + +
+
+
+ + + + +
No comments.
+ +
+
+
+ +
+ +
+ +
+ + + + + + View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }} and others + + + View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }} + + + View {{ comment.totalReplies }} replies + + +
+
+ +
+
+
+ +
+ Comments are disabled. +
+
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.scss b/client/src/app/+videos/+video-watch/comment/video-comments.component.scss new file mode 100644 index 000000000..df42fae73 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.scss @@ -0,0 +1,53 @@ +@import '_variables'; +@import '_mixins'; + +#highlighted-comment { + margin-bottom: 25px; +} + +.view-replies { + font-weight: $font-semibold; + font-size: 15px; + cursor: pointer; +} + +.glyphicon, .comment-thread-loading { + margin-right: 5px; + display: inline-block; + font-size: 13px; +} + +.title-block { + .title-page { + margin-right: 0; + } + + my-feed { + display: inline-block; + margin-left: 5px; + opacity: 0; + transition: ease-in .2s opacity; + } + &:hover my-feed { + opacity: 1; + } +} + +#dropdown-sort-comments { + font-weight: 600; + text-transform: uppercase; + border: none; + transform: translateY(-7%); +} + +@media screen and (max-width: 600px) { + .view-replies { + margin-left: 46px; + } +} + +@media screen and (max-width: 450px) { + .view-replies { + font-size: 14px; + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts new file mode 100644 index 000000000..df0018ec6 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts @@ -0,0 +1,232 @@ +import { Subject, Subscription } from 'rxjs' +import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { Syndication, VideoDetails } from '@app/shared/shared-main' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoCommentThreadTree } from './video-comment-thread-tree.model' +import { VideoComment } from './video-comment.model' +import { VideoCommentService } from './video-comment.service' + +@Component({ + selector: 'my-video-comments', + templateUrl: './video-comments.component.html', + styleUrls: ['./video-comments.component.scss'] +}) +export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { + @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef + @Input() video: VideoDetails + @Input() user: User + + @Output() timestampClicked = new EventEmitter() + + comments: VideoComment[] = [] + highlightedThread: VideoComment + sort = '-createdAt' + componentPagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 10, + totalItems: null + } + inReplyToCommentId: number + threadComments: { [ id: number ]: VideoCommentThreadTree } = {} + threadLoading: { [ id: number ]: boolean } = {} + + syndicationItems: Syndication[] = [] + + onDataSubject = new Subject() + + private sub: Subscription + + constructor ( + private authService: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private videoCommentService: VideoCommentService, + private activatedRoute: ActivatedRoute, + private i18n: I18n, + private hooks: HooksService + ) {} + + ngOnInit () { + // Find highlighted comment in params + this.sub = this.activatedRoute.params.subscribe( + params => { + if (params['threadId']) { + const highlightedThreadId = +params['threadId'] + this.processHighlightedThread(highlightedThreadId) + } + } + ) + } + + ngOnChanges (changes: SimpleChanges) { + if (changes['video']) { + this.resetVideo() + } + } + + ngOnDestroy () { + if (this.sub) this.sub.unsubscribe() + } + + viewReplies (commentId: number, highlightThread = false) { + this.threadLoading[commentId] = true + + const params = { + videoId: this.video.id, + threadId: commentId + } + + const obs = this.hooks.wrapObsFun( + this.videoCommentService.getVideoThreadComments.bind(this.videoCommentService), + params, + 'video-watch', + 'filter:api.video-watch.video-thread-replies.list.params', + 'filter:api.video-watch.video-thread-replies.list.result' + ) + + obs.subscribe( + res => { + this.threadComments[commentId] = res + this.threadLoading[commentId] = false + this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res }) + + if (highlightThread) { + this.highlightedThread = new VideoComment(res.comment) + + // Scroll to the highlighted thread + setTimeout(() => this.commentHighlightBlock.nativeElement.scrollIntoView(), 0) + } + }, + + err => this.notifier.error(err.message) + ) + } + + loadMoreThreads () { + const params = { + videoId: this.video.id, + componentPagination: this.componentPagination, + sort: this.sort + } + + const obs = this.hooks.wrapObsFun( + this.videoCommentService.getVideoCommentThreads.bind(this.videoCommentService), + params, + 'video-watch', + 'filter:api.video-watch.video-threads.list.params', + 'filter:api.video-watch.video-threads.list.result' + ) + + obs.subscribe( + res => { + this.comments = this.comments.concat(res.data) + this.componentPagination.totalItems = res.total + + this.onDataSubject.next(res.data) + this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination }) + }, + + err => this.notifier.error(err.message) + ) + } + + onCommentThreadCreated (comment: VideoComment) { + this.comments.unshift(comment) + } + + onWantedToReply (comment: VideoComment) { + this.inReplyToCommentId = comment.id + } + + onResetReply () { + this.inReplyToCommentId = undefined + } + + onThreadCreated (commentTree: VideoCommentThreadTree) { + this.viewReplies(commentTree.comment.id) + } + + handleSortChange (sort: string) { + if (this.sort === sort) return + + this.sort = sort + this.resetVideo() + } + + handleTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + + async onWantedToDelete (commentToDelete: VideoComment) { + let message = 'Do you really want to delete this comment?' + + if (commentToDelete.isLocal || this.video.isLocal) { + message += this.i18n(' The deletion will be sent to remote instances so they can reflect the change.') + } else { + message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.') + } + + const res = await this.confirmService.confirm(message, this.i18n('Delete')) + if (res === false) return + + this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id) + .subscribe( + () => { + if (this.highlightedThread?.id === commentToDelete.id) { + commentToDelete = this.comments.find(c => c.id === commentToDelete.id) + + this.highlightedThread = undefined + } + + // Mark the comment as deleted + this.softDeleteComment(commentToDelete) + }, + + err => this.notifier.error(err.message) + ) + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + onNearOfBottom () { + if (hasMoreItems(this.componentPagination)) { + this.componentPagination.currentPage++ + this.loadMoreThreads() + } + } + + private softDeleteComment (comment: VideoComment) { + comment.isDeleted = true + comment.deletedAt = new Date() + comment.text = '' + comment.account = null + } + + private resetVideo () { + if (this.video.commentsEnabled === true) { + // Reset all our fields + this.highlightedThread = null + this.comments = [] + this.threadComments = {} + this.threadLoading = {} + this.inReplyToCommentId = undefined + this.componentPagination.currentPage = 1 + this.componentPagination.totalItems = null + + this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid) + this.loadMoreThreads() + } + } + + private processHighlightedThread (highlightedThreadId: number) { + this.highlightedThread = this.comments.find(c => c.id === highlightedThreadId) + + const highlightThread = true + this.viewReplies(highlightedThreadId, highlightThread) + } +} diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.html b/client/src/app/+videos/+video-watch/modal/video-share.component.html new file mode 100644 index 000000000..5e6a2d518 --- /dev/null +++ b/client/src/app/+videos/+video-watch/modal/video-share.component.html @@ -0,0 +1,187 @@ + + + + + + + diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.scss b/client/src/app/+videos/+video-watch/modal/video-share.component.scss new file mode 100644 index 000000000..091d4dc3b --- /dev/null +++ b/client/src/app/+videos/+video-watch/modal/video-share.component.scss @@ -0,0 +1,79 @@ +@import '_mixins'; +@import '_variables'; + +my-input-readonly-copy { + width: 100%; +} + +.title-page.title-page-single { + margin-top: 0; +} + +.playlist { + margin-bottom: 50px; +} + +.peertube-select-container { + @include peertube-select-container(200px); +} + +.qr-code-group { + text-align: center; +} + +.nav-content { + margin-top: 30px; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.alert { + margin-top: 20px; +} + +.filters { + margin-top: 30px; + + .advanced-filters-button { + display: flex; + justify-content: center; + align-items: center; + margin-top: 20px; + font-size: 16px; + font-weight: $font-semibold; + cursor: pointer; + + .glyphicon { + margin-right: 5px; + } + } + + .form-group { + margin-bottom: 0; + height: 34px; + display: flex; + align-items: center; + } + + .video-caption-block { + display: flex; + align-items: center; + + .peertube-select-container { + margin-left: 10px; + } + } + + .start-at, + .stop-at { + width: 300px; + display: flex; + align-items: center; + + my-timestamp-input { + margin-left: 10px; + } + } +} diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.ts b/client/src/app/+videos/+video-watch/modal/video-share.component.ts new file mode 100644 index 000000000..b42b775c1 --- /dev/null +++ b/client/src/app/+videos/+video-watch/modal/video-share.component.ts @@ -0,0 +1,126 @@ +import { Component, ElementRef, Input, ViewChild } from '@angular/core' +import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { VideoCaption } from '@shared/models' +import { VideoDetails } from '@app/shared/shared-main' +import { VideoPlaylist } from '@app/shared/shared-video-playlist' + +type Customizations = { + startAtCheckbox: boolean + startAt: number + + stopAtCheckbox: boolean + stopAt: number + + subtitleCheckbox: boolean + subtitle: string + + loop: boolean + autoplay: boolean + muted: boolean + title: boolean + warningTitle: boolean + controls: boolean +} + +@Component({ + selector: 'my-video-share', + templateUrl: './video-share.component.html', + styleUrls: [ './video-share.component.scss' ] +}) +export class VideoShareComponent { + @ViewChild('modal', { static: true }) modal: ElementRef + + @Input() video: VideoDetails = null + @Input() videoCaptions: VideoCaption[] = [] + @Input() playlist: VideoPlaylist = null + + activeId: 'url' | 'qrcode' | 'embed' = 'url' + customizations: Customizations + isAdvancedCustomizationCollapsed = true + includeVideoInPlaylist = false + + constructor (private modalService: NgbModal) { } + + show (currentVideoTimestamp?: number) { + let subtitle: string + if (this.videoCaptions.length !== 0) { + subtitle = this.videoCaptions[0].language.id + } + + this.customizations = { + startAtCheckbox: false, + startAt: currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0, + + stopAtCheckbox: false, + stopAt: this.video.duration, + + subtitleCheckbox: false, + subtitle, + + loop: false, + autoplay: false, + muted: false, + + // Embed options + title: true, + warningTitle: true, + controls: true + } + + this.modalService.open(this.modal, { centered: true }) + } + + getVideoIframeCode () { + const options = this.getOptions(this.video.embedUrl) + + const embedUrl = buildVideoLink(options) + return buildVideoEmbed(embedUrl) + } + + getVideoUrl () { + const baseUrl = window.location.origin + '/videos/watch/' + this.video.uuid + const options = this.getOptions(baseUrl) + + return buildVideoLink(options) + } + + getPlaylistUrl () { + const base = window.location.origin + '/videos/watch/playlist/' + this.playlist.uuid + + if (!this.includeVideoInPlaylist) return base + + return base + '?videoId=' + this.video.uuid + } + + notSecure () { + return window.location.protocol === 'http:' + } + + isInEmbedTab () { + return this.activeId === 'embed' + } + + hasPlaylist () { + return !!this.playlist + } + + private getOptions (baseUrl?: string) { + return { + baseUrl, + + startTime: this.customizations.startAtCheckbox ? this.customizations.startAt : undefined, + stopTime: this.customizations.stopAtCheckbox ? this.customizations.stopAt : undefined, + + subtitle: this.customizations.subtitleCheckbox ? this.customizations.subtitle : undefined, + + loop: this.customizations.loop, + autoplay: this.customizations.autoplay, + muted: this.customizations.muted, + + title: this.customizations.title, + warningTitle: this.customizations.warningTitle, + controls: this.customizations.controls + } + } +} diff --git a/client/src/app/+videos/+video-watch/modal/video-support.component.html b/client/src/app/+videos/+video-watch/modal/video-support.component.html new file mode 100644 index 000000000..935656d23 --- /dev/null +++ b/client/src/app/+videos/+video-watch/modal/video-support.component.html @@ -0,0 +1,15 @@ + + + + + + + diff --git a/client/src/app/+videos/+video-watch/modal/video-support.component.scss b/client/src/app/+videos/+video-watch/modal/video-support.component.scss new file mode 100644 index 000000000..184e09027 --- /dev/null +++ b/client/src/app/+videos/+video-watch/modal/video-support.component.scss @@ -0,0 +1,3 @@ +.action-button-cancel { + margin-right: 0 !important; +} diff --git a/client/src/app/+videos/+video-watch/modal/video-support.component.ts b/client/src/app/+videos/+video-watch/modal/video-support.component.ts new file mode 100644 index 000000000..48d5f2948 --- /dev/null +++ b/client/src/app/+videos/+video-watch/modal/video-support.component.ts @@ -0,0 +1,29 @@ +import { Component, Input, ViewChild } from '@angular/core' +import { MarkdownService } from '@app/core' +import { VideoDetails } from '@app/shared/shared-main' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' + +@Component({ + selector: 'my-video-support', + templateUrl: './video-support.component.html', + styleUrls: [ './video-support.component.scss' ] +}) +export class VideoSupportComponent { + @Input() video: VideoDetails = null + + @ViewChild('modal', { static: true }) modal: NgbModal + + videoHTMLSupport = '' + + constructor ( + private markdownService: MarkdownService, + private modalService: NgbModal + ) { } + + show () { + this.modalService.open(this.modal, { centered: true }) + + this.markdownService.enhancedMarkdownToHTML(this.video.support) + .then(r => this.videoHTMLSupport = r) + } +} diff --git a/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts b/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts new file mode 100644 index 000000000..29fa268f4 --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts @@ -0,0 +1,81 @@ +import { Observable, of } from 'rxjs' +import { map, switchMap } from 'rxjs/operators' +import { Injectable } from '@angular/core' +import { ServerService, UserService } from '@app/core' +import { Video, VideoService } from '@app/shared/shared-main' +import { AdvancedSearch, SearchService } from '@app/shared/shared-search' +import { ServerConfig } from '@shared/models' +import { RecommendationInfo } from './recommendation-info.model' +import { RecommendationService } from './recommendations.service' + +/** + * Provides "recommendations" by providing the most recently uploaded videos. + */ +@Injectable() +export class RecentVideosRecommendationService implements RecommendationService { + readonly pageSize = 5 + + private config: ServerConfig + + constructor ( + private videos: VideoService, + private searchService: SearchService, + private userService: UserService, + private serverService: ServerService + ) { + this.config = this.serverService.getTmpConfig() + + this.serverService.getConfig() + .subscribe(config => this.config = config) + } + + getRecommendations (recommendation: RecommendationInfo): Observable { + return this.fetchPage(1, recommendation) + .pipe( + map(videos => { + const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid) + return otherVideos.slice(0, this.pageSize) + }) + ) + } + + private fetchPage (page: number, recommendation: RecommendationInfo): Observable { + const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 } + const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' }) + .pipe(map(v => v.data)) + + const tags = recommendation.tags + const searchIndexConfig = this.config.search.searchIndex + if ( + !tags || tags.length === 0 || + (searchIndexConfig.enabled === true && searchIndexConfig.disableLocalSearch === true) + ) { + return defaultSubscription + } + + return this.userService.getAnonymousOrLoggedUser() + .pipe( + map(user => { + return { + search: '', + componentPagination: pagination, + advancedSearch: new AdvancedSearch({ + tagsOneOf: recommendation.tags.join(','), + sort: '-createdAt', + searchTarget: 'local', + nsfw: user.nsfwPolicy + ? this.videos.nsfwPolicyToParam(user.nsfwPolicy) + : undefined + }) + } + }), + switchMap(params => this.searchService.searchVideos(params)), + map(v => v.data), + switchMap(videos => { + if (videos.length <= 1) return defaultSubscription + + return of(videos) + }) + ) + } +} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts b/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts new file mode 100644 index 000000000..0233563bb --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts @@ -0,0 +1,4 @@ +export interface RecommendationInfo { + uuid: string + tags?: string[] +} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts new file mode 100644 index 000000000..259afb196 --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts @@ -0,0 +1,34 @@ +import { InputSwitchModule } from 'primeng/inputswitch' +import { CommonModule } from '@angular/common' +import { NgModule } from '@angular/core' +import { SharedMainModule } from '@app/shared/shared-main' +import { SharedSearchModule } from '@app/shared/shared-search' +import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' +import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' +import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' +import { RecommendedVideosComponent } from './recommended-videos.component' +import { RecommendedVideosStore } from './recommended-videos.store' + +@NgModule({ + imports: [ + CommonModule, + InputSwitchModule, + + SharedMainModule, + SharedSearchModule, + SharedVideoPlaylistModule, + SharedVideoMiniatureModule + ], + declarations: [ + RecommendedVideosComponent + ], + exports: [ + RecommendedVideosComponent + ], + providers: [ + RecommendedVideosStore, + RecentVideosRecommendationService + ] +}) +export class RecommendationsModule { +} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts new file mode 100644 index 000000000..1d79d35f6 --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts @@ -0,0 +1,7 @@ +import { Observable } from 'rxjs' +import { Video } from '@app/shared/shared-main' +import { RecommendationInfo } from './recommendation-info.model' + +export interface RecommendationService { + getRecommendations (recommendation: RecommendationInfo): Observable +} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html new file mode 100644 index 000000000..0467cabf5 --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html @@ -0,0 +1,24 @@ +
+ +
+

+ Other videos +

+
+ AUTOPLAY + +
+
+ + + + + +
+
+
+
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss new file mode 100644 index 000000000..b278c9654 --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss @@ -0,0 +1,31 @@ +.title-page-container { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 25px; + flex-wrap: wrap-reverse; + + .title-page.active, .title-page.title-page-single { + margin-bottom: unset; + margin-right: .5rem !important; + } +} + +.title-page-autoplay { + display: flex; + width: max-content; + height: max-content; + align-items: center; + margin-left: auto; + + span { + margin-right: 0.3rem; + text-transform: uppercase; + font-size: 85%; + font-weight: 600; + } +} + +hr { + margin-top: 0; +} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts new file mode 100644 index 000000000..016975341 --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts @@ -0,0 +1,91 @@ +import { Observable } from 'rxjs' +import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' +import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core' +import { Video } from '@app/shared/shared-main' +import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' +import { VideoPlaylist } from '@app/shared/shared-video-playlist' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { RecommendationInfo } from './recommendation-info.model' +import { RecommendedVideosStore } from './recommended-videos.store' + +@Component({ + selector: 'my-recommended-videos', + templateUrl: './recommended-videos.component.html', + styleUrls: [ './recommended-videos.component.scss' ] +}) +export class RecommendedVideosComponent implements OnInit, OnChanges { + @Input() inputRecommendation: RecommendationInfo + @Input() playlist: VideoPlaylist + @Output() gotRecommendations = new EventEmitter() + + autoPlayNextVideo: boolean + autoPlayNextVideoTooltip: string + + displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: true + } + + userMiniature: User + + readonly hasVideos$: Observable + readonly videos$: Observable + + constructor ( + private userService: UserService, + private authService: AuthService, + private notifier: Notifier, + private i18n: I18n, + private store: RecommendedVideosStore, + private sessionStorageService: SessionStorageService + ) { + this.videos$ = this.store.recommendations$ + this.hasVideos$ = this.store.hasRecommendations$ + this.videos$.subscribe(videos => this.gotRecommendations.emit(videos)) + + if (this.authService.isLoggedIn()) { + this.autoPlayNextVideo = this.authService.getUser().autoPlayNextVideo + } else { + this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' || false + this.sessionStorageService.watch([User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO]).subscribe( + () => this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' + ) + } + + this.autoPlayNextVideoTooltip = this.i18n('When active, the next video is automatically played after the current one.') + } + + ngOnInit () { + this.userService.getAnonymousOrLoggedUser() + .subscribe(user => this.userMiniature = user) + } + + ngOnChanges () { + if (this.inputRecommendation) { + this.store.requestNewRecommendations(this.inputRecommendation) + } + } + + onVideoRemoved () { + this.store.requestNewRecommendations(this.inputRecommendation) + } + + switchAutoPlayNextVideo () { + this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString()) + + if (this.authService.isLoggedIn()) { + const details = { + autoPlayNextVideo: this.autoPlayNextVideo + } + + this.userService.updateMyProfile(details).subscribe( + () => { + this.authService.refreshUserInformation() + }, + err => this.notifier.error(err.message) + ) + } + } +} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts new file mode 100644 index 000000000..8c3fb6480 --- /dev/null +++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts @@ -0,0 +1,37 @@ +import { Observable, ReplaySubject } from 'rxjs' +import { map, shareReplay, switchMap, take } from 'rxjs/operators' +import { Inject, Injectable } from '@angular/core' +import { Video } from '@app/shared/shared-main' +import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' +import { RecommendationInfo } from './recommendation-info.model' +import { RecommendationService } from './recommendations.service' + +/** + * This store is intended to provide data for the RecommendedVideosComponent. + */ +@Injectable() +export class RecommendedVideosStore { + public readonly recommendations$: Observable + public readonly hasRecommendations$: Observable + private readonly requestsForLoad$$ = new ReplaySubject(1) + + constructor ( + @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService + ) { + this.recommendations$ = this.requestsForLoad$$.pipe( + switchMap(requestedRecommendation => { + return this.recommendations.getRecommendations(requestedRecommendation) + .pipe(take(1)) + }), + shareReplay() + ) + + this.hasRecommendations$ = this.recommendations$.pipe( + map(otherVideos => otherVideos.length > 0) + ) + } + + requestNewRecommendations (recommend: RecommendationInfo) { + this.requestsForLoad$$.next(recommend) + } +} diff --git a/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts b/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts new file mode 100644 index 000000000..45e023695 --- /dev/null +++ b/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts @@ -0,0 +1,39 @@ +import { Directive, EventEmitter, HostListener, Output } from '@angular/core' + +@Directive({ + selector: '[timestampRouteTransformer]' +}) +export class TimestampRouteTransformerDirective { + @Output() timestampClicked = new EventEmitter() + + @HostListener('click', ['$event']) + public onClick ($event: Event) { + const target = $event.target as HTMLLinkElement + + if (target.hasAttribute('href') !== true) return + + const ngxLink = document.createElement('a') + ngxLink.href = target.getAttribute('href') + + // we only care about reflective links + if (ngxLink.host !== window.location.host) return + + const ngxLinkParams = new URLSearchParams(ngxLink.search) + if (ngxLinkParams.has('start') !== true) return + + const separators = ['h', 'm', 's'] + const start = ngxLinkParams + .get('start') + .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator + .map(t => { + if (t.includes('h')) return parseInt(t, 10) * 3600 + if (t.includes('m')) return parseInt(t, 10) * 60 + return parseInt(t, 10) + }) + .reduce((acc, t) => acc + t) + + this.timestampClicked.emit(start) + + $event.preventDefault() + } +} diff --git a/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts b/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts new file mode 100644 index 000000000..4b6767415 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts @@ -0,0 +1,28 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Pipe({ + name: 'myVideoDurationFormatter' +}) +export class VideoDurationPipe implements PipeTransform { + + constructor (private i18n: I18n) { + + } + + transform (value: number): string { + const hours = Math.floor(value / 3600) + const minutes = Math.floor((value % 3600) / 60) + const seconds = value % 60 + + if (hours > 0) { + return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds }) + } + + if (minutes > 0) { + return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds }) + } + + return this.i18n('{{seconds}} sec', { seconds }) + } +} diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.html b/client/src/app/+videos/+video-watch/video-watch-playlist.component.html new file mode 100644 index 000000000..246ef83cf --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch-playlist.component.html @@ -0,0 +1,46 @@ +
+
+
+ {{ playlist.displayName }} + + Unlisted + Private + Public +
+ +
+
{{ playlist.ownerBy }}
+
+ {{ currentPlaylistPosition }}{{ playlistPagination.totalItems }} +
+
+ +
+ + + +
+
+ +
+ +
+
diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss b/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss new file mode 100644 index 000000000..0b0a2a899 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss @@ -0,0 +1,83 @@ +@import '_variables'; +@import '_mixins'; +@import '_bootstrap-variables'; +@import '_miniature'; + +.playlist { + min-width: 200px; + max-width: 470px; + height: 66vh; + background-color: pvar(--mainBackgroundColor); + overflow-y: auto; + border-bottom: 1px solid $separator-border-color; + + .playlist-info { + padding: 5px 30px; + background-color: #e4e4e4; + + .playlist-display-name { + font-size: 18px; + font-weight: $font-semibold; + margin-bottom: 5px; + } + + .playlist-by-index { + color: pvar(--greyForegroundColor); + display: flex; + + .playlist-by { + margin-right: 5px; + } + + .playlist-index span:first-child::after { + content: '/'; + margin: 0 3px; + } + } + + .playlist-controls { + display: flex; + margin: 10px 0; + + my-global-icon:not(:last-child) { + margin-right: .5rem; + } + + my-global-icon { + &:not(.active) { + opacity: .5 + } + + ::ng-deep { + cursor: pointer; + } + } + } + } + + my-video-playlist-element-miniature { + ::ng-deep { + .video { + .position { + margin-right: 0; + } + + .video-info { + .video-info-name { + font-size: 15px; + } + } + } + + my-video-thumbnail { + @include thumbnail-size-component(90px, 50px); + } + + .fake-thumbnail { + width: 90px; + height: 50px; + } + } + } +} + diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts new file mode 100644 index 000000000..2c21be643 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts @@ -0,0 +1,201 @@ +import { Component, Input } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core' +import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage' +import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models' + +@Component({ + selector: 'my-video-watch-playlist', + templateUrl: './video-watch-playlist.component.html', + styleUrls: [ './video-watch-playlist.component.scss' ] +}) +export class VideoWatchPlaylistComponent { + static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist' + static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist' + + @Input() video: VideoDetails + @Input() playlist: VideoPlaylist + + playlistElements: VideoPlaylistElement[] = [] + playlistPagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 30, + totalItems: null + } + + autoPlayNextVideoPlaylist: boolean + autoPlayNextVideoPlaylistSwitchText = '' + loopPlaylist: boolean + loopPlaylistSwitchText = '' + noPlaylistVideos = false + currentPlaylistPosition = 1 + + constructor ( + private userService: UserService, + private auth: AuthService, + private notifier: Notifier, + private i18n: I18n, + private videoPlaylist: VideoPlaylistService, + private localStorageService: LocalStorageService, + private sessionStorageService: SessionStorageService, + private router: Router + ) { + // defaults to true + this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() + ? this.auth.getUser().autoPlayNextVideoPlaylist + : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' + this.setAutoPlayNextVideoPlaylistSwitchText() + + // defaults to false + this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' + this.setLoopPlaylistSwitchText() + } + + onPlaylistVideosNearOfBottom () { + // Last page + if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return + + this.playlistPagination.currentPage += 1 + this.loadPlaylistElements(this.playlist,false) + } + + onElementRemoved (playlistElement: VideoPlaylistElement) { + this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id) + + this.playlistPagination.totalItems-- + } + + isPlaylistOwned () { + return this.playlist.isLocal === true && + this.auth.isLoggedIn() && + this.playlist.ownerAccount.name === this.auth.getUser().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 + } + + loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) { + this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination) + .subscribe(({ total, data }) => { + this.playlistElements = this.playlistElements.concat(data) + this.playlistPagination.totalItems = total + + const firstAvailableVideos = this.playlistElements.find(e => !!e.video) + if (!firstAvailableVideos) { + this.noPlaylistVideos = true + return + } + + this.updatePlaylistIndex(this.video) + + if (redirectToFirst) { + const extras = { + queryParams: { + start: firstAvailableVideos.startTimestamp, + stop: firstAvailableVideos.stopTimestamp, + videoId: firstAvailableVideos.video.uuid + }, + replaceUrl: true + } + this.router.navigate([], extras) + } + }) + } + + updatePlaylistIndex (video: VideoDetails) { + if (this.playlistElements.length === 0 || !video) return + + for (const playlistElement of this.playlistElements) { + if (playlistElement.video && playlistElement.video.id === video.id) { + this.currentPlaylistPosition = playlistElement.position + return + } + } + + // Load more videos to find our video + this.onPlaylistVideosNearOfBottom() + } + + findNextPlaylistVideo (position = this.currentPlaylistPosition): VideoPlaylistElement { + if (this.currentPlaylistPosition >= this.playlistPagination.totalItems) { + // we have reached the end of the playlist: either loop or stop + if (this.loopPlaylist) { + this.currentPlaylistPosition = position = 0 + } else { + return + } + } + + const next = this.playlistElements.find(e => e.position === position) + + if (!next || !next.video) { + return this.findNextPlaylistVideo(position + 1) + } + + return next + } + + navigateToNextPlaylistVideo () { + const next = this.findNextPlaylistVideo(this.currentPlaylistPosition + 1) + if (!next) return + const start = next.startTimestamp + const stop = next.stopTimestamp + this.router.navigate([],{ queryParams: { videoId: next.video.uuid, start, stop } }) + } + + switchAutoPlayNextVideoPlaylist () { + this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist + this.setAutoPlayNextVideoPlaylistSwitchText() + + peertubeLocalStorage.setItem( + VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, + this.autoPlayNextVideoPlaylist.toString() + ) + + if (this.auth.isLoggedIn()) { + const details = { + autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist + } + + this.userService.updateMyProfile(details).subscribe( + () => { + this.auth.refreshUserInformation() + }, + err => this.notifier.error(err.message) + ) + } + } + + switchLoopPlaylist () { + this.loopPlaylist = !this.loopPlaylist + this.setLoopPlaylistSwitchText() + + peertubeSessionStorage.setItem( + VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, + this.loopPlaylist.toString() + ) + } + + private setAutoPlayNextVideoPlaylistSwitchText () { + this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist + ? this.i18n('Stop autoplaying next video') + : this.i18n('Autoplay next video') + } + + private setLoopPlaylistSwitchText () { + this.loopPlaylistSwitchText = this.loopPlaylist + ? this.i18n('Stop looping playlist videos') + : this.i18n('Loop playlist videos') + } +} 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 new file mode 100644 index 000000000..d8fecb87d --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch-routing.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { MetaGuard } from '@ngx-meta/core' +import { VideoWatchComponent } from './video-watch.component' + +const videoWatchRoutes: Routes = [ + { + path: 'playlist/:playlistId', + component: VideoWatchComponent, + canActivate: [ MetaGuard ] + }, + { + path: ':videoId/comments/:commentId', + redirectTo: ':videoId' + }, + { + path: ':videoId', + component: VideoWatchComponent, + canActivate: [ MetaGuard ] + } +] + +@NgModule({ + imports: [ RouterModule.forChild(videoWatchRoutes) ], + exports: [ RouterModule ] +}) +export class VideoWatchRoutingModule {} diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html new file mode 100644 index 000000000..0447268f0 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch.component.html @@ -0,0 +1,277 @@ +
+ +
+
+ Sorry, but this video is not available because the remote instance is not responding. +
+ Please try again later. +
+ +
+ + +
+ +
+
+ The video is being imported, it will be available when the import is finished. +
+ +
+ The video is being transcoded, it may not work properly. +
+ +
+ This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. +
+ +
+
This video is blocked.
+ {{ video.blockedReason }} +
+
+ + +
+
+
+
+
+

{{ video.name }}

+ +
+ Published • {{ video.views | myNumberFormatter }} views +
+
+ +
+
+

{{ video.name }}

+
+ +
+
+ Published • {{ video.views | myNumberFormatter }} views +
+ +
+
+ + + + + + + + +
+ + +
+ +
+
+ + +
+ +
+
+
+ +
+
+
+
+ +
+ +
+
+
+ + + +
+ +
+ +
+
+ +
+ Show more + + +
+ +
+ Show less + +
+
+ +
+
+ Privacy + {{ video.privacy.label }} +
+ +
+ Origin instance + {{ video.originInstanceHost }} +
+ +
+ Originally published + {{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }} +
+ +
+ Category + {{ video.category.label }} + {{ video.category.label }} +
+ +
+ Licence + {{ video.licence.label }} + {{ video.licence.label }} +
+ +
+ Language + {{ video.language.label }} + {{ video.language.label }} +
+ +
+ Tags + {{ tag }} +
+ +
+ Duration + {{ video.duration | myVideoDurationFormatter }} +
+
+ + +
+ + +
+ +
+
+ + Friendly Reminder: + + the sharing system used for this video implies that some technical information about your system (such as a public IP address) can be sent to other peers. + + + More information +
+ +
+ OK +
+
+
+ + + + + diff --git a/client/src/app/+videos/+video-watch/video-watch.component.scss b/client/src/app/+videos/+video-watch/video-watch.component.scss new file mode 100644 index 000000000..2e083982e --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch.component.scss @@ -0,0 +1,607 @@ +@import '_variables'; +@import '_mixins'; +@import '_bootstrap-variables'; +@import '_miniature'; + +$player-factor: 1.7; // 16/9 +$video-info-margin-left: 44px; + +@function getPlayerHeight($width){ + @return calc(#{$width} / #{$player-factor}) +} + +@function getPlayerWidth($height){ + @return calc(#{$height} * #{$player-factor}) +} + +@mixin playlist-below-player { + width: 100% !important; + height: auto !important; + max-height: 300px !important; + max-width: initial; + border-bottom: 1px solid $separator-border-color !important; +} + +.root { + &.theater-enabled #video-wrapper { + flex-direction: column; + justify-content: center; + + #videojs-wrapper { + width: 100%; + } + + ::ng-deep .video-js { + $height: calc(100vh - #{$header-height} - #{$theater-bottom-space}); + + height: $height; + width: 100%; + max-width: initial; + } + + my-video-watch-playlist ::ng-deep .playlist { + @include playlist-below-player; + } + } +} + +.blocked-label { + font-weight: $font-semibold; +} + +#video-wrapper { + background-color: #000; + display: flex; + justify-content: center; + + #videojs-wrapper { + display: flex; + justify-content: center; + flex-grow: 1; + } + + .remote-server-down { + color: #fff; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + justify-content: center; + background-color: #141313; + width: 100%; + font-size: 24px; + height: 500px; + + @media screen and (max-width: 1000px) { + font-size: 20px; + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + } + + ::ng-deep .video-js { + width: 100%; + max-width: getPlayerWidth(66vh); + height: 66vh; + + // VideoJS create an inner video player + video { + outline: 0; + position: relative !important; + } + } + + @media screen and (max-width: 600px) { + .remote-server-down, + ::ng-deep .video-js { + width: 100vw; + height: getPlayerHeight(100vw) + } + } +} + +.alert { + text-align: center; + border-radius: 0; +} + +.flex-direction-column { + flex-direction: column; +} + +#video-not-found { + height: 300px; + line-height: 300px; + margin-top: 50px; + text-align: center; + font-weight: $font-semibold; + font-size: 15px; +} + +.video-bottom { + display: flex; + margin-top: 1.5rem; + + .video-info { + flex-grow: 1; + // Set min width for flex item + min-width: 1px; + max-width: 100%; + + .video-info-first-row { + display: flex; + + & > div:first-child { + flex-grow: 1; + } + + .video-info-name { + margin-right: 30px; + min-height: 40px; // Align with the action buttons + font-size: 27px; + font-weight: $font-semibold; + flex-grow: 1; + } + + .video-info-first-row-bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + width: 100%; + } + + .video-info-date-views { + align-self: start; + margin-bottom: 10px; + margin-right: 10px; + font-size: 1em; + } + + .video-info-channel { + font-weight: $font-semibold; + font-size: 15px; + + a { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + + &:hover { + opacity: 0.8; + } + + img { + @include avatar(18px); + + margin: -2px 5px 0 0; + } + } + + .video-info-channel-left { + flex-grow: 1; + + .video-info-channel-left-links { + display: flex; + flex-direction: column; + position: relative; + line-height: 1.37; + + a:nth-of-type(2) { + font-weight: 500; + font-size: 90%; + } + } + } + + my-subscribe-button { + margin-left: 5px; + } + } + + my-feed { + margin-left: 5px; + margin-top: 1px; + } + + .video-actions-rates { + margin: 0 0 10px 0; + align-items: start; + width: max-content; + margin-left: auto; + + .video-actions { + height: 40px; // Align with the title + display: flex; + align-items: center; + + .action-button:not(:first-child), + .action-dropdown, + my-video-actions-dropdown { + margin-left: 5px; + } + + ::ng-deep.action-button { + @include peertube-button; + @include button-with-icon(21px, 0, -1px); + @include apply-svg-color(pvar(--actionButtonColor)); + + font-size: 100%; + font-weight: $font-semibold; + display: inline-block; + padding: 0 10px 0 10px; + white-space: nowrap; + background-color: transparent !important; + color: pvar(--actionButtonColor); + text-transform: uppercase; + + &::after { + display: none; + } + + &:hover { + opacity: 0.9; + } + + &.action-button-like, + &.action-button-dislike { + filter: brightness(120%); + + .count { + margin-right: 5px; + } + } + + &.action-button-like.activated { + .count { + color: pvar(--activatedActionButtonColor); + } + + my-global-icon { + @include apply-svg-color(pvar(--activatedActionButtonColor)); + } + } + + &.action-button-dislike.activated { + .count { + color: pvar(--activatedActionButtonColor); + } + + my-global-icon { + @include apply-svg-color(pvar(--activatedActionButtonColor)); + } + } + + &.action-button-support { + color: pvar(--supportButtonColor); + + my-global-icon { + @include apply-svg-color(pvar(--supportButtonColor)); + } + } + + &.action-button-support { + my-global-icon { + ::ng-deep path:first-child { + fill: pvar(--supportButtonHeartColor) !important; + } + } + } + + &.action-button-save { + my-global-icon { + top: 0 !important; + right: -1px; + } + } + + .icon-text { + margin-left: 3px; + } + } + } + + .video-info-likes-dislikes-bar-outer-container { + position: relative; + } + + .video-info-likes-dislikes-bar-inner-container { + position: absolute; + height: 20px; + } + + .video-info-likes-dislikes-bar { + $likes-bar-height: 2px; + height: $likes-bar-height; + margin-top: -$likes-bar-height; + width: 120px; + background-color: #ccc; + position: relative; + top: 10px; + + .likes-bar { + height: 100%; + background-color: #909090; + + &.liked { + background-color: pvar(--activatedActionButtonColor); + } + } + } + } + } + + .video-info-description { + margin: 20px 0; + margin-left: $video-info-margin-left; + font-size: 15px; + + .video-info-description-html { + @include peertube-word-wrap; + + /deep/ a { + text-decoration: none; + } + } + + .glyphicon, .description-loading { + margin-left: 3px; + } + + .description-loading { + display: inline-block; + } + + .video-info-description-more { + cursor: pointer; + font-weight: $font-semibold; + color: pvar(--greyForegroundColor); + font-size: 14px; + + .glyphicon { + position: relative; + top: 2px; + } + } + } + + .video-attributes { + margin-left: $video-info-margin-left; + } + + .video-attributes .video-attribute { + font-size: 13px; + display: block; + margin-bottom: 12px; + + .video-attribute-label { + min-width: 142px; + padding-right: 5px; + display: inline-block; + color: pvar(--greyForegroundColor); + font-weight: $font-bold; + } + + a.video-attribute-value { + @include disable-default-a-behaviour; + color: pvar(--mainForegroundColor); + + &:hover { + opacity: 0.9; + } + } + + &.video-attribute-tags { + .video-attribute-value:not(:nth-child(2)) { + &::before { + content: ', ' + } + } + } + } + } + + ::ng-deep .other-videos { + padding-left: 15px; + min-width: $video-miniature-width; + + @media screen and (min-width: 1800px - (3* $video-miniature-width)) { + width: min-content; + } + + .title-page { + margin: 0 !important; + } + + .video-miniature { + display: flex; + width: max-content; + height: 100%; + padding-bottom: 20px; + flex-wrap: wrap; + } + + .video-bottom { + @media screen and (max-width: 1800px - (3* $video-miniature-width)) { + margin-left: 1rem; + } + @media screen and (max-width: 500px) { + margin-left: 0; + margin-top: .5rem; + } + } + } +} + +my-video-comments { + display: inline-block; + width: 100%; + margin-bottom: 20px; +} + +// If the view is not expanded, take into account the menu +.privacy-concerns { + z-index: z(dropdown) + 1; + width: calc(100% - #{$menu-width}); +} + +@media screen and (max-width: $small-view) { + .privacy-concerns { + margin-left: $menu-width - 15px; // Menu is absolute + } +} + +:host-context(.expanded) { + .privacy-concerns { + width: 100%; + margin-left: -15px; + } +} + +.privacy-concerns { + position: fixed; + bottom: 0; + z-index: z(privacymsg); + + padding: 5px 15px; + + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + background-color: rgba(0, 0, 0, 0.9); + color: #fff; + + .privacy-concerns-text { + margin: 0 5px; + } + + a { + @include disable-default-a-behaviour; + + color: pvar(--mainColor); + transition: color 0.3s; + + &:hover { + color: #fff; + } + } + + .privacy-concerns-button { + padding: 5px 8px 5px 7px; + margin-left: auto; + border-radius: 3px; + white-space: nowrap; + cursor: pointer; + transition: background-color 0.3s; + font-weight: $font-semibold; + + &:hover { + background-color: #000; + } + } + + .privacy-concerns-okay { + background-color: pvar(--mainColor); + margin-left: 10px; + } +} + +@media screen and (max-width: 1600px) { + .video-bottom .video-info .video-attributes .video-attribute { + margin-bottom: 5px; + } +} + +@media screen and (max-width: 1300px) { + .privacy-concerns { + font-size: 12px; + padding: 2px 5px; + + .privacy-concerns-text { + margin: 0; + } + } +} + +@media screen and (max-width: 1100px) { + #video-wrapper { + flex-direction: column; + justify-content: center; + + my-video-watch-playlist ::ng-deep .playlist { + @include playlist-below-player; + } + } + + .video-bottom { + flex-direction: column; + + ::ng-deep .other-videos { + padding-left: 0 !important; + + ::ng-deep .video-miniature { + flex-direction: row; + width: auto; + } + } + } +} + +@media screen and (max-width: 600px) { + .video-bottom { + margin-top: 20px !important; + padding-bottom: 20px !important; + + .video-info { + padding: 0; + + .video-info-first-row { + + .video-info-name { + font-size: 20px; + height: auto; + } + } + } + } + + ::ng-deep .other-videos .video-miniature { + flex-direction: column; + } + + .privacy-concerns { + width: 100%; + + strong { + display: none; + } + } +} + +@media screen and (max-width: 450px) { + .video-bottom { + .action-button .icon-text { + display: none !important; + } + + .video-info .video-info-first-row { + .video-info-name { + font-size: 18px; + } + + .video-info-date-views { + font-size: 14px; + } + + .video-actions-rates { + margin-top: 10px; + } + } + + .video-info-description { + font-size: 14px !important; + } + } +} diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts new file mode 100644 index 000000000..5b0b34c80 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -0,0 +1,782 @@ +import { Hotkey, HotkeysService } from 'angular2-hotkeys' +import { forkJoin, Observable, Subscription } from 'rxjs' +import { catchError } from 'rxjs/operators' +import { PlatformLocation } from '@angular/common' +import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { RedirectService } from '@app/core/routing/redirect.service' +import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers' +import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' +import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' +import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' +import { MetaService } from '@ngx-meta/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models' +import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage' +import { + CustomizationOptions, + P2PMediaLoaderOptions, + PeertubePlayerManager, + PeertubePlayerManagerOptions, + PlayerMode, + videojs +} from '../../../assets/player/peertube-player-manager' +import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils' +import { environment } from '../../../environments/environment' +import { VideoShareComponent } from './modal/video-share.component' +import { VideoSupportComponent } from './modal/video-support.component' +import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' + +@Component({ + selector: 'my-video-watch', + templateUrl: './video-watch.component.html', + styleUrls: [ './video-watch.component.scss' ] +}) +export class VideoWatchComponent implements OnInit, OnDestroy { + private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' + + @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent + @ViewChild('videoShareModal') videoShareModal: VideoShareComponent + @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent + @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent + + player: any + playerElement: HTMLVideoElement + theaterEnabled = false + userRating: UserVideoRateType = null + descriptionLoading = false + + video: VideoDetails = null + videoCaptions: VideoCaption[] = [] + + playlist: VideoPlaylist = null + + completeDescriptionShown = false + completeVideoDescription: string + shortVideoDescription: string + videoHTMLDescription = '' + likesBarTooltipText = '' + hasAlreadyAcceptedPrivacyConcern = false + remoteServerDown = false + hotkeys: Hotkey[] = [] + + tooltipLike = '' + tooltipDislike = '' + tooltipSupport = '' + tooltipSaveToPlaylist = '' + + private nextVideoUuid = '' + private nextVideoTitle = '' + private currentTime: number + private paramsSub: Subscription + private queryParamsSub: Subscription + private configSub: Subscription + + private serverConfig: ServerConfig + + constructor ( + private elementRef: ElementRef, + private changeDetector: ChangeDetectorRef, + private route: ActivatedRoute, + private router: Router, + private videoService: VideoService, + private playlistService: VideoPlaylistService, + private confirmService: ConfirmService, + private metaService: MetaService, + private authService: AuthService, + private userService: UserService, + private serverService: ServerService, + private restExtractor: RestExtractor, + private notifier: Notifier, + private markdownService: MarkdownService, + private zone: NgZone, + private redirectService: RedirectService, + private videoCaptionService: VideoCaptionService, + private i18n: I18n, + private hotkeysService: HotkeysService, + private hooks: HooksService, + private location: PlatformLocation, + @Inject(LOCALE_ID) private localeId: string + ) { + this.tooltipLike = this.i18n('Like this video') + this.tooltipDislike = this.i18n('Dislike this video') + this.tooltipSupport = this.i18n('Support options for this video') + this.tooltipSaveToPlaylist = this.i18n('Save to playlist') + } + + get user () { + return this.authService.getUser() + } + + get anonymousUser () { + return this.userService.getAnonymousUser() + } + + async ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + + this.configSub = this.serverService.getConfig() + .subscribe(config => { + this.serverConfig = config + + if ( + isWebRTCDisabled() || + this.serverConfig.tracker.enabled === false || + getStoredP2PEnabled() === false || + peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true' + ) { + this.hasAlreadyAcceptedPrivacyConcern = true + } + }) + + this.paramsSub = this.route.params.subscribe(routeParams => { + const videoId = routeParams[ 'videoId' ] + if (videoId) this.loadVideo(videoId) + + const playlistId = routeParams[ 'playlistId' ] + if (playlistId) this.loadPlaylist(playlistId) + }) + + this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => { + const videoId = queryParams[ 'videoId' ] + if (videoId) this.loadVideo(videoId) + + const start = queryParams[ 'start' ] + if (this.player && start) this.player.currentTime(parseInt(start, 10)) + }) + + this.initHotkeys() + + this.theaterEnabled = getStoredTheater() + + this.hooks.runAction('action:video-watch.init', 'video-watch') + } + + ngOnDestroy () { + this.flushPlayer() + + // Unsubscribe subscriptions + if (this.paramsSub) this.paramsSub.unsubscribe() + if (this.queryParamsSub) this.queryParamsSub.unsubscribe() + + // Unbind hotkeys + this.hotkeysService.remove(this.hotkeys) + } + + setLike () { + if (this.isUserLoggedIn() === false) return + + // Already liked this video + if (this.userRating === 'like') this.setRating('none') + else this.setRating('like') + } + + setDislike () { + if (this.isUserLoggedIn() === false) return + + // Already disliked this video + if (this.userRating === 'dislike') this.setRating('none') + else this.setRating('dislike') + } + + getRatePopoverText () { + if (this.isUserLoggedIn()) return undefined + + return this.i18n('You need to be connected to rate this content.') + } + + showMoreDescription () { + if (this.completeVideoDescription === undefined) { + return this.loadCompleteDescription() + } + + this.updateVideoDescription(this.completeVideoDescription) + this.completeDescriptionShown = true + } + + showLessDescription () { + this.updateVideoDescription(this.shortVideoDescription) + this.completeDescriptionShown = false + } + + loadCompleteDescription () { + this.descriptionLoading = true + + this.videoService.loadCompleteDescription(this.video.descriptionPath) + .subscribe( + description => { + this.completeDescriptionShown = true + this.descriptionLoading = false + + this.shortVideoDescription = this.video.description + this.completeVideoDescription = description + + this.updateVideoDescription(this.completeVideoDescription) + }, + + error => { + this.descriptionLoading = false + this.notifier.error(error.message) + } + ) + } + + showSupportModal () { + this.pausePlayer() + + this.videoSupportModal.show() + } + + showShareModal () { + this.pausePlayer() + + this.videoShareModal.show(this.currentTime) + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + getVideoTags () { + if (!this.video || Array.isArray(this.video.tags) === false) return [] + + return this.video.tags + } + + onRecommendations (videos: Video[]) { + if (videos.length > 0) { + // The recommended videos's first element should be the next video + const video = videos[0] + this.nextVideoUuid = video.uuid + this.nextVideoTitle = video.name + } + } + + onModalOpened () { + this.pausePlayer() + } + + onVideoRemoved () { + this.redirectService.redirectToHomepage() + } + + declinedPrivacyConcern () { + peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false') + this.hasAlreadyAcceptedPrivacyConcern = false + } + + acceptedPrivacyConcern () { + peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true') + this.hasAlreadyAcceptedPrivacyConcern = true + } + + isVideoToTranscode () { + return this.video && this.video.state.id === VideoState.TO_TRANSCODE + } + + isVideoToImport () { + return this.video && this.video.state.id === VideoState.TO_IMPORT + } + + hasVideoScheduledPublication () { + return this.video && this.video.scheduledUpdate !== undefined + } + + isVideoBlur (video: Video) { + return video.isVideoNSFWForUser(this.user, this.serverConfig) + } + + isAutoPlayEnabled () { + return ( + (this.user && this.user.autoPlayNextVideo) || + this.anonymousUser.autoPlayNextVideo + ) + } + + handleTimestampClicked (timestamp: number) { + if (this.player) this.player.currentTime(timestamp) + scrollToTop() + } + + isPlaylistAutoPlayEnabled () { + return ( + (this.user && this.user.autoPlayNextVideoPlaylist) || + this.anonymousUser.autoPlayNextVideoPlaylist + ) + } + + private loadVideo (videoId: string) { + // Video did not change + if (this.video && this.video.uuid === videoId) return + + if (this.player) this.player.pause() + + const videoObs = this.hooks.wrapObsFun( + this.videoService.getVideo.bind(this.videoService), + { videoId }, + 'video-watch', + 'filter:api.video-watch.video.get.params', + 'filter:api.video-watch.video.get.result' + ) + + // Video did change + forkJoin([ + videoObs, + this.videoCaptionService.listCaptions(videoId) + ]) + .pipe( + // If 401, the video is private or blocked so redirect to 404 + catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) + ) + .subscribe(([ video, captionsResult ]) => { + const queryParams = this.route.snapshot.queryParams + + const urlOptions = { + startTime: queryParams.start, + stopTime: queryParams.stop, + + muted: queryParams.muted, + loop: queryParams.loop, + subtitle: queryParams.subtitle, + + playerMode: queryParams.mode, + peertubeLink: false + } + + this.onVideoFetched(video, captionsResult.data, urlOptions) + .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 blocked 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.videoWatchPlaylist.loadPlaylistElements(playlist, !videoId) + }) + } + + private updateVideoDescription (description: string) { + this.video.description = description + this.setVideoDescriptionHTML() + .catch(err => console.error(err)) + } + + private async setVideoDescriptionHTML () { + const html = await this.markdownService.textMarkdownToHTML(this.video.description) + this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html) + } + + private setVideoLikesBarTooltipText () { + this.likesBarTooltipText = this.i18n('{{likesNumber}} likes / {{dislikesNumber}} dislikes', { + likesNumber: this.video.likes, + dislikesNumber: this.video.dislikes + }) + } + + private handleError (err: any) { + const errorMessage: string = typeof err === 'string' ? err : err.message + if (!errorMessage) return + + // Display a message in the video player instead of a notification + if (errorMessage.indexOf('from xs param') !== -1) { + this.flushPlayer() + this.remoteServerDown = true + this.changeDetector.detectChanges() + + return + } + + this.notifier.error(errorMessage) + } + + private checkUserRating () { + // Unlogged users do not have ratings + if (this.isUserLoggedIn() === false) return + + this.videoService.getUserVideoRating(this.video.id) + .subscribe( + ratingObject => { + if (ratingObject) { + this.userRating = ratingObject.rating + } + }, + + err => this.notifier.error(err.message) + ) + } + + private async onVideoFetched ( + video: VideoDetails, + videoCaptions: VideoCaption[], + urlOptions: CustomizationOptions & { playerMode: PlayerMode } + ) { + this.video = video + this.videoCaptions = videoCaptions + + // Re init attributes + this.descriptionLoading = false + this.completeDescriptionShown = false + this.remoteServerDown = false + this.currentTime = undefined + + this.videoWatchPlaylist.updatePlaylistIndex(video) + + 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') + ) + if (res === false) return this.location.back() + } + + // Flush old player if needed + this.flushPlayer() + + // Build video element, because videojs removes it on dispose + 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') + playerElementWrapper.appendChild(this.playerElement) + + const params = { + video: this.video, + videoCaptions, + urlOptions, + user: this.user + } + const { playerMode, playerOptions } = await this.hooks.wrapFun( + this.buildPlayerManagerOptions.bind(this), + params, + 'video-watch', + 'filter:internal.video-watch.player.build-options.params', + 'filter:internal.video-watch.player.build-options.result' + ) + + this.zone.runOutsideAngular(async () => { + this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) + this.player.focus() + + this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) + + this.player.on('timeupdate', () => { + this.currentTime = Math.floor(this.player.currentTime()) + }) + + /** + * replaces this.player.one('ended') + * 'condition()': true to make the upnext functionality trigger, + * false to disable the upnext functionality + * go to the next video in 'condition()' if you don't want of the timer. + * 'next': function triggered at the end of the timer. + * 'suspended': function used at each clic of the timer checking if we need + * to reset progress and wait until 'suspended' becomes truthy again. + */ + this.player.upnext({ + timeout: 10000, // 10s + headText: this.i18n('Up Next'), + cancelText: this.i18n('Cancel'), + suspendedText: this.i18n('Autoplay is suspended'), + getTitle: () => this.nextVideoTitle, + next: () => this.zone.run(() => this.autoplayNext()), + condition: () => { + if (this.playlist) { + if (this.isPlaylistAutoPlayEnabled()) { + // upnext will not trigger, and instead the next video will play immediately + this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) + } + } else if (this.isAutoPlayEnabled()) { + return true // upnext will trigger + } + return false // upnext will not trigger, and instead leave the video stopping + }, + suspended: () => { + return ( + !isXPercentInViewport(this.player.el(), 80) || + !document.getElementById('content').contains(document.activeElement) + ) + } + }) + + this.player.one('stopped', () => { + if (this.playlist) { + if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) + } + }) + + this.player.on('theaterChange', (_: any, enabled: boolean) => { + this.zone.run(() => this.theaterEnabled = enabled) + }) + + this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { player: this.player }) + }) + + this.setVideoDescriptionHTML() + this.setVideoLikesBarTooltipText() + + this.setOpenGraphTags() + this.checkUserRating() + + this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', { videojs }) + } + + private autoplayNext () { + if (this.playlist) { + this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) + } else if (this.nextVideoUuid) { + this.router.navigate([ '/videos/watch', this.nextVideoUuid ]) + } + } + + private setRating (nextRating: UserVideoRateType) { + const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable } = { + like: this.videoService.setVideoLike, + dislike: this.videoService.setVideoDislike, + none: this.videoService.unsetVideoLike + } + + ratingMethods[nextRating].call(this.videoService, this.video.id) + .subscribe( + () => { + // Update the video like attribute + this.updateVideoRating(this.userRating, nextRating) + this.userRating = nextRating + }, + + (err: { message: string }) => this.notifier.error(err.message) + ) + } + + private updateVideoRating (oldRating: UserVideoRateType, newRating: UserVideoRateType) { + let likesToIncrement = 0 + let dislikesToIncrement = 0 + + if (oldRating) { + if (oldRating === 'like') likesToIncrement-- + if (oldRating === 'dislike') dislikesToIncrement-- + } + + if (newRating === 'like') likesToIncrement++ + if (newRating === 'dislike') dislikesToIncrement++ + + this.video.likes += likesToIncrement + this.video.dislikes += dislikesToIncrement + + this.video.buildLikeAndDislikePercents() + this.setVideoLikesBarTooltipText() + } + + private setOpenGraphTags () { + this.metaService.setTitle(this.video.name) + + this.metaService.setTag('og:type', 'video') + + this.metaService.setTag('og:title', this.video.name) + this.metaService.setTag('name', this.video.name) + + this.metaService.setTag('og:description', this.video.description) + this.metaService.setTag('description', this.video.description) + + this.metaService.setTag('og:image', this.video.previewPath) + + this.metaService.setTag('og:duration', this.video.duration.toString()) + + this.metaService.setTag('og:site_name', 'PeerTube') + + this.metaService.setTag('og:url', window.location.href) + this.metaService.setTag('url', window.location.href) + } + + private isAutoplay () { + // We'll jump to the thread id, so do not play the video + if (this.route.snapshot.params['threadId']) return false + + // Otherwise true by default + if (!this.user) return true + + // Be sure the autoPlay is set to false + return this.user.autoPlayVideo !== false + } + + private flushPlayer () { + // Remove player if it exists + if (this.player) { + try { + this.player.dispose() + this.player = undefined + } catch (err) { + console.error('Cannot dispose player.', err) + } + } + } + + private buildPlayerManagerOptions (params: { + video: VideoDetails, + videoCaptions: VideoCaption[], + urlOptions: CustomizationOptions & { playerMode: PlayerMode }, + user?: AuthUser + }) { + const { video, videoCaptions, urlOptions, user } = params + const getStartTime = () => { + const byUrl = urlOptions.startTime !== undefined + const byHistory = video.userHistory && (!this.playlist || urlOptions.resume !== undefined) + + if (byUrl) { + return timeToInt(urlOptions.startTime) + } else if (byHistory) { + return video.userHistory.currentTime + } else { + return 0 + } + } + + let startTime = getStartTime() + // If we are at the end of the video, reset the timer + if (video.duration - startTime <= 1) startTime = 0 + + const playerCaptions = videoCaptions.map(c => ({ + label: c.language.label, + language: c.language.id, + src: environment.apiUrl + c.captionPath + })) + + const options: PeertubePlayerManagerOptions = { + common: { + autoplay: this.isAutoplay(), + nextVideo: () => this.zone.run(() => this.autoplayNext()), + + playerElement: this.playerElement, + onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element, + + videoDuration: video.duration, + enableHotkeys: true, + inactivityTimeout: 2500, + poster: video.previewUrl, + + startTime, + stopTime: urlOptions.stopTime, + controls: urlOptions.controls, + muted: urlOptions.muted, + loop: urlOptions.loop, + subtitle: urlOptions.subtitle, + + peertubeLink: urlOptions.peertubeLink, + + theaterButton: true, + captions: videoCaptions.length !== 0, + + videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE + ? this.videoService.getVideoViewUrl(video.uuid) + : null, + embedUrl: video.embedUrl, + + language: this.localeId, + + userWatching: user && user.videosHistoryEnabled === true ? { + url: this.videoService.getUserWatchingVideoUrl(video.uuid), + authorizationHeader: this.authService.getRequestHeaderValue() + } : undefined, + + serverUrl: environment.apiUrl, + + videoCaptions: playerCaptions + }, + + webtorrent: { + videoFiles: video.files + } + } + + let mode: PlayerMode + + if (urlOptions.playerMode) { + if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' + else mode = 'webtorrent' + } else { + if (video.hasHlsPlaylist()) mode = 'p2p-media-loader' + else mode = 'webtorrent' + } + + // p2p-media-loader needs TextEncoder, try to fallback on WebTorrent + if (typeof TextEncoder === 'undefined') { + mode = 'webtorrent' + } + + if (mode === 'p2p-media-loader') { + const hlsPlaylist = video.getHlsPlaylist() + + const p2pMediaLoader = { + playlistUrl: hlsPlaylist.playlistUrl, + segmentsSha256Url: hlsPlaylist.segmentsSha256Url, + redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), + trackerAnnounce: video.trackerUrls, + videoFiles: hlsPlaylist.files + } as P2PMediaLoaderOptions + + Object.assign(options, { p2pMediaLoader }) + } + + return { playerMode: mode, playerOptions: options } + } + + private pausePlayer () { + if (!this.player) return + + this.player.pause() + } + + private initHotkeys () { + this.hotkeys = [ + // These hotkeys are managed by the player + new Hotkey('f', e => e, undefined, this.i18n('Enter/exit fullscreen (requires player focus)')), + new Hotkey('space', e => e, undefined, this.i18n('Play/Pause the video (requires player focus)')), + new Hotkey('m', e => e, undefined, this.i18n('Mute/unmute the video (requires player focus)')), + + new Hotkey('0-9', e => e, undefined, this.i18n('Skip to a percentage of the video: 0 is 0% and 9 is 90% (requires player focus)')), + + new Hotkey('up', e => e, undefined, this.i18n('Increase the volume (requires player focus)')), + new Hotkey('down', e => e, undefined, this.i18n('Decrease the volume (requires player focus)')), + + new Hotkey('right', e => e, undefined, this.i18n('Seek the video forward (requires player focus)')), + new Hotkey('left', e => e, undefined, this.i18n('Seek the video backward (requires player focus)')), + + new Hotkey('>', e => e, undefined, this.i18n('Increase playback rate (requires player focus)')), + new Hotkey('<', e => e, undefined, this.i18n('Decrease playback rate (requires player focus)')), + + new Hotkey('.', e => e, undefined, this.i18n('Navigate in the video frame by frame (requires player focus)')) + ] + + if (this.isUserLoggedIn()) { + this.hotkeys = this.hotkeys.concat([ + new Hotkey('shift+l', () => { + this.setLike() + return false + }, undefined, this.i18n('Like the video')), + + new Hotkey('shift+d', () => { + this.setDislike() + return false + }, undefined, this.i18n('Dislike the video')), + + new Hotkey('shift+s', () => { + this.subscribeButton.subscribed ? this.subscribeButton.unsubscribe() : this.subscribeButton.subscribe() + return false + }, undefined, this.i18n('Subscribe to the account')) + ]) + } + + this.hotkeysService.add(this.hotkeys) + } +} diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts new file mode 100644 index 000000000..421170d81 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch.module.ts @@ -0,0 +1,65 @@ +import { QRCodeModule } from 'angularx-qrcode' +import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' +import { SharedGlobalIconModule } from '@app/shared/shared-icons' +import { SharedMainModule } from '@app/shared/shared-main' +import { SharedModerationModule } from '@app/shared/shared-moderation' +import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' +import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' +import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' +import { RecommendationsModule } from './recommendations/recommendations.module' +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' +import { VideoCommentAddComponent } from './comment/video-comment-add.component' +import { VideoCommentComponent } from './comment/video-comment.component' +import { VideoCommentService } from './comment/video-comment.service' +import { VideoCommentsComponent } from './comment/video-comments.component' +import { VideoShareComponent } from './modal/video-share.component' +import { VideoSupportComponent } from './modal/video-support.component' +import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' +import { VideoDurationPipe } from './video-duration-formatter.pipe' +import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' +import { VideoWatchRoutingModule } from './video-watch-routing.module' +import { VideoWatchComponent } from './video-watch.component' + +@NgModule({ + imports: [ + VideoWatchRoutingModule, + NgbTooltipModule, + QRCodeModule, + RecommendationsModule, + + SharedMainModule, + SharedFormModule, + SharedVideoMiniatureModule, + SharedVideoPlaylistModule, + SharedUserSubscriptionModule, + SharedModerationModule, + SharedGlobalIconModule + ], + + declarations: [ + VideoWatchComponent, + VideoWatchPlaylistComponent, + + VideoShareComponent, + VideoSupportComponent, + VideoCommentsComponent, + VideoCommentAddComponent, + VideoCommentComponent, + + TimestampRouteTransformerDirective, + VideoDurationPipe, + TimestampRouteTransformerDirective + ], + + exports: [ + VideoWatchComponent, + + TimestampRouteTransformerDirective + ], + + providers: [ + VideoCommentService + ] +}) +export class VideoWatchModule { } -- cgit v1.2.3