diff options
Diffstat (limited to 'client/src/app/videos/+video-watch')
28 files changed, 0 insertions, 3883 deletions
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 deleted file mode 100644 index 9b43d91da..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html +++ /dev/null | |||
@@ -1,56 +0,0 @@ | |||
1 | <form novalidate [formGroup]="form" (ngSubmit)="formValidated()"> | ||
2 | <div class="avatar-and-textarea"> | ||
3 | <img [src]="getAvatarUrl()" alt="Avatar" /> | ||
4 | |||
5 | <div class="form-group"> | ||
6 | <textarea i18n-placeholder placeholder="Add comment..." myAutoResize | ||
7 | [readonly]="(user === null) ? true : false" | ||
8 | (click)="openVisitorModal($event)" | ||
9 | formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }" | ||
10 | (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()" #textarea> | ||
11 | |||
12 | </textarea> | ||
13 | <div *ngIf="formErrors.text" class="form-error"> | ||
14 | {{ formErrors.text }} | ||
15 | </div> | ||
16 | </div> | ||
17 | </div> | ||
18 | |||
19 | <div class="comment-buttons"> | ||
20 | <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" type="button" i18n> | ||
21 | Cancel | ||
22 | </button> | ||
23 | <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n> | ||
24 | Reply | ||
25 | </button> | ||
26 | </div> | ||
27 | </form> | ||
28 | |||
29 | <ng-template #visitorModal let-modal> | ||
30 | <div class="modal-header"> | ||
31 | <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4> | ||
32 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideVisitorModal()"></my-global-icon> | ||
33 | </div> | ||
34 | <div class="modal-body"> | ||
35 | <span i18n> | ||
36 | You can comment using an account on any ActivityPub-compatible instance. | ||
37 | On most platforms, you can find the video by typing its URL in the search bar and then comment it | ||
38 | from within the software's interface. | ||
39 | </span> | ||
40 | <span i18n> | ||
41 | If you have an account on Mastodon or Pleroma, you can open it directly in their interface: | ||
42 | </span> | ||
43 | <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe> | ||
44 | </div> | ||
45 | <div class="modal-footer inputs"> | ||
46 | <input | ||
47 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
48 | (click)="hideVisitorModal()" (key.enter)="hideVisitorModal()" | ||
49 | > | ||
50 | |||
51 | <input | ||
52 | type="submit" i18n-value value="Login to comment" class="action-button-submit" | ||
53 | (click)="gotoLogin()" | ||
54 | > | ||
55 | </div> | ||
56 | </ng-template> | ||
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 deleted file mode 100644 index b3725ab94..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss +++ /dev/null | |||
@@ -1,82 +0,0 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | form { | ||
5 | margin-bottom: 30px; | ||
6 | } | ||
7 | |||
8 | .avatar-and-textarea { | ||
9 | display: flex; | ||
10 | margin-bottom: 10px; | ||
11 | |||
12 | img { | ||
13 | @include avatar(25px); | ||
14 | |||
15 | vertical-align: top; | ||
16 | margin-right: 10px; | ||
17 | } | ||
18 | |||
19 | .form-group { | ||
20 | flex-grow: 1; | ||
21 | margin: 0; | ||
22 | |||
23 | textarea { | ||
24 | @include peertube-textarea(100%, 60px); | ||
25 | |||
26 | &:focus::placeholder { | ||
27 | opacity: 0; | ||
28 | } | ||
29 | } | ||
30 | } | ||
31 | } | ||
32 | |||
33 | .comment-buttons { | ||
34 | display: flex; | ||
35 | justify-content: flex-end; | ||
36 | |||
37 | button { | ||
38 | @include peertube-button; | ||
39 | @include disable-outline; | ||
40 | @include disable-default-a-behaviour; | ||
41 | |||
42 | &:not(:last-child) { | ||
43 | margin-right: .5rem; | ||
44 | } | ||
45 | |||
46 | &:last-child { | ||
47 | @include orange-button; | ||
48 | } | ||
49 | } | ||
50 | |||
51 | .cancel-button { | ||
52 | @include tertiary-button; | ||
53 | |||
54 | font-weight: $font-semibold; | ||
55 | display: inline-block; | ||
56 | padding: 0 10px 0 10px; | ||
57 | white-space: nowrap; | ||
58 | background: transparent; | ||
59 | } | ||
60 | } | ||
61 | |||
62 | @media screen and (max-width: 600px) { | ||
63 | textarea, .comment-buttons button { | ||
64 | font-size: 14px !important; | ||
65 | } | ||
66 | |||
67 | textarea { | ||
68 | padding: 5px !important; | ||
69 | } | ||
70 | } | ||
71 | |||
72 | .modal-body { | ||
73 | .btn { | ||
74 | @include peertube-button; | ||
75 | @include orange-button; | ||
76 | } | ||
77 | |||
78 | span { | ||
79 | float: left; | ||
80 | margin-bottom: 20px; | ||
81 | } | ||
82 | } | ||
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 deleted file mode 100644 index 79505c779..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts +++ /dev/null | |||
@@ -1,149 +0,0 @@ | |||
1 | import { Observable } from 'rxjs' | ||
2 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | ||
3 | import { Router } from '@angular/router' | ||
4 | import { Notifier, User } from '@app/core' | ||
5 | import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms' | ||
6 | import { Video } from '@app/shared/shared-main' | ||
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
8 | import { VideoCommentCreate } from '@shared/models' | ||
9 | import { VideoComment } from './video-comment.model' | ||
10 | import { VideoCommentService } from './video-comment.service' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-comment-add', | ||
14 | templateUrl: './video-comment-add.component.html', | ||
15 | styleUrls: ['./video-comment-add.component.scss'] | ||
16 | }) | ||
17 | export class VideoCommentAddComponent extends FormReactive implements OnInit { | ||
18 | @Input() user: User | ||
19 | @Input() video: Video | ||
20 | @Input() parentComment: VideoComment | ||
21 | @Input() parentComments: VideoComment[] | ||
22 | @Input() focusOnInit = false | ||
23 | |||
24 | @Output() commentCreated = new EventEmitter<VideoComment>() | ||
25 | @Output() cancel = new EventEmitter() | ||
26 | |||
27 | @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal | ||
28 | @ViewChild('textarea', { static: true }) textareaElement: ElementRef | ||
29 | |||
30 | addingComment = false | ||
31 | |||
32 | constructor ( | ||
33 | protected formValidatorService: FormValidatorService, | ||
34 | private videoCommentValidatorsService: VideoCommentValidatorsService, | ||
35 | private notifier: Notifier, | ||
36 | private videoCommentService: VideoCommentService, | ||
37 | private modalService: NgbModal, | ||
38 | private router: Router | ||
39 | ) { | ||
40 | super() | ||
41 | } | ||
42 | |||
43 | ngOnInit () { | ||
44 | this.buildForm({ | ||
45 | text: this.videoCommentValidatorsService.VIDEO_COMMENT_TEXT | ||
46 | }) | ||
47 | |||
48 | if (this.user) { | ||
49 | if (this.focusOnInit === true) { | ||
50 | this.textareaElement.nativeElement.focus() | ||
51 | } | ||
52 | |||
53 | if (this.parentComment) { | ||
54 | const mentions = this.parentComments | ||
55 | .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves | ||
56 | .map(c => '@' + c.by) | ||
57 | |||
58 | const mentionsSet = new Set(mentions) | ||
59 | const mentionsText = Array.from(mentionsSet).join(' ') + ' ' | ||
60 | |||
61 | this.form.patchValue({ text: mentionsText }) | ||
62 | } | ||
63 | } | ||
64 | } | ||
65 | |||
66 | onValidKey () { | ||
67 | this.check() | ||
68 | if (!this.form.valid) return | ||
69 | |||
70 | this.formValidated() | ||
71 | } | ||
72 | |||
73 | openVisitorModal (event: any) { | ||
74 | if (this.user === null) { // we only open it for visitors | ||
75 | // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error | ||
76 | event.srcElement.blur() | ||
77 | event.preventDefault() | ||
78 | |||
79 | this.modalService.open(this.visitorModal) | ||
80 | } | ||
81 | } | ||
82 | |||
83 | hideVisitorModal () { | ||
84 | this.modalService.dismissAll() | ||
85 | } | ||
86 | |||
87 | formValidated () { | ||
88 | // If we validate very quickly the comment form, we might comment twice | ||
89 | if (this.addingComment) return | ||
90 | |||
91 | this.addingComment = true | ||
92 | |||
93 | const commentCreate: VideoCommentCreate = this.form.value | ||
94 | let obs: Observable<VideoComment> | ||
95 | |||
96 | if (this.parentComment) { | ||
97 | obs = this.addCommentReply(commentCreate) | ||
98 | } else { | ||
99 | obs = this.addCommentThread(commentCreate) | ||
100 | } | ||
101 | |||
102 | obs.subscribe( | ||
103 | comment => { | ||
104 | this.addingComment = false | ||
105 | this.commentCreated.emit(comment) | ||
106 | this.form.reset() | ||
107 | }, | ||
108 | |||
109 | err => { | ||
110 | this.addingComment = false | ||
111 | |||
112 | this.notifier.error(err.text) | ||
113 | } | ||
114 | ) | ||
115 | } | ||
116 | |||
117 | isAddButtonDisplayed () { | ||
118 | return this.form.value['text'] | ||
119 | } | ||
120 | |||
121 | getUri () { | ||
122 | return window.location.href | ||
123 | } | ||
124 | |||
125 | getAvatarUrl () { | ||
126 | if (this.user) return this.user.accountAvatarUrl | ||
127 | return window.location.origin + '/client/assets/images/default-avatar.png' | ||
128 | } | ||
129 | |||
130 | gotoLogin () { | ||
131 | this.hideVisitorModal() | ||
132 | this.router.navigate([ '/login' ]) | ||
133 | } | ||
134 | |||
135 | cancelCommentReply () { | ||
136 | this.cancel.emit(null) | ||
137 | this.form.value['text'] = this.textareaElement.nativeElement.value = '' | ||
138 | } | ||
139 | |||
140 | private addCommentReply (commentCreate: VideoCommentCreate) { | ||
141 | return this.videoCommentService | ||
142 | .addCommentReply(this.video.id, this.parentComment.id, commentCreate) | ||
143 | } | ||
144 | |||
145 | private addCommentThread (commentCreate: VideoCommentCreate) { | ||
146 | return this.videoCommentService | ||
147 | .addCommentThread(this.video.id, commentCreate) | ||
148 | } | ||
149 | } | ||
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 deleted file mode 100644 index 7c2aaeadd..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts +++ /dev/null | |||
@@ -1,7 +0,0 @@ | |||
1 | import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models' | ||
2 | import { VideoComment } from './video-comment.model' | ||
3 | |||
4 | export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { | ||
5 | comment: VideoComment | ||
6 | children: VideoCommentThreadTree[] | ||
7 | } | ||
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 deleted file mode 100644 index 002de57e4..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.html +++ /dev/null | |||
@@ -1,95 +0,0 @@ | |||
1 | <div class="root-comment"> | ||
2 | <div class="left"> | ||
3 | <a *ngIf="!comment.isDeleted" [href]="comment.account.url" target="_blank" rel="noopener noreferrer"> | ||
4 | <img | ||
5 | class="comment-avatar" | ||
6 | [src]="comment.accountAvatarUrl" | ||
7 | (error)="switchToDefaultAvatar($event)" | ||
8 | alt="Avatar" | ||
9 | /> | ||
10 | </a> | ||
11 | |||
12 | <div class="vertical-border"></div> | ||
13 | </div> | ||
14 | |||
15 | <div class="right" [ngClass]="{ 'mb-3': firstInThread }"> | ||
16 | <span *ngIf="comment.isDeleted" class="comment-avatar"></span> | ||
17 | |||
18 | <div class="comment"> | ||
19 | <ng-container *ngIf="!comment.isDeleted"> | ||
20 | <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div> | ||
21 | |||
22 | <div class="comment-account-date"> | ||
23 | <div class="comment-account"> | ||
24 | <a | ||
25 | [routerLink]="[ '/accounts', comment.by ]" | ||
26 | class="comment-account-name" [ngClass]="{ 'video-author': video.account.id === comment.account.id }" | ||
27 | > | ||
28 | {{ comment.account.displayName }} | ||
29 | </a> | ||
30 | |||
31 | <a [href]="comment.account.url" target="_blank" rel="noopener noreferrer" class="comment-account-fid ml-1">{{ comment.by }}</a> | ||
32 | </div> | ||
33 | <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" | ||
34 | class="comment-date" [title]="comment.createdAt">{{ comment.createdAt | myFromNow }}</a> | ||
35 | </div> | ||
36 | <div | ||
37 | class="comment-html" | ||
38 | [innerHTML]="sanitizedCommentHTML" | ||
39 | (timestampClicked)="handleTimestampClicked($event)" | ||
40 | timestampRouteTransformer | ||
41 | ></div> | ||
42 | |||
43 | <div class="comment-actions"> | ||
44 | <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div> | ||
45 | <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div> | ||
46 | |||
47 | <my-user-moderation-dropdown | ||
48 | buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto" | ||
49 | ></my-user-moderation-dropdown> | ||
50 | </div> | ||
51 | </ng-container> | ||
52 | |||
53 | <ng-container *ngIf="comment.isDeleted"> | ||
54 | <div class="comment-account-date"> | ||
55 | <span class="comment-account" i18n>Deleted</span> | ||
56 | <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" | ||
57 | class="comment-date">{{ comment.createdAt | myFromNow }}</a> | ||
58 | </div> | ||
59 | |||
60 | <div *ngIf="comment.isDeleted" class="comment-html comment-html-deleted"> | ||
61 | <i i18n>This comment has been deleted</i> | ||
62 | </div> | ||
63 | </ng-container> | ||
64 | |||
65 | <my-video-comment-add | ||
66 | *ngIf="!comment.isDeleted && isUserLoggedIn() && inReplyToCommentId === comment.id" | ||
67 | [user]="user" | ||
68 | [video]="video" | ||
69 | [parentComment]="comment" | ||
70 | [parentComments]="newParentComments" | ||
71 | [focusOnInit]="true" | ||
72 | (commentCreated)="onCommentReplyCreated($event)" | ||
73 | (cancel)="onResetReply()" | ||
74 | ></my-video-comment-add> | ||
75 | |||
76 | <div *ngIf="commentTree" class="children"> | ||
77 | <div *ngFor="let commentChild of commentTree.children"> | ||
78 | <my-video-comment | ||
79 | [comment]="commentChild.comment" | ||
80 | [video]="video" | ||
81 | [inReplyToCommentId]="inReplyToCommentId" | ||
82 | [commentTree]="commentChild" | ||
83 | [parentComments]="newParentComments" | ||
84 | (wantedToReply)="onWantToReply($event)" | ||
85 | (wantedToDelete)="onWantToDelete($event)" | ||
86 | (resetReply)="onResetReply()" | ||
87 | (timestampClicked)="handleTimestampClicked($event)" | ||
88 | ></my-video-comment> | ||
89 | </div> | ||
90 | </div> | ||
91 | |||
92 | <ng-content></ng-content> | ||
93 | </div> | ||
94 | </div> | ||
95 | </div> | ||
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 deleted file mode 100644 index e7ef79561..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.scss +++ /dev/null | |||
@@ -1,189 +0,0 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .root-comment { | ||
5 | font-size: 15px; | ||
6 | display: flex; | ||
7 | |||
8 | .left { | ||
9 | display: flex; | ||
10 | flex-direction: column; | ||
11 | align-items: center; | ||
12 | margin-right: 10px; | ||
13 | |||
14 | .vertical-border { | ||
15 | width: 2px; | ||
16 | height: 100%; | ||
17 | background-color: rgba(0, 0, 0, 0.05); | ||
18 | margin: 10px calc(1rem + 1px); | ||
19 | } | ||
20 | } | ||
21 | |||
22 | .right { | ||
23 | width: 100%; | ||
24 | } | ||
25 | |||
26 | .comment-avatar { | ||
27 | @include avatar(36px); | ||
28 | } | ||
29 | |||
30 | .comment { | ||
31 | flex-grow: 1; | ||
32 | // Fix word-wrap with flex | ||
33 | min-width: 1px; | ||
34 | |||
35 | .highlighted-comment { | ||
36 | display: inline-block; | ||
37 | background-color: #F5F5F5; | ||
38 | color: #3d3d3d; | ||
39 | padding: 0 5px; | ||
40 | font-size: 13px; | ||
41 | margin-bottom: 5px; | ||
42 | font-weight: $font-semibold; | ||
43 | border-radius: 3px; | ||
44 | } | ||
45 | |||
46 | .comment-account-date { | ||
47 | display: flex; | ||
48 | margin-bottom: 4px; | ||
49 | |||
50 | .video-author { | ||
51 | height: 20px; | ||
52 | background-color: #888888; | ||
53 | border-radius: 12px; | ||
54 | margin-bottom: 2px; | ||
55 | max-width: 100%; | ||
56 | box-sizing: border-box; | ||
57 | flex-direction: row; | ||
58 | align-items: center; | ||
59 | display: inline-flex; | ||
60 | padding-right: 6px; | ||
61 | padding-left: 6px; | ||
62 | color: white !important; | ||
63 | } | ||
64 | |||
65 | .comment-account { | ||
66 | word-break: break-all; | ||
67 | font-weight: 600; | ||
68 | font-size: 90%; | ||
69 | |||
70 | a { | ||
71 | @include disable-default-a-behaviour; | ||
72 | |||
73 | color: pvar(--mainForegroundColor); | ||
74 | } | ||
75 | |||
76 | .comment-account-fid { | ||
77 | opacity: .6; | ||
78 | } | ||
79 | } | ||
80 | |||
81 | .comment-date { | ||
82 | font-size: 90%; | ||
83 | color: pvar(--greyForegroundColor); | ||
84 | margin-left: 5px; | ||
85 | text-decoration: none; | ||
86 | } | ||
87 | } | ||
88 | |||
89 | .comment-html { | ||
90 | @include peertube-word-wrap; | ||
91 | |||
92 | // Mentions | ||
93 | ::ng-deep a { | ||
94 | |||
95 | &:not(.linkified-url) { | ||
96 | @include disable-default-a-behaviour; | ||
97 | |||
98 | color: pvar(--mainForegroundColor); | ||
99 | |||
100 | font-weight: $font-semibold; | ||
101 | } | ||
102 | |||
103 | } | ||
104 | |||
105 | // Paragraphs | ||
106 | ::ng-deep p { | ||
107 | margin-bottom: .3rem; | ||
108 | } | ||
109 | |||
110 | &.comment-html-deleted { | ||
111 | color: pvar(--greyForegroundColor); | ||
112 | margin-bottom: 1rem; | ||
113 | } | ||
114 | } | ||
115 | |||
116 | .comment-actions { | ||
117 | margin-bottom: 10px; | ||
118 | display: flex; | ||
119 | |||
120 | ::ng-deep .dropdown-toggle, | ||
121 | .comment-action-reply, | ||
122 | .comment-action-delete { | ||
123 | color: pvar(--greyForegroundColor); | ||
124 | cursor: pointer; | ||
125 | margin-right: 10px; | ||
126 | |||
127 | &:hover { | ||
128 | color: pvar(--mainForegroundColor); | ||
129 | } | ||
130 | } | ||
131 | |||
132 | ::ng-deep .action-button { | ||
133 | background-color: transparent; | ||
134 | padding: 0; | ||
135 | font-weight: unset; | ||
136 | } | ||
137 | } | ||
138 | |||
139 | my-video-comment-add { | ||
140 | ::ng-deep form { | ||
141 | margin-top: 1rem; | ||
142 | margin-bottom: 0; | ||
143 | } | ||
144 | } | ||
145 | } | ||
146 | |||
147 | .children { | ||
148 | // Reduce avatars size for replies | ||
149 | .comment-avatar { | ||
150 | @include avatar(25px); | ||
151 | } | ||
152 | |||
153 | .left { | ||
154 | margin-right: 6px; | ||
155 | } | ||
156 | } | ||
157 | } | ||
158 | |||
159 | @media screen and (max-width: 1200px) { | ||
160 | .children { | ||
161 | margin-left: -10px; | ||
162 | } | ||
163 | } | ||
164 | |||
165 | @media screen and (max-width: 600px) { | ||
166 | .root-comment { | ||
167 | .children { | ||
168 | margin-left: -20px; | ||
169 | |||
170 | .left { | ||
171 | align-items: flex-start; | ||
172 | |||
173 | .vertical-border { | ||
174 | margin-left: 2px; | ||
175 | } | ||
176 | } | ||
177 | } | ||
178 | |||
179 | .comment { | ||
180 | .comment-account-date { | ||
181 | flex-direction: column; | ||
182 | |||
183 | .comment-date { | ||
184 | margin-left: 0; | ||
185 | } | ||
186 | } | ||
187 | } | ||
188 | } | ||
189 | } | ||
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 deleted file mode 100644 index 27846c1ad..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts +++ /dev/null | |||
@@ -1,131 +0,0 @@ | |||
1 | import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' | ||
2 | import { MarkdownService, Notifier, UserService } from '@app/core' | ||
3 | import { AuthService } from '@app/core/auth' | ||
4 | import { Account, Actor, Video } from '@app/shared/shared-main' | ||
5 | import { User, UserRight } from '@shared/models' | ||
6 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | ||
7 | import { VideoComment } from './video-comment.model' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-video-comment', | ||
11 | templateUrl: './video-comment.component.html', | ||
12 | styleUrls: ['./video-comment.component.scss'] | ||
13 | }) | ||
14 | export class VideoCommentComponent implements OnInit, OnChanges { | ||
15 | @Input() video: Video | ||
16 | @Input() comment: VideoComment | ||
17 | @Input() parentComments: VideoComment[] = [] | ||
18 | @Input() commentTree: VideoCommentThreadTree | ||
19 | @Input() inReplyToCommentId: number | ||
20 | @Input() highlightedComment = false | ||
21 | @Input() firstInThread = false | ||
22 | |||
23 | @Output() wantedToDelete = new EventEmitter<VideoComment>() | ||
24 | @Output() wantedToReply = new EventEmitter<VideoComment>() | ||
25 | @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>() | ||
26 | @Output() resetReply = new EventEmitter() | ||
27 | @Output() timestampClicked = new EventEmitter<number>() | ||
28 | |||
29 | sanitizedCommentHTML = '' | ||
30 | newParentComments: VideoComment[] = [] | ||
31 | |||
32 | commentAccount: Account | ||
33 | commentUser: User | ||
34 | |||
35 | constructor ( | ||
36 | private markdownService: MarkdownService, | ||
37 | private authService: AuthService, | ||
38 | private userService: UserService, | ||
39 | private notifier: Notifier | ||
40 | ) {} | ||
41 | |||
42 | get user () { | ||
43 | return this.authService.getUser() | ||
44 | } | ||
45 | |||
46 | ngOnInit () { | ||
47 | this.init() | ||
48 | } | ||
49 | |||
50 | ngOnChanges () { | ||
51 | this.init() | ||
52 | } | ||
53 | |||
54 | onCommentReplyCreated (createdComment: VideoComment) { | ||
55 | if (!this.commentTree) { | ||
56 | this.commentTree = { | ||
57 | comment: this.comment, | ||
58 | children: [] | ||
59 | } | ||
60 | |||
61 | this.threadCreated.emit(this.commentTree) | ||
62 | } | ||
63 | |||
64 | this.commentTree.children.unshift({ | ||
65 | comment: createdComment, | ||
66 | children: [] | ||
67 | }) | ||
68 | this.resetReply.emit() | ||
69 | } | ||
70 | |||
71 | onWantToReply (comment?: VideoComment) { | ||
72 | this.wantedToReply.emit(comment || this.comment) | ||
73 | } | ||
74 | |||
75 | onWantToDelete (comment?: VideoComment) { | ||
76 | this.wantedToDelete.emit(comment || this.comment) | ||
77 | } | ||
78 | |||
79 | isUserLoggedIn () { | ||
80 | return this.authService.isLoggedIn() | ||
81 | } | ||
82 | |||
83 | onResetReply () { | ||
84 | this.resetReply.emit() | ||
85 | } | ||
86 | |||
87 | handleTimestampClicked (timestamp: number) { | ||
88 | this.timestampClicked.emit(timestamp) | ||
89 | } | ||
90 | |||
91 | isRemovableByUser () { | ||
92 | return this.comment.account && this.isUserLoggedIn() && | ||
93 | ( | ||
94 | this.user.account.id === this.comment.account.id || | ||
95 | this.user.account.id === this.video.account.id || | ||
96 | this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) | ||
97 | ) | ||
98 | } | ||
99 | |||
100 | switchToDefaultAvatar ($event: Event) { | ||
101 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() | ||
102 | } | ||
103 | |||
104 | private getUserIfNeeded (account: Account) { | ||
105 | if (!account.userId) return | ||
106 | if (!this.authService.isLoggedIn()) return | ||
107 | |||
108 | const user = this.authService.getUser() | ||
109 | if (user.hasRight(UserRight.MANAGE_USERS)) { | ||
110 | this.userService.getUserWithCache(account.userId) | ||
111 | .subscribe( | ||
112 | user => this.commentUser = user, | ||
113 | |||
114 | err => this.notifier.error(err.message) | ||
115 | ) | ||
116 | } | ||
117 | } | ||
118 | |||
119 | private async init () { | ||
120 | const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) | ||
121 | this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) | ||
122 | this.newParentComments = this.parentComments.concat([ this.comment ]) | ||
123 | |||
124 | if (this.comment.account) { | ||
125 | this.commentAccount = new Account(this.comment.account) | ||
126 | this.getUserIfNeeded(this.commentAccount) | ||
127 | } else { | ||
128 | this.comment.account = null | ||
129 | } | ||
130 | } | ||
131 | } | ||
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 deleted file mode 100644 index e85443196..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.model.ts +++ /dev/null | |||
@@ -1,48 +0,0 @@ | |||
1 | import { getAbsoluteAPIUrl } from '@app/helpers' | ||
2 | import { Actor } from '@app/shared/shared-main' | ||
3 | import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models' | ||
4 | |||
5 | export class VideoComment implements VideoCommentServerModel { | ||
6 | id: number | ||
7 | url: string | ||
8 | text: string | ||
9 | threadId: number | ||
10 | inReplyToCommentId: number | ||
11 | videoId: number | ||
12 | createdAt: Date | string | ||
13 | updatedAt: Date | string | ||
14 | deletedAt: Date | string | ||
15 | isDeleted: boolean | ||
16 | account: AccountInterface | ||
17 | totalRepliesFromVideoAuthor: number | ||
18 | totalReplies: number | ||
19 | by: string | ||
20 | accountAvatarUrl: string | ||
21 | |||
22 | isLocal: boolean | ||
23 | |||
24 | constructor (hash: VideoCommentServerModel) { | ||
25 | this.id = hash.id | ||
26 | this.url = hash.url | ||
27 | this.text = hash.text | ||
28 | this.threadId = hash.threadId | ||
29 | this.inReplyToCommentId = hash.inReplyToCommentId | ||
30 | this.videoId = hash.videoId | ||
31 | this.createdAt = new Date(hash.createdAt.toString()) | ||
32 | this.updatedAt = new Date(hash.updatedAt.toString()) | ||
33 | this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null | ||
34 | this.isDeleted = hash.isDeleted | ||
35 | this.account = hash.account | ||
36 | this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor | ||
37 | this.totalReplies = hash.totalReplies | ||
38 | |||
39 | if (this.account) { | ||
40 | this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) | ||
41 | this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) | ||
42 | |||
43 | const absoluteAPIUrl = getAbsoluteAPIUrl() | ||
44 | const thisHost = new URL(absoluteAPIUrl).host | ||
45 | this.isLocal = this.account.host.trim() === thisHost | ||
46 | } | ||
47 | } | ||
48 | } | ||
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 deleted file mode 100644 index a73fb9ca8..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.service.ts +++ /dev/null | |||
@@ -1,149 +0,0 @@ | |||
1 | import { Observable } from 'rxjs' | ||
2 | import { catchError, map } from 'rxjs/operators' | ||
3 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' | ||
6 | import { objectLineFeedToHtml } from '@app/helpers' | ||
7 | import { | ||
8 | FeedFormat, | ||
9 | ResultList, | ||
10 | VideoComment as VideoCommentServerModel, | ||
11 | VideoCommentCreate, | ||
12 | VideoCommentThreadTree as VideoCommentThreadTreeServerModel | ||
13 | } from '@shared/models' | ||
14 | import { environment } from '../../../../environments/environment' | ||
15 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | ||
16 | import { VideoComment } from './video-comment.model' | ||
17 | |||
18 | @Injectable() | ||
19 | export class VideoCommentService { | ||
20 | private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | ||
21 | private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.' | ||
22 | |||
23 | constructor ( | ||
24 | private authHttp: HttpClient, | ||
25 | private restExtractor: RestExtractor, | ||
26 | private restService: RestService | ||
27 | ) {} | ||
28 | |||
29 | addCommentThread (videoId: number | string, comment: VideoCommentCreate) { | ||
30 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | ||
31 | const normalizedComment = objectLineFeedToHtml(comment, 'text') | ||
32 | |||
33 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) | ||
34 | .pipe( | ||
35 | map(data => this.extractVideoComment(data.comment)), | ||
36 | catchError(err => this.restExtractor.handleError(err)) | ||
37 | ) | ||
38 | } | ||
39 | |||
40 | addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) { | ||
41 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId | ||
42 | const normalizedComment = objectLineFeedToHtml(comment, 'text') | ||
43 | |||
44 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) | ||
45 | .pipe( | ||
46 | map(data => this.extractVideoComment(data.comment)), | ||
47 | catchError(err => this.restExtractor.handleError(err)) | ||
48 | ) | ||
49 | } | ||
50 | |||
51 | getVideoCommentThreads (parameters: { | ||
52 | videoId: number | string, | ||
53 | componentPagination: ComponentPaginationLight, | ||
54 | sort: string | ||
55 | }): Observable<ResultList<VideoComment>> { | ||
56 | const { videoId, componentPagination, sort } = parameters | ||
57 | |||
58 | const pagination = this.restService.componentPaginationToRestPagination(componentPagination) | ||
59 | |||
60 | let params = new HttpParams() | ||
61 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
62 | |||
63 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | ||
64 | return this.authHttp.get<ResultList<VideoComment>>(url, { params }) | ||
65 | .pipe( | ||
66 | map(result => this.extractVideoComments(result)), | ||
67 | catchError(err => this.restExtractor.handleError(err)) | ||
68 | ) | ||
69 | } | ||
70 | |||
71 | getVideoThreadComments (parameters: { | ||
72 | videoId: number | string, | ||
73 | threadId: number | ||
74 | }): Observable<VideoCommentThreadTree> { | ||
75 | const { videoId, threadId } = parameters | ||
76 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` | ||
77 | |||
78 | return this.authHttp | ||
79 | .get<VideoCommentThreadTreeServerModel>(url) | ||
80 | .pipe( | ||
81 | map(tree => this.extractVideoCommentTree(tree)), | ||
82 | catchError(err => this.restExtractor.handleError(err)) | ||
83 | ) | ||
84 | } | ||
85 | |||
86 | deleteVideoComment (videoId: number | string, commentId: number) { | ||
87 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` | ||
88 | |||
89 | return this.authHttp | ||
90 | .delete(url) | ||
91 | .pipe( | ||
92 | map(this.restExtractor.extractDataBool), | ||
93 | catchError(err => this.restExtractor.handleError(err)) | ||
94 | ) | ||
95 | } | ||
96 | |||
97 | getVideoCommentsFeeds (videoUUID?: string) { | ||
98 | const feeds = [ | ||
99 | { | ||
100 | format: FeedFormat.RSS, | ||
101 | label: 'rss 2.0', | ||
102 | url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() | ||
103 | }, | ||
104 | { | ||
105 | format: FeedFormat.ATOM, | ||
106 | label: 'atom 1.0', | ||
107 | url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() | ||
108 | }, | ||
109 | { | ||
110 | format: FeedFormat.JSON, | ||
111 | label: 'json 1.0', | ||
112 | url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() | ||
113 | } | ||
114 | ] | ||
115 | |||
116 | if (videoUUID !== undefined) { | ||
117 | for (const feed of feeds) { | ||
118 | feed.url += '?videoId=' + videoUUID | ||
119 | } | ||
120 | } | ||
121 | |||
122 | return feeds | ||
123 | } | ||
124 | |||
125 | private extractVideoComment (videoComment: VideoCommentServerModel) { | ||
126 | return new VideoComment(videoComment) | ||
127 | } | ||
128 | |||
129 | private extractVideoComments (result: ResultList<VideoCommentServerModel>) { | ||
130 | const videoCommentsJson = result.data | ||
131 | const totalComments = result.total | ||
132 | const comments: VideoComment[] = [] | ||
133 | |||
134 | for (const videoCommentJson of videoCommentsJson) { | ||
135 | comments.push(new VideoComment(videoCommentJson)) | ||
136 | } | ||
137 | |||
138 | return { data: comments, total: totalComments } | ||
139 | } | ||
140 | |||
141 | private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { | ||
142 | if (!tree) return tree as VideoCommentThreadTree | ||
143 | |||
144 | tree.comment = new VideoComment(tree.comment) | ||
145 | tree.children.forEach(c => this.extractVideoCommentTree(c)) | ||
146 | |||
147 | return tree as VideoCommentThreadTree | ||
148 | } | ||
149 | } | ||
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 deleted file mode 100644 index dd1d43560..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.html +++ /dev/null | |||
@@ -1,98 +0,0 @@ | |||
1 | <div> | ||
2 | <div class="title-block"> | ||
3 | <h2 class="title-page title-page-single"> | ||
4 | <ng-container *ngIf="componentPagination.totalItems > 0; then hasComments; else noComments"></ng-container> | ||
5 | <ng-template #hasComments> | ||
6 | <ng-container i18n *ngIf="componentPagination.totalItems === 1; else manyComments">1 Comment</ng-container> | ||
7 | <ng-template i18n #manyComments>{{ componentPagination.totalItems }} Comments</ng-template> | ||
8 | </ng-template> | ||
9 | <ng-template i18n #noComments>Comments</ng-template> | ||
10 | </h2> | ||
11 | |||
12 | <my-feed [syndicationItems]="syndicationItems"></my-feed> | ||
13 | |||
14 | <div ngbDropdown class="d-inline-block ml-4"> | ||
15 | <button class="btn btn-sm btn-outline-secondary" id="dropdown-sort-comments" ngbDropdownToggle i18n> | ||
16 | SORT BY | ||
17 | </button> | ||
18 | <div ngbDropdownMenu aria-labelledby="dropdown-sort-comments"> | ||
19 | <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button> | ||
20 | <button (click)="handleSortChange('-totalReplies')" ngbDropdownItem i18n>Most replies first</button> | ||
21 | </div> | ||
22 | </div> | ||
23 | </div> | ||
24 | |||
25 | <ng-template [ngIf]="video.commentsEnabled === true"> | ||
26 | <my-video-comment-add | ||
27 | [video]="video" | ||
28 | [user]="user" | ||
29 | (commentCreated)="onCommentThreadCreated($event)" | ||
30 | ></my-video-comment-add> | ||
31 | |||
32 | <div *ngIf="componentPagination.totalItems === 0 && comments.length === 0" i18n>No comments.</div> | ||
33 | |||
34 | <div | ||
35 | class="comment-threads" | ||
36 | myInfiniteScroller | ||
37 | [autoInit]="true" | ||
38 | (nearOfBottom)="onNearOfBottom()" | ||
39 | [dataObservable]="onDataSubject.asObservable()" | ||
40 | > | ||
41 | <div> | ||
42 | <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div> | ||
43 | <my-video-comment | ||
44 | *ngIf="highlightedThread" | ||
45 | [comment]="highlightedThread" | ||
46 | [video]="video" | ||
47 | [inReplyToCommentId]="inReplyToCommentId" | ||
48 | [commentTree]="threadComments[highlightedThread.id]" | ||
49 | [highlightedComment]="true" | ||
50 | [firstInThread]="true" | ||
51 | (wantedToReply)="onWantedToReply($event)" | ||
52 | (wantedToDelete)="onWantedToDelete($event)" | ||
53 | (threadCreated)="onThreadCreated($event)" | ||
54 | (resetReply)="onResetReply()" | ||
55 | (timestampClicked)="handleTimestampClicked($event)" | ||
56 | ></my-video-comment> | ||
57 | </div> | ||
58 | |||
59 | <div *ngFor="let comment of comments; index as i"> | ||
60 | <my-video-comment | ||
61 | *ngIf="!highlightedThread || comment.id !== highlightedThread.id" | ||
62 | [comment]="comment" | ||
63 | [video]="video" | ||
64 | [inReplyToCommentId]="inReplyToCommentId" | ||
65 | [commentTree]="threadComments[comment.id]" | ||
66 | [firstInThread]="i + 1 !== comments.length" | ||
67 | (wantedToReply)="onWantedToReply($event)" | ||
68 | (wantedToDelete)="onWantedToDelete($event)" | ||
69 | (threadCreated)="onThreadCreated($event)" | ||
70 | (resetReply)="onResetReply()" | ||
71 | (timestampClicked)="handleTimestampClicked($event)" | ||
72 | > | ||
73 | <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies mb-2"> | ||
74 | <span class="glyphicon glyphicon-menu-down"></span> | ||
75 | |||
76 | <ng-container *ngIf="comment.totalRepliesFromVideoAuthor > 0; then hasAuthorComments; else noAuthorComments"></ng-container> | ||
77 | <ng-template #hasAuthorComments> | ||
78 | <ng-container *ngIf="comment.totalReplies !== comment.totalRepliesFromVideoAuthor; else onlyAuthorComments" i18n> | ||
79 | View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }} and others | ||
80 | </ng-container> | ||
81 | <ng-template i18n #onlyAuthorComments> | ||
82 | View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }} | ||
83 | </ng-template> | ||
84 | </ng-template> | ||
85 | <ng-template i18n #noAuthorComments>View {{ comment.totalReplies }} replies</ng-template> | ||
86 | |||
87 | <my-small-loader class="comment-thread-loading ml-1" [loading]="threadLoading[comment.id]"></my-small-loader> | ||
88 | </div> | ||
89 | </my-video-comment> | ||
90 | |||
91 | </div> | ||
92 | </div> | ||
93 | </ng-template> | ||
94 | |||
95 | <div *ngIf="video.commentsEnabled === false" i18n> | ||
96 | Comments are disabled. | ||
97 | </div> | ||
98 | </div> | ||
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 deleted file mode 100644 index df42fae73..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.scss +++ /dev/null | |||
@@ -1,53 +0,0 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | #highlighted-comment { | ||
5 | margin-bottom: 25px; | ||
6 | } | ||
7 | |||
8 | .view-replies { | ||
9 | font-weight: $font-semibold; | ||
10 | font-size: 15px; | ||
11 | cursor: pointer; | ||
12 | } | ||
13 | |||
14 | .glyphicon, .comment-thread-loading { | ||
15 | margin-right: 5px; | ||
16 | display: inline-block; | ||
17 | font-size: 13px; | ||
18 | } | ||
19 | |||
20 | .title-block { | ||
21 | .title-page { | ||
22 | margin-right: 0; | ||
23 | } | ||
24 | |||
25 | my-feed { | ||
26 | display: inline-block; | ||
27 | margin-left: 5px; | ||
28 | opacity: 0; | ||
29 | transition: ease-in .2s opacity; | ||
30 | } | ||
31 | &:hover my-feed { | ||
32 | opacity: 1; | ||
33 | } | ||
34 | } | ||
35 | |||
36 | #dropdown-sort-comments { | ||
37 | font-weight: 600; | ||
38 | text-transform: uppercase; | ||
39 | border: none; | ||
40 | transform: translateY(-7%); | ||
41 | } | ||
42 | |||
43 | @media screen and (max-width: 600px) { | ||
44 | .view-replies { | ||
45 | margin-left: 46px; | ||
46 | } | ||
47 | } | ||
48 | |||
49 | @media screen and (max-width: 450px) { | ||
50 | .view-replies { | ||
51 | font-size: 14px; | ||
52 | } | ||
53 | } | ||
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 deleted file mode 100644 index df0018ec6..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts +++ /dev/null | |||
@@ -1,232 +0,0 @@ | |||
1 | import { Subject, Subscription } from 'rxjs' | ||
2 | import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' | ||
3 | import { ActivatedRoute } from '@angular/router' | ||
4 | import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' | ||
5 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
6 | import { Syndication, VideoDetails } from '@app/shared/shared-main' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | ||
9 | import { VideoComment } from './video-comment.model' | ||
10 | import { VideoCommentService } from './video-comment.service' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-comments', | ||
14 | templateUrl: './video-comments.component.html', | ||
15 | styleUrls: ['./video-comments.component.scss'] | ||
16 | }) | ||
17 | export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | ||
18 | @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef | ||
19 | @Input() video: VideoDetails | ||
20 | @Input() user: User | ||
21 | |||
22 | @Output() timestampClicked = new EventEmitter<number>() | ||
23 | |||
24 | comments: VideoComment[] = [] | ||
25 | highlightedThread: VideoComment | ||
26 | sort = '-createdAt' | ||
27 | componentPagination: ComponentPagination = { | ||
28 | currentPage: 1, | ||
29 | itemsPerPage: 10, | ||
30 | totalItems: null | ||
31 | } | ||
32 | inReplyToCommentId: number | ||
33 | threadComments: { [ id: number ]: VideoCommentThreadTree } = {} | ||
34 | threadLoading: { [ id: number ]: boolean } = {} | ||
35 | |||
36 | syndicationItems: Syndication[] = [] | ||
37 | |||
38 | onDataSubject = new Subject<any[]>() | ||
39 | |||
40 | private sub: Subscription | ||
41 | |||
42 | constructor ( | ||
43 | private authService: AuthService, | ||
44 | private notifier: Notifier, | ||
45 | private confirmService: ConfirmService, | ||
46 | private videoCommentService: VideoCommentService, | ||
47 | private activatedRoute: ActivatedRoute, | ||
48 | private i18n: I18n, | ||
49 | private hooks: HooksService | ||
50 | ) {} | ||
51 | |||
52 | ngOnInit () { | ||
53 | // Find highlighted comment in params | ||
54 | this.sub = this.activatedRoute.params.subscribe( | ||
55 | params => { | ||
56 | if (params['threadId']) { | ||
57 | const highlightedThreadId = +params['threadId'] | ||
58 | this.processHighlightedThread(highlightedThreadId) | ||
59 | } | ||
60 | } | ||
61 | ) | ||
62 | } | ||
63 | |||
64 | ngOnChanges (changes: SimpleChanges) { | ||
65 | if (changes['video']) { | ||
66 | this.resetVideo() | ||
67 | } | ||
68 | } | ||
69 | |||
70 | ngOnDestroy () { | ||
71 | if (this.sub) this.sub.unsubscribe() | ||
72 | } | ||
73 | |||
74 | viewReplies (commentId: number, highlightThread = false) { | ||
75 | this.threadLoading[commentId] = true | ||
76 | |||
77 | const params = { | ||
78 | videoId: this.video.id, | ||
79 | threadId: commentId | ||
80 | } | ||
81 | |||
82 | const obs = this.hooks.wrapObsFun( | ||
83 | this.videoCommentService.getVideoThreadComments.bind(this.videoCommentService), | ||
84 | params, | ||
85 | 'video-watch', | ||
86 | 'filter:api.video-watch.video-thread-replies.list.params', | ||
87 | 'filter:api.video-watch.video-thread-replies.list.result' | ||
88 | ) | ||
89 | |||
90 | obs.subscribe( | ||
91 | res => { | ||
92 | this.threadComments[commentId] = res | ||
93 | this.threadLoading[commentId] = false | ||
94 | this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res }) | ||
95 | |||
96 | if (highlightThread) { | ||
97 | this.highlightedThread = new VideoComment(res.comment) | ||
98 | |||
99 | // Scroll to the highlighted thread | ||
100 | setTimeout(() => this.commentHighlightBlock.nativeElement.scrollIntoView(), 0) | ||
101 | } | ||
102 | }, | ||
103 | |||
104 | err => this.notifier.error(err.message) | ||
105 | ) | ||
106 | } | ||
107 | |||
108 | loadMoreThreads () { | ||
109 | const params = { | ||
110 | videoId: this.video.id, | ||
111 | componentPagination: this.componentPagination, | ||
112 | sort: this.sort | ||
113 | } | ||
114 | |||
115 | const obs = this.hooks.wrapObsFun( | ||
116 | this.videoCommentService.getVideoCommentThreads.bind(this.videoCommentService), | ||
117 | params, | ||
118 | 'video-watch', | ||
119 | 'filter:api.video-watch.video-threads.list.params', | ||
120 | 'filter:api.video-watch.video-threads.list.result' | ||
121 | ) | ||
122 | |||
123 | obs.subscribe( | ||
124 | res => { | ||
125 | this.comments = this.comments.concat(res.data) | ||
126 | this.componentPagination.totalItems = res.total | ||
127 | |||
128 | this.onDataSubject.next(res.data) | ||
129 | this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination }) | ||
130 | }, | ||
131 | |||
132 | err => this.notifier.error(err.message) | ||
133 | ) | ||
134 | } | ||
135 | |||
136 | onCommentThreadCreated (comment: VideoComment) { | ||
137 | this.comments.unshift(comment) | ||
138 | } | ||
139 | |||
140 | onWantedToReply (comment: VideoComment) { | ||
141 | this.inReplyToCommentId = comment.id | ||
142 | } | ||
143 | |||
144 | onResetReply () { | ||
145 | this.inReplyToCommentId = undefined | ||
146 | } | ||
147 | |||
148 | onThreadCreated (commentTree: VideoCommentThreadTree) { | ||
149 | this.viewReplies(commentTree.comment.id) | ||
150 | } | ||
151 | |||
152 | handleSortChange (sort: string) { | ||
153 | if (this.sort === sort) return | ||
154 | |||
155 | this.sort = sort | ||
156 | this.resetVideo() | ||
157 | } | ||
158 | |||
159 | handleTimestampClicked (timestamp: number) { | ||
160 | this.timestampClicked.emit(timestamp) | ||
161 | } | ||
162 | |||
163 | async onWantedToDelete (commentToDelete: VideoComment) { | ||
164 | let message = 'Do you really want to delete this comment?' | ||
165 | |||
166 | if (commentToDelete.isLocal || this.video.isLocal) { | ||
167 | message += this.i18n(' The deletion will be sent to remote instances so they can reflect the change.') | ||
168 | } else { | ||
169 | message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.') | ||
170 | } | ||
171 | |||
172 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) | ||
173 | if (res === false) return | ||
174 | |||
175 | this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id) | ||
176 | .subscribe( | ||
177 | () => { | ||
178 | if (this.highlightedThread?.id === commentToDelete.id) { | ||
179 | commentToDelete = this.comments.find(c => c.id === commentToDelete.id) | ||
180 | |||
181 | this.highlightedThread = undefined | ||
182 | } | ||
183 | |||
184 | // Mark the comment as deleted | ||
185 | this.softDeleteComment(commentToDelete) | ||
186 | }, | ||
187 | |||
188 | err => this.notifier.error(err.message) | ||
189 | ) | ||
190 | } | ||
191 | |||
192 | isUserLoggedIn () { | ||
193 | return this.authService.isLoggedIn() | ||
194 | } | ||
195 | |||
196 | onNearOfBottom () { | ||
197 | if (hasMoreItems(this.componentPagination)) { | ||
198 | this.componentPagination.currentPage++ | ||
199 | this.loadMoreThreads() | ||
200 | } | ||
201 | } | ||
202 | |||
203 | private softDeleteComment (comment: VideoComment) { | ||
204 | comment.isDeleted = true | ||
205 | comment.deletedAt = new Date() | ||
206 | comment.text = '' | ||
207 | comment.account = null | ||
208 | } | ||
209 | |||
210 | private resetVideo () { | ||
211 | if (this.video.commentsEnabled === true) { | ||
212 | // Reset all our fields | ||
213 | this.highlightedThread = null | ||
214 | this.comments = [] | ||
215 | this.threadComments = {} | ||
216 | this.threadLoading = {} | ||
217 | this.inReplyToCommentId = undefined | ||
218 | this.componentPagination.currentPage = 1 | ||
219 | this.componentPagination.totalItems = null | ||
220 | |||
221 | this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid) | ||
222 | this.loadMoreThreads() | ||
223 | } | ||
224 | } | ||
225 | |||
226 | private processHighlightedThread (highlightedThreadId: number) { | ||
227 | this.highlightedThread = this.comments.find(c => c.id === highlightedThreadId) | ||
228 | |||
229 | const highlightThread = true | ||
230 | this.viewReplies(highlightedThreadId, highlightThread) | ||
231 | } | ||
232 | } | ||
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 deleted file mode 100644 index 5e6a2d518..000000000 --- a/client/src/app/videos/+video-watch/modal/video-share.component.html +++ /dev/null | |||
@@ -1,187 +0,0 @@ | |||
1 | <ng-template #modal let-hide="close"> | ||
2 | <div class="modal-header"> | ||
3 | <h4 i18n class="modal-title">Share</h4> | ||
4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | ||
5 | </div> | ||
6 | |||
7 | |||
8 | <div class="modal-body"> | ||
9 | <div class="playlist" *ngIf="hasPlaylist()"> | ||
10 | <div class="title-page title-page-single" i18n>Share the playlist</div> | ||
11 | |||
12 | <my-input-readonly-copy [value]="getPlaylistUrl()"></my-input-readonly-copy> | ||
13 | |||
14 | <div class="filters"> | ||
15 | |||
16 | <div class="form-group"> | ||
17 | <my-peertube-checkbox | ||
18 | inputName="includeVideoInPlaylist" [(ngModel)]="includeVideoInPlaylist" | ||
19 | i18n-labelText labelText="Share the playlist at this video position" | ||
20 | ></my-peertube-checkbox> | ||
21 | </div> | ||
22 | |||
23 | </div> | ||
24 | </div> | ||
25 | |||
26 | |||
27 | <div class="video"> | ||
28 | <div class="title-page title-page-single" *ngIf="hasPlaylist()" i18n>Share the video</div> | ||
29 | |||
30 | <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeId"> | ||
31 | |||
32 | <ng-container ngbNavItem="url"> | ||
33 | <a ngbNavLink i18n>URL</a> | ||
34 | |||
35 | <ng-template ngbNavContent> | ||
36 | <div class="nav-content"> | ||
37 | <my-input-readonly-copy [value]="getVideoUrl()"></my-input-readonly-copy> | ||
38 | </div> | ||
39 | </ng-template> | ||
40 | </ng-container> | ||
41 | |||
42 | <ng-container ngbNavItem="qrcode"> | ||
43 | <a ngbNavLink i18n>QR-Code</a> | ||
44 | |||
45 | <ng-template ngbNavContent> | ||
46 | <div class="nav-content"> | ||
47 | <qrcode [qrdata]="getVideoUrl()" [size]="256" level="Q"></qrcode> | ||
48 | </div> | ||
49 | </ng-template> | ||
50 | </ng-container> | ||
51 | |||
52 | <ng-container ngbNavItem="embed"> | ||
53 | <a ngbNavLink i18n>Embed</a> | ||
54 | |||
55 | <ng-template ngbNavContent> | ||
56 | <div class="nav-content"> | ||
57 | <my-input-readonly-copy [value]="getVideoIframeCode()"></my-input-readonly-copy> | ||
58 | |||
59 | <div i18n *ngIf="notSecure()" class="alert alert-warning"> | ||
60 | The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS websites). | ||
61 | </div> | ||
62 | </div> | ||
63 | </ng-template> | ||
64 | </ng-container> | ||
65 | |||
66 | </div> | ||
67 | |||
68 | <div [ngbNavOutlet]="nav"></div> | ||
69 | |||
70 | <div class="filters"> | ||
71 | <div> | ||
72 | <div class="form-group start-at"> | ||
73 | <my-peertube-checkbox | ||
74 | inputName="startAt" [(ngModel)]="customizations.startAtCheckbox" | ||
75 | i18n-labelText labelText="Start at" | ||
76 | ></my-peertube-checkbox> | ||
77 | |||
78 | <my-timestamp-input | ||
79 | [timestamp]="customizations.startAt" | ||
80 | [maxTimestamp]="video.duration" | ||
81 | [disabled]="!customizations.startAtCheckbox" | ||
82 | [(ngModel)]="customizations.startAt" | ||
83 | > | ||
84 | </my-timestamp-input> | ||
85 | </div> | ||
86 | |||
87 | <div *ngIf="videoCaptions.length !== 0" class="form-group video-caption-block"> | ||
88 | <my-peertube-checkbox | ||
89 | inputName="subtitleCheckbox" [(ngModel)]="customizations.subtitleCheckbox" | ||
90 | i18n-labelText labelText="Auto select subtitle" | ||
91 | ></my-peertube-checkbox> | ||
92 | |||
93 | <div class="peertube-select-container" [ngClass]="{ disabled: !customizations.subtitleCheckbox }"> | ||
94 | <select [(ngModel)]="customizations.subtitle" [disabled]="!customizations.subtitleCheckbox"> | ||
95 | <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option> | ||
96 | </select> | ||
97 | </div> | ||
98 | </div> | ||
99 | </div> | ||
100 | |||
101 | <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> | ||
102 | <div> | ||
103 | <div class="form-group stop-at"> | ||
104 | <my-peertube-checkbox | ||
105 | inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox" | ||
106 | i18n-labelText labelText="Stop at" | ||
107 | ></my-peertube-checkbox> | ||
108 | |||
109 | <my-timestamp-input | ||
110 | [timestamp]="customizations.stopAt" | ||
111 | [maxTimestamp]="video.duration" | ||
112 | [disabled]="!customizations.stopAtCheckbox" | ||
113 | [(ngModel)]="customizations.stopAt" | ||
114 | > | ||
115 | </my-timestamp-input> | ||
116 | </div> | ||
117 | |||
118 | <div class="form-group"> | ||
119 | <my-peertube-checkbox | ||
120 | inputName="autoplay" [(ngModel)]="customizations.autoplay" | ||
121 | i18n-labelText labelText="Autoplay" | ||
122 | ></my-peertube-checkbox> | ||
123 | </div> | ||
124 | |||
125 | <div class="form-group"> | ||
126 | <my-peertube-checkbox | ||
127 | inputName="muted" [(ngModel)]="customizations.muted" | ||
128 | i18n-labelText labelText="Muted" | ||
129 | ></my-peertube-checkbox> | ||
130 | </div> | ||
131 | |||
132 | <div class="form-group"> | ||
133 | <my-peertube-checkbox | ||
134 | inputName="loop" [(ngModel)]="customizations.loop" | ||
135 | i18n-labelText labelText="Loop" | ||
136 | ></my-peertube-checkbox> | ||
137 | </div> | ||
138 | </div> | ||
139 | |||
140 | <ng-container *ngIf="isInEmbedTab()"> | ||
141 | <div class="form-group"> | ||
142 | <my-peertube-checkbox | ||
143 | inputName="title" [(ngModel)]="customizations.title" | ||
144 | i18n-labelText labelText="Display video title" | ||
145 | ></my-peertube-checkbox> | ||
146 | </div> | ||
147 | |||
148 | <div class="form-group"> | ||
149 | <my-peertube-checkbox | ||
150 | inputName="warningTitle" [(ngModel)]="customizations.warningTitle" | ||
151 | i18n-labelText labelText="Display privacy warning" | ||
152 | ></my-peertube-checkbox> | ||
153 | </div> | ||
154 | |||
155 | <div class="form-group"> | ||
156 | <my-peertube-checkbox | ||
157 | inputName="controls" [(ngModel)]="customizations.controls" | ||
158 | i18n-labelText labelText="Display player controls" | ||
159 | ></my-peertube-checkbox> | ||
160 | </div> | ||
161 | </ng-container> | ||
162 | </div> | ||
163 | |||
164 | <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button" | ||
165 | [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"> | ||
166 | |||
167 | <ng-container *ngIf="isAdvancedCustomizationCollapsed"> | ||
168 | <span class="glyphicon glyphicon-menu-down"></span> | ||
169 | |||
170 | <ng-container i18n> | ||
171 | More customization | ||
172 | </ng-container> | ||
173 | </ng-container> | ||
174 | |||
175 | <ng-container *ngIf="!isAdvancedCustomizationCollapsed"> | ||
176 | <span class="glyphicon glyphicon-menu-up"></span> | ||
177 | |||
178 | <ng-container i18n> | ||
179 | Less customization | ||
180 | </ng-container> | ||
181 | </ng-container> | ||
182 | </div> | ||
183 | </div> | ||
184 | </div> | ||
185 | </div> | ||
186 | |||
187 | </ng-template> | ||
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 deleted file mode 100644 index 091d4dc3b..000000000 --- a/client/src/app/videos/+video-watch/modal/video-share.component.scss +++ /dev/null | |||
@@ -1,79 +0,0 @@ | |||
1 | @import '_mixins'; | ||
2 | @import '_variables'; | ||
3 | |||
4 | my-input-readonly-copy { | ||
5 | width: 100%; | ||
6 | } | ||
7 | |||
8 | .title-page.title-page-single { | ||
9 | margin-top: 0; | ||
10 | } | ||
11 | |||
12 | .playlist { | ||
13 | margin-bottom: 50px; | ||
14 | } | ||
15 | |||
16 | .peertube-select-container { | ||
17 | @include peertube-select-container(200px); | ||
18 | } | ||
19 | |||
20 | .qr-code-group { | ||
21 | text-align: center; | ||
22 | } | ||
23 | |||
24 | .nav-content { | ||
25 | margin-top: 30px; | ||
26 | display: flex; | ||
27 | justify-content: center; | ||
28 | align-items: center; | ||
29 | flex-direction: column; | ||
30 | } | ||
31 | |||
32 | .alert { | ||
33 | margin-top: 20px; | ||
34 | } | ||
35 | |||
36 | .filters { | ||
37 | margin-top: 30px; | ||
38 | |||
39 | .advanced-filters-button { | ||
40 | display: flex; | ||
41 | justify-content: center; | ||
42 | align-items: center; | ||
43 | margin-top: 20px; | ||
44 | font-size: 16px; | ||
45 | font-weight: $font-semibold; | ||
46 | cursor: pointer; | ||
47 | |||
48 | .glyphicon { | ||
49 | margin-right: 5px; | ||
50 | } | ||
51 | } | ||
52 | |||
53 | .form-group { | ||
54 | margin-bottom: 0; | ||
55 | height: 34px; | ||
56 | display: flex; | ||
57 | align-items: center; | ||
58 | } | ||
59 | |||
60 | .video-caption-block { | ||
61 | display: flex; | ||
62 | align-items: center; | ||
63 | |||
64 | .peertube-select-container { | ||
65 | margin-left: 10px; | ||
66 | } | ||
67 | } | ||
68 | |||
69 | .start-at, | ||
70 | .stop-at { | ||
71 | width: 300px; | ||
72 | display: flex; | ||
73 | align-items: center; | ||
74 | |||
75 | my-timestamp-input { | ||
76 | margin-left: 10px; | ||
77 | } | ||
78 | } | ||
79 | } | ||
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 deleted file mode 100644 index b42b775c1..000000000 --- a/client/src/app/videos/+video-watch/modal/video-share.component.ts +++ /dev/null | |||
@@ -1,126 +0,0 @@ | |||
1 | import { Component, ElementRef, Input, ViewChild } from '@angular/core' | ||
2 | import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' | ||
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
4 | import { VideoCaption } from '@shared/models' | ||
5 | import { VideoDetails } from '@app/shared/shared-main' | ||
6 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' | ||
7 | |||
8 | type Customizations = { | ||
9 | startAtCheckbox: boolean | ||
10 | startAt: number | ||
11 | |||
12 | stopAtCheckbox: boolean | ||
13 | stopAt: number | ||
14 | |||
15 | subtitleCheckbox: boolean | ||
16 | subtitle: string | ||
17 | |||
18 | loop: boolean | ||
19 | autoplay: boolean | ||
20 | muted: boolean | ||
21 | title: boolean | ||
22 | warningTitle: boolean | ||
23 | controls: boolean | ||
24 | } | ||
25 | |||
26 | @Component({ | ||
27 | selector: 'my-video-share', | ||
28 | templateUrl: './video-share.component.html', | ||
29 | styleUrls: [ './video-share.component.scss' ] | ||
30 | }) | ||
31 | export class VideoShareComponent { | ||
32 | @ViewChild('modal', { static: true }) modal: ElementRef | ||
33 | |||
34 | @Input() video: VideoDetails = null | ||
35 | @Input() videoCaptions: VideoCaption[] = [] | ||
36 | @Input() playlist: VideoPlaylist = null | ||
37 | |||
38 | activeId: 'url' | 'qrcode' | 'embed' = 'url' | ||
39 | customizations: Customizations | ||
40 | isAdvancedCustomizationCollapsed = true | ||
41 | includeVideoInPlaylist = false | ||
42 | |||
43 | constructor (private modalService: NgbModal) { } | ||
44 | |||
45 | show (currentVideoTimestamp?: number) { | ||
46 | let subtitle: string | ||
47 | if (this.videoCaptions.length !== 0) { | ||
48 | subtitle = this.videoCaptions[0].language.id | ||
49 | } | ||
50 | |||
51 | this.customizations = { | ||
52 | startAtCheckbox: false, | ||
53 | startAt: currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0, | ||
54 | |||
55 | stopAtCheckbox: false, | ||
56 | stopAt: this.video.duration, | ||
57 | |||
58 | subtitleCheckbox: false, | ||
59 | subtitle, | ||
60 | |||
61 | loop: false, | ||
62 | autoplay: false, | ||
63 | muted: false, | ||
64 | |||
65 | // Embed options | ||
66 | title: true, | ||
67 | warningTitle: true, | ||
68 | controls: true | ||
69 | } | ||
70 | |||
71 | this.modalService.open(this.modal, { centered: true }) | ||
72 | } | ||
73 | |||
74 | getVideoIframeCode () { | ||
75 | const options = this.getOptions(this.video.embedUrl) | ||
76 | |||
77 | const embedUrl = buildVideoLink(options) | ||
78 | return buildVideoEmbed(embedUrl) | ||
79 | } | ||
80 | |||
81 | getVideoUrl () { | ||
82 | const baseUrl = window.location.origin + '/videos/watch/' + this.video.uuid | ||
83 | const options = this.getOptions(baseUrl) | ||
84 | |||
85 | return buildVideoLink(options) | ||
86 | } | ||
87 | |||
88 | getPlaylistUrl () { | ||
89 | const base = window.location.origin + '/videos/watch/playlist/' + this.playlist.uuid | ||
90 | |||
91 | if (!this.includeVideoInPlaylist) return base | ||
92 | |||
93 | return base + '?videoId=' + this.video.uuid | ||
94 | } | ||
95 | |||
96 | notSecure () { | ||
97 | return window.location.protocol === 'http:' | ||
98 | } | ||
99 | |||
100 | isInEmbedTab () { | ||
101 | return this.activeId === 'embed' | ||
102 | } | ||
103 | |||
104 | hasPlaylist () { | ||
105 | return !!this.playlist | ||
106 | } | ||
107 | |||
108 | private getOptions (baseUrl?: string) { | ||
109 | return { | ||
110 | baseUrl, | ||
111 | |||
112 | startTime: this.customizations.startAtCheckbox ? this.customizations.startAt : undefined, | ||
113 | stopTime: this.customizations.stopAtCheckbox ? this.customizations.stopAt : undefined, | ||
114 | |||
115 | subtitle: this.customizations.subtitleCheckbox ? this.customizations.subtitle : undefined, | ||
116 | |||
117 | loop: this.customizations.loop, | ||
118 | autoplay: this.customizations.autoplay, | ||
119 | muted: this.customizations.muted, | ||
120 | |||
121 | title: this.customizations.title, | ||
122 | warningTitle: this.customizations.warningTitle, | ||
123 | controls: this.customizations.controls | ||
124 | } | ||
125 | } | ||
126 | } | ||
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 deleted file mode 100644 index 935656d23..000000000 --- a/client/src/app/videos/+video-watch/modal/video-support.component.html +++ /dev/null | |||
@@ -1,15 +0,0 @@ | |||
1 | <ng-template #modal let-hide="close"> | ||
2 | <div class="modal-header"> | ||
3 | <h4 i18n class="modal-title">Support {{ video.account.displayName }}</h4> | ||
4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | ||
5 | </div> | ||
6 | |||
7 | <div class="modal-body" [innerHTML]="videoHTMLSupport"></div> | ||
8 | |||
9 | <div class="modal-footer inputs"> | ||
10 | <input | ||
11 | type="button" role="button" i18n-value value="Maybe later" class="action-button action-button-cancel" | ||
12 | (click)="hide()" (key.enter)="hide()" | ||
13 | > | ||
14 | </div> | ||
15 | </ng-template> | ||
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 deleted file mode 100644 index 184e09027..000000000 --- a/client/src/app/videos/+video-watch/modal/video-support.component.scss +++ /dev/null | |||
@@ -1,3 +0,0 @@ | |||
1 | .action-button-cancel { | ||
2 | margin-right: 0 !important; | ||
3 | } | ||
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 deleted file mode 100644 index 48d5f2948..000000000 --- a/client/src/app/videos/+video-watch/modal/video-support.component.ts +++ /dev/null | |||
@@ -1,29 +0,0 @@ | |||
1 | import { Component, Input, ViewChild } from '@angular/core' | ||
2 | import { MarkdownService } from '@app/core' | ||
3 | import { VideoDetails } from '@app/shared/shared-main' | ||
4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-video-support', | ||
8 | templateUrl: './video-support.component.html', | ||
9 | styleUrls: [ './video-support.component.scss' ] | ||
10 | }) | ||
11 | export class VideoSupportComponent { | ||
12 | @Input() video: VideoDetails = null | ||
13 | |||
14 | @ViewChild('modal', { static: true }) modal: NgbModal | ||
15 | |||
16 | videoHTMLSupport = '' | ||
17 | |||
18 | constructor ( | ||
19 | private markdownService: MarkdownService, | ||
20 | private modalService: NgbModal | ||
21 | ) { } | ||
22 | |||
23 | show () { | ||
24 | this.modalService.open(this.modal, { centered: true }) | ||
25 | |||
26 | this.markdownService.enhancedMarkdownToHTML(this.video.support) | ||
27 | .then(r => this.videoHTMLSupport = r) | ||
28 | } | ||
29 | } | ||
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 deleted file mode 100644 index 45e023695..000000000 --- a/client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts +++ /dev/null | |||
@@ -1,39 +0,0 @@ | |||
1 | import { Directive, EventEmitter, HostListener, Output } from '@angular/core' | ||
2 | |||
3 | @Directive({ | ||
4 | selector: '[timestampRouteTransformer]' | ||
5 | }) | ||
6 | export class TimestampRouteTransformerDirective { | ||
7 | @Output() timestampClicked = new EventEmitter<number>() | ||
8 | |||
9 | @HostListener('click', ['$event']) | ||
10 | public onClick ($event: Event) { | ||
11 | const target = $event.target as HTMLLinkElement | ||
12 | |||
13 | if (target.hasAttribute('href') !== true) return | ||
14 | |||
15 | const ngxLink = document.createElement('a') | ||
16 | ngxLink.href = target.getAttribute('href') | ||
17 | |||
18 | // we only care about reflective links | ||
19 | if (ngxLink.host !== window.location.host) return | ||
20 | |||
21 | const ngxLinkParams = new URLSearchParams(ngxLink.search) | ||
22 | if (ngxLinkParams.has('start') !== true) return | ||
23 | |||
24 | const separators = ['h', 'm', 's'] | ||
25 | const start = ngxLinkParams | ||
26 | .get('start') | ||
27 | .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator | ||
28 | .map(t => { | ||
29 | if (t.includes('h')) return parseInt(t, 10) * 3600 | ||
30 | if (t.includes('m')) return parseInt(t, 10) * 60 | ||
31 | return parseInt(t, 10) | ||
32 | }) | ||
33 | .reduce((acc, t) => acc + t) | ||
34 | |||
35 | this.timestampClicked.emit(start) | ||
36 | |||
37 | $event.preventDefault() | ||
38 | } | ||
39 | } | ||
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 deleted file mode 100644 index 4b6767415..000000000 --- a/client/src/app/videos/+video-watch/video-duration-formatter.pipe.ts +++ /dev/null | |||
@@ -1,28 +0,0 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
3 | |||
4 | @Pipe({ | ||
5 | name: 'myVideoDurationFormatter' | ||
6 | }) | ||
7 | export class VideoDurationPipe implements PipeTransform { | ||
8 | |||
9 | constructor (private i18n: I18n) { | ||
10 | |||
11 | } | ||
12 | |||
13 | transform (value: number): string { | ||
14 | const hours = Math.floor(value / 3600) | ||
15 | const minutes = Math.floor((value % 3600) / 60) | ||
16 | const seconds = value % 60 | ||
17 | |||
18 | if (hours > 0) { | ||
19 | return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds }) | ||
20 | } | ||
21 | |||
22 | if (minutes > 0) { | ||
23 | return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds }) | ||
24 | } | ||
25 | |||
26 | return this.i18n('{{seconds}} sec', { seconds }) | ||
27 | } | ||
28 | } | ||
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 deleted file mode 100644 index 246ef83cf..000000000 --- a/client/src/app/videos/+video-watch/video-watch-playlist.component.html +++ /dev/null | |||
@@ -1,46 +0,0 @@ | |||
1 | <div *ngIf="playlist && video" class="playlist" myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"> | ||
2 | <div class="playlist-info"> | ||
3 | <div class="playlist-display-name"> | ||
4 | {{ playlist.displayName }} | ||
5 | |||
6 | <span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span> | ||
7 | <span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span> | ||
8 | <span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span> | ||
9 | </div> | ||
10 | |||
11 | <div class="playlist-by-index"> | ||
12 | <div class="playlist-by">{{ playlist.ownerBy }}</div> | ||
13 | <div class="playlist-index"> | ||
14 | <span>{{ currentPlaylistPosition }}</span><span>{{ playlistPagination.totalItems }}</span> | ||
15 | </div> | ||
16 | </div> | ||
17 | |||
18 | <div class="playlist-controls"> | ||
19 | <my-global-icon | ||
20 | iconName="videos" | ||
21 | [class.active]="autoPlayNextVideoPlaylist" | ||
22 | (click)="switchAutoPlayNextVideoPlaylist()" | ||
23 | [ngbTooltip]="autoPlayNextVideoPlaylistSwitchText" | ||
24 | placement="bottom auto" | ||
25 | container="body" | ||
26 | ></my-global-icon> | ||
27 | |||
28 | <my-global-icon | ||
29 | iconName="repeat" | ||
30 | [class.active]="loopPlaylist" | ||
31 | (click)="switchLoopPlaylist()" | ||
32 | [ngbTooltip]="loopPlaylistSwitchText" | ||
33 | placement="bottom auto" | ||
34 | container="body" | ||
35 | ></my-global-icon> | ||
36 | </div> | ||
37 | </div> | ||
38 | |||
39 | <div *ngFor="let playlistElement of playlistElements"> | ||
40 | <my-video-playlist-element-miniature | ||
41 | [playlistElement]="playlistElement" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)" | ||
42 | [playing]="currentPlaylistPosition === playlistElement.position" [accountLink]="false" [position]="playlistElement.position" | ||
43 | [touchScreenEditButton]="true" | ||
44 | ></my-video-playlist-element-miniature> | ||
45 | </div> | ||
46 | </div> | ||
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 deleted file mode 100644 index 0b0a2a899..000000000 --- a/client/src/app/videos/+video-watch/video-watch-playlist.component.scss +++ /dev/null | |||
@@ -1,83 +0,0 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | @import '_bootstrap-variables'; | ||
4 | @import '_miniature'; | ||
5 | |||
6 | .playlist { | ||
7 | min-width: 200px; | ||
8 | max-width: 470px; | ||
9 | height: 66vh; | ||
10 | background-color: pvar(--mainBackgroundColor); | ||
11 | overflow-y: auto; | ||
12 | border-bottom: 1px solid $separator-border-color; | ||
13 | |||
14 | .playlist-info { | ||
15 | padding: 5px 30px; | ||
16 | background-color: #e4e4e4; | ||
17 | |||
18 | .playlist-display-name { | ||
19 | font-size: 18px; | ||
20 | font-weight: $font-semibold; | ||
21 | margin-bottom: 5px; | ||
22 | } | ||
23 | |||
24 | .playlist-by-index { | ||
25 | color: pvar(--greyForegroundColor); | ||
26 | display: flex; | ||
27 | |||
28 | .playlist-by { | ||
29 | margin-right: 5px; | ||
30 | } | ||
31 | |||
32 | .playlist-index span:first-child::after { | ||
33 | content: '/'; | ||
34 | margin: 0 3px; | ||
35 | } | ||
36 | } | ||
37 | |||
38 | .playlist-controls { | ||
39 | display: flex; | ||
40 | margin: 10px 0; | ||
41 | |||
42 | my-global-icon:not(:last-child) { | ||
43 | margin-right: .5rem; | ||
44 | } | ||
45 | |||
46 | my-global-icon { | ||
47 | &:not(.active) { | ||
48 | opacity: .5 | ||
49 | } | ||
50 | |||
51 | ::ng-deep { | ||
52 | cursor: pointer; | ||
53 | } | ||
54 | } | ||
55 | } | ||
56 | } | ||
57 | |||
58 | my-video-playlist-element-miniature { | ||
59 | ::ng-deep { | ||
60 | .video { | ||
61 | .position { | ||
62 | margin-right: 0; | ||
63 | } | ||
64 | |||
65 | .video-info { | ||
66 | .video-info-name { | ||
67 | font-size: 15px; | ||
68 | } | ||
69 | } | ||
70 | } | ||
71 | |||
72 | my-video-thumbnail { | ||
73 | @include thumbnail-size-component(90px, 50px); | ||
74 | } | ||
75 | |||
76 | .fake-thumbnail { | ||
77 | width: 90px; | ||
78 | height: 50px; | ||
79 | } | ||
80 | } | ||
81 | } | ||
82 | } | ||
83 | |||
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 deleted file mode 100644 index 2c21be643..000000000 --- a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts +++ /dev/null | |||
@@ -1,201 +0,0 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core' | ||
4 | import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage' | ||
5 | import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-video-watch-playlist', | ||
11 | templateUrl: './video-watch-playlist.component.html', | ||
12 | styleUrls: [ './video-watch-playlist.component.scss' ] | ||
13 | }) | ||
14 | export class VideoWatchPlaylistComponent { | ||
15 | static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist' | ||
16 | static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist' | ||
17 | |||
18 | @Input() video: VideoDetails | ||
19 | @Input() playlist: VideoPlaylist | ||
20 | |||
21 | playlistElements: VideoPlaylistElement[] = [] | ||
22 | playlistPagination: ComponentPagination = { | ||
23 | currentPage: 1, | ||
24 | itemsPerPage: 30, | ||
25 | totalItems: null | ||
26 | } | ||
27 | |||
28 | autoPlayNextVideoPlaylist: boolean | ||
29 | autoPlayNextVideoPlaylistSwitchText = '' | ||
30 | loopPlaylist: boolean | ||
31 | loopPlaylistSwitchText = '' | ||
32 | noPlaylistVideos = false | ||
33 | currentPlaylistPosition = 1 | ||
34 | |||
35 | constructor ( | ||
36 | private userService: UserService, | ||
37 | private auth: AuthService, | ||
38 | private notifier: Notifier, | ||
39 | private i18n: I18n, | ||
40 | private videoPlaylist: VideoPlaylistService, | ||
41 | private localStorageService: LocalStorageService, | ||
42 | private sessionStorageService: SessionStorageService, | ||
43 | private router: Router | ||
44 | ) { | ||
45 | // defaults to true | ||
46 | this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() | ||
47 | ? this.auth.getUser().autoPlayNextVideoPlaylist | ||
48 | : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' | ||
49 | this.setAutoPlayNextVideoPlaylistSwitchText() | ||
50 | |||
51 | // defaults to false | ||
52 | this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' | ||
53 | this.setLoopPlaylistSwitchText() | ||
54 | } | ||
55 | |||
56 | onPlaylistVideosNearOfBottom () { | ||
57 | // Last page | ||
58 | if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return | ||
59 | |||
60 | this.playlistPagination.currentPage += 1 | ||
61 | this.loadPlaylistElements(this.playlist,false) | ||
62 | } | ||
63 | |||
64 | onElementRemoved (playlistElement: VideoPlaylistElement) { | ||
65 | this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id) | ||
66 | |||
67 | this.playlistPagination.totalItems-- | ||
68 | } | ||
69 | |||
70 | isPlaylistOwned () { | ||
71 | return this.playlist.isLocal === true && | ||
72 | this.auth.isLoggedIn() && | ||
73 | this.playlist.ownerAccount.name === this.auth.getUser().username | ||
74 | } | ||
75 | |||
76 | isUnlistedPlaylist () { | ||
77 | return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED | ||
78 | } | ||
79 | |||
80 | isPrivatePlaylist () { | ||
81 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE | ||
82 | } | ||
83 | |||
84 | isPublicPlaylist () { | ||
85 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC | ||
86 | } | ||
87 | |||
88 | loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) { | ||
89 | this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination) | ||
90 | .subscribe(({ total, data }) => { | ||
91 | this.playlistElements = this.playlistElements.concat(data) | ||
92 | this.playlistPagination.totalItems = total | ||
93 | |||
94 | const firstAvailableVideos = this.playlistElements.find(e => !!e.video) | ||
95 | if (!firstAvailableVideos) { | ||
96 | this.noPlaylistVideos = true | ||
97 | return | ||
98 | } | ||
99 | |||
100 | this.updatePlaylistIndex(this.video) | ||
101 | |||
102 | if (redirectToFirst) { | ||
103 | const extras = { | ||
104 | queryParams: { | ||
105 | start: firstAvailableVideos.startTimestamp, | ||
106 | stop: firstAvailableVideos.stopTimestamp, | ||
107 | videoId: firstAvailableVideos.video.uuid | ||
108 | }, | ||
109 | replaceUrl: true | ||
110 | } | ||
111 | this.router.navigate([], extras) | ||
112 | } | ||
113 | }) | ||
114 | } | ||
115 | |||
116 | updatePlaylistIndex (video: VideoDetails) { | ||
117 | if (this.playlistElements.length === 0 || !video) return | ||
118 | |||
119 | for (const playlistElement of this.playlistElements) { | ||
120 | if (playlistElement.video && playlistElement.video.id === video.id) { | ||
121 | this.currentPlaylistPosition = playlistElement.position | ||
122 | return | ||
123 | } | ||
124 | } | ||
125 | |||
126 | // Load more videos to find our video | ||
127 | this.onPlaylistVideosNearOfBottom() | ||
128 | } | ||
129 | |||
130 | findNextPlaylistVideo (position = this.currentPlaylistPosition): VideoPlaylistElement { | ||
131 | if (this.currentPlaylistPosition >= this.playlistPagination.totalItems) { | ||
132 | // we have reached the end of the playlist: either loop or stop | ||
133 | if (this.loopPlaylist) { | ||
134 | this.currentPlaylistPosition = position = 0 | ||
135 | } else { | ||
136 | return | ||
137 | } | ||
138 | } | ||
139 | |||
140 | const next = this.playlistElements.find(e => e.position === position) | ||
141 | |||
142 | if (!next || !next.video) { | ||
143 | return this.findNextPlaylistVideo(position + 1) | ||
144 | } | ||
145 | |||
146 | return next | ||
147 | } | ||
148 | |||
149 | navigateToNextPlaylistVideo () { | ||
150 | const next = this.findNextPlaylistVideo(this.currentPlaylistPosition + 1) | ||
151 | if (!next) return | ||
152 | const start = next.startTimestamp | ||
153 | const stop = next.stopTimestamp | ||
154 | this.router.navigate([],{ queryParams: { videoId: next.video.uuid, start, stop } }) | ||
155 | } | ||
156 | |||
157 | switchAutoPlayNextVideoPlaylist () { | ||
158 | this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist | ||
159 | this.setAutoPlayNextVideoPlaylistSwitchText() | ||
160 | |||
161 | peertubeLocalStorage.setItem( | ||
162 | VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, | ||
163 | this.autoPlayNextVideoPlaylist.toString() | ||
164 | ) | ||
165 | |||
166 | if (this.auth.isLoggedIn()) { | ||
167 | const details = { | ||
168 | autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist | ||
169 | } | ||
170 | |||
171 | this.userService.updateMyProfile(details).subscribe( | ||
172 | () => { | ||
173 | this.auth.refreshUserInformation() | ||
174 | }, | ||
175 | err => this.notifier.error(err.message) | ||
176 | ) | ||
177 | } | ||
178 | } | ||
179 | |||
180 | switchLoopPlaylist () { | ||
181 | this.loopPlaylist = !this.loopPlaylist | ||
182 | this.setLoopPlaylistSwitchText() | ||
183 | |||
184 | peertubeSessionStorage.setItem( | ||
185 | VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, | ||
186 | this.loopPlaylist.toString() | ||
187 | ) | ||
188 | } | ||
189 | |||
190 | private setAutoPlayNextVideoPlaylistSwitchText () { | ||
191 | this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist | ||
192 | ? this.i18n('Stop autoplaying next video') | ||
193 | : this.i18n('Autoplay next video') | ||
194 | } | ||
195 | |||
196 | private setLoopPlaylistSwitchText () { | ||
197 | this.loopPlaylistSwitchText = this.loopPlaylist | ||
198 | ? this.i18n('Stop looping playlist videos') | ||
199 | : this.i18n('Loop playlist videos') | ||
200 | } | ||
201 | } | ||
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 deleted file mode 100644 index d8fecb87d..000000000 --- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts +++ /dev/null | |||
@@ -1,27 +0,0 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { MetaGuard } from '@ngx-meta/core' | ||
4 | import { VideoWatchComponent } from './video-watch.component' | ||
5 | |||
6 | const videoWatchRoutes: Routes = [ | ||
7 | { | ||
8 | path: 'playlist/:playlistId', | ||
9 | component: VideoWatchComponent, | ||
10 | canActivate: [ MetaGuard ] | ||
11 | }, | ||
12 | { | ||
13 | path: ':videoId/comments/:commentId', | ||
14 | redirectTo: ':videoId' | ||
15 | }, | ||
16 | { | ||
17 | path: ':videoId', | ||
18 | component: VideoWatchComponent, | ||
19 | canActivate: [ MetaGuard ] | ||
20 | } | ||
21 | ] | ||
22 | |||
23 | @NgModule({ | ||
24 | imports: [ RouterModule.forChild(videoWatchRoutes) ], | ||
25 | exports: [ RouterModule ] | ||
26 | }) | ||
27 | 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 deleted file mode 100644 index 0447268f0..000000000 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ /dev/null | |||
@@ -1,277 +0,0 @@ | |||
1 | <div class="root" [ngClass]="{ 'theater-enabled': theaterEnabled }"> | ||
2 | <!-- We need the video container for videojs so we just hide it --> | ||
3 | <div id="video-wrapper"> | ||
4 | <div *ngIf="remoteServerDown" class="remote-server-down"> | ||
5 | Sorry, but this video is not available because the remote instance is not responding. | ||
6 | <br /> | ||
7 | Please try again later. | ||
8 | </div> | ||
9 | |||
10 | <div id="videojs-wrapper"></div> | ||
11 | |||
12 | <my-video-watch-playlist | ||
13 | #videoWatchPlaylist | ||
14 | [video]="video" [playlist]="playlist" class="playlist" | ||
15 | ></my-video-watch-playlist> | ||
16 | </div> | ||
17 | |||
18 | <div class="row"> | ||
19 | <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToImport()"> | ||
20 | The video is being imported, it will be available when the import is finished. | ||
21 | </div> | ||
22 | |||
23 | <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToTranscode()"> | ||
24 | The video is being transcoded, it may not work properly. | ||
25 | </div> | ||
26 | |||
27 | <div i18n class="col-md-12 alert alert-info" *ngIf="hasVideoScheduledPublication()"> | ||
28 | This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. | ||
29 | </div> | ||
30 | |||
31 | <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted"> | ||
32 | <div class="blocked-label" i18n>This video is blocked.</div> | ||
33 | {{ video.blockedReason }} | ||
34 | </div> | ||
35 | </div> | ||
36 | |||
37 | <!-- Video information --> | ||
38 | <div *ngIf="video" class="margin-content video-bottom"> | ||
39 | <div class="video-info"> | ||
40 | <div class="video-info-first-row"> | ||
41 | <div> | ||
42 | <div class="d-block d-md-none"> <!-- only shown on medium devices, has its counterpart for larger viewports below --> | ||
43 | <h1 class="video-info-name">{{ video.name }}</h1> | ||
44 | |||
45 | <div i18n class="video-info-date-views"> | ||
46 | Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span> | ||
47 | </div> | ||
48 | </div> | ||
49 | |||
50 | <div class="d-flex justify-content-between flex-direction-column"> | ||
51 | <div class="d-none d-md-block"> | ||
52 | <h1 class="video-info-name">{{ video.name }}</h1> | ||
53 | </div> | ||
54 | |||
55 | <div class="video-info-first-row-bottom"> | ||
56 | <div i18n class="d-none d-md-block video-info-date-views"> | ||
57 | Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span> | ||
58 | </div> | ||
59 | |||
60 | <div class="video-actions-rates"> | ||
61 | <div class="video-actions fullWidth justify-content-end"> | ||
62 | <button | ||
63 | [ngbPopover]="getRatePopoverText()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" (keyup.enter)="setLike()" | ||
64 | class="action-button action-button-like" [attr.aria-pressed]="userRating === 'like'" [attr.aria-label]="tooltipLike" | ||
65 | [ngbTooltip]="tooltipLike" | ||
66 | placement="bottom auto" | ||
67 | > | ||
68 | <my-global-icon iconName="like"></my-global-icon> | ||
69 | <span *ngIf="video.likes" class="count">{{ video.likes }}</span> | ||
70 | </button> | ||
71 | |||
72 | <button | ||
73 | [ngbPopover]="getRatePopoverText()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" (keyup.enter)="setDislike()" | ||
74 | class="action-button action-button-dislike" [attr.aria-pressed]="userRating === 'dislike'" [attr.aria-label]="tooltipDislike" | ||
75 | [ngbTooltip]="tooltipDislike" | ||
76 | placement="bottom auto" | ||
77 | > | ||
78 | <my-global-icon iconName="dislike"></my-global-icon> | ||
79 | <span *ngIf="video.dislikes" class="count">{{ video.dislikes }}</span> | ||
80 | </button> | ||
81 | |||
82 | <button *ngIf="video.support" (click)="showSupportModal()" (keyup.enter)="showSupportModal()" class="action-button action-button-support" [attr.aria-label]="tooltipSupport" | ||
83 | [ngbTooltip]="tooltipSupport" | ||
84 | placement="bottom auto" | ||
85 | > | ||
86 | <my-global-icon iconName="support" aria-hidden="true"></my-global-icon> | ||
87 | <span class="icon-text" i18n>SUPPORT</span> | ||
88 | </button> | ||
89 | |||
90 | <button (click)="showShareModal()" (keyup.enter)="showShareModal()" class="action-button"> | ||
91 | <my-global-icon iconName="share" aria-hidden="true"></my-global-icon> | ||
92 | <span class="icon-text" i18n>SHARE</span> | ||
93 | </button> | ||
94 | |||
95 | <div | ||
96 | class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" | ||
97 | *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)" | ||
98 | [ngbTooltip]="tooltipSaveToPlaylist" | ||
99 | placement="bottom auto" | ||
100 | > | ||
101 | <button class="action-button action-button-save" ngbDropdownToggle> | ||
102 | <my-global-icon iconName="playlist-add" aria-hidden="true"></my-global-icon> | ||
103 | <span class="icon-text" i18n>SAVE</span> | ||
104 | </button> | ||
105 | |||
106 | <div ngbDropdownMenu> | ||
107 | <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist> | ||
108 | </div> | ||
109 | </div> | ||
110 | |||
111 | <my-video-actions-dropdown | ||
112 | placement="bottom auto" buttonDirection="horizontal" [buttonStyled]="true" [video]="video" [videoCaptions]="videoCaptions" | ||
113 | (videoRemoved)="onVideoRemoved()" (modalOpened)="onModalOpened()" | ||
114 | ></my-video-actions-dropdown> | ||
115 | </div> | ||
116 | |||
117 | <div class="video-info-likes-dislikes-bar-outer-container"> | ||
118 | <div | ||
119 | class="video-info-likes-dislikes-bar-inner-container" | ||
120 | *ngIf="video.likes !== 0 || video.dislikes !== 0" | ||
121 | [ngbTooltip]="likesBarTooltipText" | ||
122 | placement="bottom" | ||
123 | > | ||
124 | <div | ||
125 | class="video-info-likes-dislikes-bar" | ||
126 | > | ||
127 | <div class="likes-bar" [ngClass]="{ 'liked': userRating !== 'none' }" [ngStyle]="{ 'width.%': video.likesPercent }"></div> | ||
128 | </div> | ||
129 | </div> | ||
130 | </div> | ||
131 | </div> | ||
132 | |||
133 | <div | ||
134 | class="video-info-likes-dislikes-bar" | ||
135 | *ngIf="video.likes !== 0 || video.dislikes !== 0" | ||
136 | [ngbTooltip]="likesBarTooltipText" | ||
137 | placement="bottom" | ||
138 | > | ||
139 | <div class="likes-bar" [ngStyle]="{ 'width.%': video.likesPercent }"></div> | ||
140 | </div> | ||
141 | </div> | ||
142 | </div> | ||
143 | |||
144 | |||
145 | <div class="pt-3 border-top video-info-channel d-flex"> | ||
146 | <div class="video-info-channel-left d-flex"> | ||
147 | <avatar-channel [video]="video"></avatar-channel> | ||
148 | |||
149 | <div class="video-info-channel-left-links ml-1"> | ||
150 | <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Channel page"> | ||
151 | {{ video.channel.displayName }} | ||
152 | </a> | ||
153 | <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Account page"> | ||
154 | <span i18n>By {{ video.byAccount }}</span> | ||
155 | </a> | ||
156 | </div> | ||
157 | </div> | ||
158 | |||
159 | <my-subscribe-button #subscribeButton [videoChannels]="[video.channel]" size="small"></my-subscribe-button> | ||
160 | </div> | ||
161 | </div> | ||
162 | |||
163 | </div> | ||
164 | |||
165 | <div class="video-info-description"> | ||
166 | <div | ||
167 | class="video-info-description-html" | ||
168 | [innerHTML]="videoHTMLDescription" | ||
169 | (timestampClicked)="handleTimestampClicked($event)" | ||
170 | timestampRouteTransformer | ||
171 | ></div> | ||
172 | |||
173 | <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()"> | ||
174 | <ng-container i18n>Show more</ng-container> | ||
175 | <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span> | ||
176 | <my-small-loader class="description-loading" [loading]="descriptionLoading"></my-small-loader> | ||
177 | </div> | ||
178 | |||
179 | <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more"> | ||
180 | <ng-container i18n>Show less</ng-container> | ||
181 | <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span> | ||
182 | </div> | ||
183 | </div> | ||
184 | |||
185 | <div class="video-attributes mb-3"> | ||
186 | <div class="video-attribute"> | ||
187 | <span i18n class="video-attribute-label">Privacy</span> | ||
188 | <span class="video-attribute-value">{{ video.privacy.label }}</span> | ||
189 | </div> | ||
190 | |||
191 | <div *ngIf="video.isLocal === false" class="video-attribute"> | ||
192 | <span i18n class="video-attribute-label">Origin instance</span> | ||
193 | <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="video.originInstanceUrl">{{ video.originInstanceHost }}</a> | ||
194 | </div> | ||
195 | |||
196 | <div *ngIf="!!video.originallyPublishedAt" class="video-attribute"> | ||
197 | <span i18n class="video-attribute-label">Originally published</span> | ||
198 | <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> | ||
199 | </div> | ||
200 | |||
201 | <div class="video-attribute"> | ||
202 | <span i18n class="video-attribute-label">Category</span> | ||
203 | <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span> | ||
204 | <a | ||
205 | *ngIf="video.category.id" class="video-attribute-value" | ||
206 | [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }" | ||
207 | >{{ video.category.label }}</a> | ||
208 | </div> | ||
209 | |||
210 | <div class="video-attribute"> | ||
211 | <span i18n class="video-attribute-label">Licence</span> | ||
212 | <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span> | ||
213 | <a | ||
214 | *ngIf="video.licence.id" class="video-attribute-value" | ||
215 | [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }" | ||
216 | >{{ video.licence.label }}</a> | ||
217 | </div> | ||
218 | |||
219 | <div class="video-attribute"> | ||
220 | <span i18n class="video-attribute-label">Language</span> | ||
221 | <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span> | ||
222 | <a | ||
223 | *ngIf="video.language.id" class="video-attribute-value" | ||
224 | [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }" | ||
225 | >{{ video.language.label }}</a> | ||
226 | </div> | ||
227 | |||
228 | <div class="video-attribute video-attribute-tags"> | ||
229 | <span i18n class="video-attribute-label">Tags</span> | ||
230 | <a | ||
231 | *ngFor="let tag of getVideoTags()" | ||
232 | class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }" | ||
233 | >{{ tag }}</a> | ||
234 | </div> | ||
235 | |||
236 | <div class="video-attribute"> | ||
237 | <span i18n class="video-attribute-label">Duration</span> | ||
238 | <span class="video-attribute-value">{{ video.duration | myVideoDurationFormatter }}</span> | ||
239 | </div> | ||
240 | </div> | ||
241 | |||
242 | <my-video-comments | ||
243 | class="border-top" | ||
244 | [video]="video" | ||
245 | [user]="user" | ||
246 | (timestampClicked)="handleTimestampClicked($event)" | ||
247 | ></my-video-comments> | ||
248 | </div> | ||
249 | |||
250 | <my-recommended-videos | ||
251 | [inputRecommendation]="{ uuid: video.uuid, tags: video.tags }" | ||
252 | [playlist]="playlist" | ||
253 | (gotRecommendations)="onRecommendations($event)" | ||
254 | ></my-recommended-videos> | ||
255 | </div> | ||
256 | |||
257 | <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false"> | ||
258 | <div class="privacy-concerns-text"> | ||
259 | <span class="mr-2"> | ||
260 | <strong i18n>Friendly Reminder: </strong> | ||
261 | <ng-container i18n> | ||
262 | 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. | ||
263 | </ng-container> | ||
264 | </span> | ||
265 | <a i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube#privacy">More information</a> | ||
266 | </div> | ||
267 | |||
268 | <div i18n class="privacy-concerns-button privacy-concerns-okay" (click)="acceptedPrivacyConcern()"> | ||
269 | OK | ||
270 | </div> | ||
271 | </div> | ||
272 | </div> | ||
273 | |||
274 | <ng-container *ngIf="video !== null"> | ||
275 | <my-video-support #videoSupportModal [video]="video"></my-video-support> | ||
276 | <my-video-share #videoShareModal [video]="video" [videoCaptions]="videoCaptions" [playlist]="playlist"></my-video-share> | ||
277 | </ng-container> | ||
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss deleted file mode 100644 index 2e083982e..000000000 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ /dev/null | |||
@@ -1,607 +0,0 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | @import '_bootstrap-variables'; | ||
4 | @import '_miniature'; | ||
5 | |||
6 | $player-factor: 1.7; // 16/9 | ||
7 | $video-info-margin-left: 44px; | ||
8 | |||
9 | @function getPlayerHeight($width){ | ||
10 | @return calc(#{$width} / #{$player-factor}) | ||
11 | } | ||
12 | |||
13 | @function getPlayerWidth($height){ | ||
14 | @return calc(#{$height} * #{$player-factor}) | ||
15 | } | ||
16 | |||
17 | @mixin playlist-below-player { | ||
18 | width: 100% !important; | ||
19 | height: auto !important; | ||
20 | max-height: 300px !important; | ||
21 | max-width: initial; | ||
22 | border-bottom: 1px solid $separator-border-color !important; | ||
23 | } | ||
24 | |||
25 | .root { | ||
26 | &.theater-enabled #video-wrapper { | ||
27 | flex-direction: column; | ||
28 | justify-content: center; | ||
29 | |||
30 | #videojs-wrapper { | ||
31 | width: 100%; | ||
32 | } | ||
33 | |||
34 | ::ng-deep .video-js { | ||
35 | $height: calc(100vh - #{$header-height} - #{$theater-bottom-space}); | ||
36 | |||
37 | height: $height; | ||
38 | width: 100%; | ||
39 | max-width: initial; | ||
40 | } | ||
41 | |||
42 | my-video-watch-playlist ::ng-deep .playlist { | ||
43 | @include playlist-below-player; | ||
44 | } | ||
45 | } | ||
46 | } | ||
47 | |||
48 | .blocked-label { | ||
49 | font-weight: $font-semibold; | ||
50 | } | ||
51 | |||
52 | #video-wrapper { | ||
53 | background-color: #000; | ||
54 | display: flex; | ||
55 | justify-content: center; | ||
56 | |||
57 | #videojs-wrapper { | ||
58 | display: flex; | ||
59 | justify-content: center; | ||
60 | flex-grow: 1; | ||
61 | } | ||
62 | |||
63 | .remote-server-down { | ||
64 | color: #fff; | ||
65 | display: flex; | ||
66 | flex-direction: column; | ||
67 | align-items: center; | ||
68 | text-align: center; | ||
69 | justify-content: center; | ||
70 | background-color: #141313; | ||
71 | width: 100%; | ||
72 | font-size: 24px; | ||
73 | height: 500px; | ||
74 | |||
75 | @media screen and (max-width: 1000px) { | ||
76 | font-size: 20px; | ||
77 | } | ||
78 | |||
79 | @media screen and (max-width: 600px) { | ||
80 | font-size: 16px; | ||
81 | } | ||
82 | } | ||
83 | |||
84 | ::ng-deep .video-js { | ||
85 | width: 100%; | ||
86 | max-width: getPlayerWidth(66vh); | ||
87 | height: 66vh; | ||
88 | |||
89 | // VideoJS create an inner video player | ||
90 | video { | ||
91 | outline: 0; | ||
92 | position: relative !important; | ||
93 | } | ||
94 | } | ||
95 | |||
96 | @media screen and (max-width: 600px) { | ||
97 | .remote-server-down, | ||
98 | ::ng-deep .video-js { | ||
99 | width: 100vw; | ||
100 | height: getPlayerHeight(100vw) | ||
101 | } | ||
102 | } | ||
103 | } | ||
104 | |||
105 | .alert { | ||
106 | text-align: center; | ||
107 | border-radius: 0; | ||
108 | } | ||
109 | |||
110 | .flex-direction-column { | ||
111 | flex-direction: column; | ||
112 | } | ||
113 | |||
114 | #video-not-found { | ||
115 | height: 300px; | ||
116 | line-height: 300px; | ||
117 | margin-top: 50px; | ||
118 | text-align: center; | ||
119 | font-weight: $font-semibold; | ||
120 | font-size: 15px; | ||
121 | } | ||
122 | |||
123 | .video-bottom { | ||
124 | display: flex; | ||
125 | margin-top: 1.5rem; | ||
126 | |||
127 | .video-info { | ||
128 | flex-grow: 1; | ||
129 | // Set min width for flex item | ||
130 | min-width: 1px; | ||
131 | max-width: 100%; | ||
132 | |||
133 | .video-info-first-row { | ||
134 | display: flex; | ||
135 | |||
136 | & > div:first-child { | ||
137 | flex-grow: 1; | ||
138 | } | ||
139 | |||
140 | .video-info-name { | ||
141 | margin-right: 30px; | ||
142 | min-height: 40px; // Align with the action buttons | ||
143 | font-size: 27px; | ||
144 | font-weight: $font-semibold; | ||
145 | flex-grow: 1; | ||
146 | } | ||
147 | |||
148 | .video-info-first-row-bottom { | ||
149 | display: flex; | ||
150 | flex-wrap: wrap; | ||
151 | align-items: center; | ||
152 | width: 100%; | ||
153 | } | ||
154 | |||
155 | .video-info-date-views { | ||
156 | align-self: start; | ||
157 | margin-bottom: 10px; | ||
158 | margin-right: 10px; | ||
159 | font-size: 1em; | ||
160 | } | ||
161 | |||
162 | .video-info-channel { | ||
163 | font-weight: $font-semibold; | ||
164 | font-size: 15px; | ||
165 | |||
166 | a { | ||
167 | @include disable-default-a-behaviour; | ||
168 | |||
169 | color: pvar(--mainForegroundColor); | ||
170 | |||
171 | &:hover { | ||
172 | opacity: 0.8; | ||
173 | } | ||
174 | |||
175 | img { | ||
176 | @include avatar(18px); | ||
177 | |||
178 | margin: -2px 5px 0 0; | ||
179 | } | ||
180 | } | ||
181 | |||
182 | .video-info-channel-left { | ||
183 | flex-grow: 1; | ||
184 | |||
185 | .video-info-channel-left-links { | ||
186 | display: flex; | ||
187 | flex-direction: column; | ||
188 | position: relative; | ||
189 | line-height: 1.37; | ||
190 | |||
191 | a:nth-of-type(2) { | ||
192 | font-weight: 500; | ||
193 | font-size: 90%; | ||
194 | } | ||
195 | } | ||
196 | } | ||
197 | |||
198 | my-subscribe-button { | ||
199 | margin-left: 5px; | ||
200 | } | ||
201 | } | ||
202 | |||
203 | my-feed { | ||
204 | margin-left: 5px; | ||
205 | margin-top: 1px; | ||
206 | } | ||
207 | |||
208 | .video-actions-rates { | ||
209 | margin: 0 0 10px 0; | ||
210 | align-items: start; | ||
211 | width: max-content; | ||
212 | margin-left: auto; | ||
213 | |||
214 | .video-actions { | ||
215 | height: 40px; // Align with the title | ||
216 | display: flex; | ||
217 | align-items: center; | ||
218 | |||
219 | .action-button:not(:first-child), | ||
220 | .action-dropdown, | ||
221 | my-video-actions-dropdown { | ||
222 | margin-left: 5px; | ||
223 | } | ||
224 | |||
225 | ::ng-deep.action-button { | ||
226 | @include peertube-button; | ||
227 | @include button-with-icon(21px, 0, -1px); | ||
228 | @include apply-svg-color(pvar(--actionButtonColor)); | ||
229 | |||
230 | font-size: 100%; | ||
231 | font-weight: $font-semibold; | ||
232 | display: inline-block; | ||
233 | padding: 0 10px 0 10px; | ||
234 | white-space: nowrap; | ||
235 | background-color: transparent !important; | ||
236 | color: pvar(--actionButtonColor); | ||
237 | text-transform: uppercase; | ||
238 | |||
239 | &::after { | ||
240 | display: none; | ||
241 | } | ||
242 | |||
243 | &:hover { | ||
244 | opacity: 0.9; | ||
245 | } | ||
246 | |||
247 | &.action-button-like, | ||
248 | &.action-button-dislike { | ||
249 | filter: brightness(120%); | ||
250 | |||
251 | .count { | ||
252 | margin-right: 5px; | ||
253 | } | ||
254 | } | ||
255 | |||
256 | &.action-button-like.activated { | ||
257 | .count { | ||
258 | color: pvar(--activatedActionButtonColor); | ||
259 | } | ||
260 | |||
261 | my-global-icon { | ||
262 | @include apply-svg-color(pvar(--activatedActionButtonColor)); | ||
263 | } | ||
264 | } | ||
265 | |||
266 | &.action-button-dislike.activated { | ||
267 | .count { | ||
268 | color: pvar(--activatedActionButtonColor); | ||
269 | } | ||
270 | |||
271 | my-global-icon { | ||
272 | @include apply-svg-color(pvar(--activatedActionButtonColor)); | ||
273 | } | ||
274 | } | ||
275 | |||
276 | &.action-button-support { | ||
277 | color: pvar(--supportButtonColor); | ||
278 | |||
279 | my-global-icon { | ||
280 | @include apply-svg-color(pvar(--supportButtonColor)); | ||
281 | } | ||
282 | } | ||
283 | |||
284 | &.action-button-support { | ||
285 | my-global-icon { | ||
286 | ::ng-deep path:first-child { | ||
287 | fill: pvar(--supportButtonHeartColor) !important; | ||
288 | } | ||
289 | } | ||
290 | } | ||
291 | |||
292 | &.action-button-save { | ||
293 | my-global-icon { | ||
294 | top: 0 !important; | ||
295 | right: -1px; | ||
296 | } | ||
297 | } | ||
298 | |||
299 | .icon-text { | ||
300 | margin-left: 3px; | ||
301 | } | ||
302 | } | ||
303 | } | ||
304 | |||
305 | .video-info-likes-dislikes-bar-outer-container { | ||
306 | position: relative; | ||
307 | } | ||
308 | |||
309 | .video-info-likes-dislikes-bar-inner-container { | ||
310 | position: absolute; | ||
311 | height: 20px; | ||
312 | } | ||
313 | |||
314 | .video-info-likes-dislikes-bar { | ||
315 | $likes-bar-height: 2px; | ||
316 | height: $likes-bar-height; | ||
317 | margin-top: -$likes-bar-height; | ||
318 | width: 120px; | ||
319 | background-color: #ccc; | ||
320 | position: relative; | ||
321 | top: 10px; | ||
322 | |||
323 | .likes-bar { | ||
324 | height: 100%; | ||
325 | background-color: #909090; | ||
326 | |||
327 | &.liked { | ||
328 | background-color: pvar(--activatedActionButtonColor); | ||
329 | } | ||
330 | } | ||
331 | } | ||
332 | } | ||
333 | } | ||
334 | |||
335 | .video-info-description { | ||
336 | margin: 20px 0; | ||
337 | margin-left: $video-info-margin-left; | ||
338 | font-size: 15px; | ||
339 | |||
340 | .video-info-description-html { | ||
341 | @include peertube-word-wrap; | ||
342 | |||
343 | /deep/ a { | ||
344 | text-decoration: none; | ||
345 | } | ||
346 | } | ||
347 | |||
348 | .glyphicon, .description-loading { | ||
349 | margin-left: 3px; | ||
350 | } | ||
351 | |||
352 | .description-loading { | ||
353 | display: inline-block; | ||
354 | } | ||
355 | |||
356 | .video-info-description-more { | ||
357 | cursor: pointer; | ||
358 | font-weight: $font-semibold; | ||
359 | color: pvar(--greyForegroundColor); | ||
360 | font-size: 14px; | ||
361 | |||
362 | .glyphicon { | ||
363 | position: relative; | ||
364 | top: 2px; | ||
365 | } | ||
366 | } | ||
367 | } | ||
368 | |||
369 | .video-attributes { | ||
370 | margin-left: $video-info-margin-left; | ||
371 | } | ||
372 | |||
373 | .video-attributes .video-attribute { | ||
374 | font-size: 13px; | ||
375 | display: block; | ||
376 | margin-bottom: 12px; | ||
377 | |||
378 | .video-attribute-label { | ||
379 | min-width: 142px; | ||
380 | padding-right: 5px; | ||
381 | display: inline-block; | ||
382 | color: pvar(--greyForegroundColor); | ||
383 | font-weight: $font-bold; | ||
384 | } | ||
385 | |||
386 | a.video-attribute-value { | ||
387 | @include disable-default-a-behaviour; | ||
388 | color: pvar(--mainForegroundColor); | ||
389 | |||
390 | &:hover { | ||
391 | opacity: 0.9; | ||
392 | } | ||
393 | } | ||
394 | |||
395 | &.video-attribute-tags { | ||
396 | .video-attribute-value:not(:nth-child(2)) { | ||
397 | &::before { | ||
398 | content: ', ' | ||
399 | } | ||
400 | } | ||
401 | } | ||
402 | } | ||
403 | } | ||
404 | |||
405 | ::ng-deep .other-videos { | ||
406 | padding-left: 15px; | ||
407 | min-width: $video-miniature-width; | ||
408 | |||
409 | @media screen and (min-width: 1800px - (3* $video-miniature-width)) { | ||
410 | width: min-content; | ||
411 | } | ||
412 | |||
413 | .title-page { | ||
414 | margin: 0 !important; | ||
415 | } | ||
416 | |||
417 | .video-miniature { | ||
418 | display: flex; | ||
419 | width: max-content; | ||
420 | height: 100%; | ||
421 | padding-bottom: 20px; | ||
422 | flex-wrap: wrap; | ||
423 | } | ||
424 | |||
425 | .video-bottom { | ||
426 | @media screen and (max-width: 1800px - (3* $video-miniature-width)) { | ||
427 | margin-left: 1rem; | ||
428 | } | ||
429 | @media screen and (max-width: 500px) { | ||
430 | margin-left: 0; | ||
431 | margin-top: .5rem; | ||
432 | } | ||
433 | } | ||
434 | } | ||
435 | } | ||
436 | |||
437 | my-video-comments { | ||
438 | display: inline-block; | ||
439 | width: 100%; | ||
440 | margin-bottom: 20px; | ||
441 | } | ||
442 | |||
443 | // If the view is not expanded, take into account the menu | ||
444 | .privacy-concerns { | ||
445 | z-index: z(dropdown) + 1; | ||
446 | width: calc(100% - #{$menu-width}); | ||
447 | } | ||
448 | |||
449 | @media screen and (max-width: $small-view) { | ||
450 | .privacy-concerns { | ||
451 | margin-left: $menu-width - 15px; // Menu is absolute | ||
452 | } | ||
453 | } | ||
454 | |||
455 | :host-context(.expanded) { | ||
456 | .privacy-concerns { | ||
457 | width: 100%; | ||
458 | margin-left: -15px; | ||
459 | } | ||
460 | } | ||
461 | |||
462 | .privacy-concerns { | ||
463 | position: fixed; | ||
464 | bottom: 0; | ||
465 | z-index: z(privacymsg); | ||
466 | |||
467 | padding: 5px 15px; | ||
468 | |||
469 | display: flex; | ||
470 | flex-wrap: nowrap; | ||
471 | align-items: center; | ||
472 | justify-content: space-between; | ||
473 | background-color: rgba(0, 0, 0, 0.9); | ||
474 | color: #fff; | ||
475 | |||
476 | .privacy-concerns-text { | ||
477 | margin: 0 5px; | ||
478 | } | ||
479 | |||
480 | a { | ||
481 | @include disable-default-a-behaviour; | ||
482 | |||
483 | color: pvar(--mainColor); | ||
484 | transition: color 0.3s; | ||
485 | |||
486 | &:hover { | ||
487 | color: #fff; | ||
488 | } | ||
489 | } | ||
490 | |||
491 | .privacy-concerns-button { | ||
492 | padding: 5px 8px 5px 7px; | ||
493 | margin-left: auto; | ||
494 | border-radius: 3px; | ||
495 | white-space: nowrap; | ||
496 | cursor: pointer; | ||
497 | transition: background-color 0.3s; | ||
498 | font-weight: $font-semibold; | ||
499 | |||
500 | &:hover { | ||
501 | background-color: #000; | ||
502 | } | ||
503 | } | ||
504 | |||
505 | .privacy-concerns-okay { | ||
506 | background-color: pvar(--mainColor); | ||
507 | margin-left: 10px; | ||
508 | } | ||
509 | } | ||
510 | |||
511 | @media screen and (max-width: 1600px) { | ||
512 | .video-bottom .video-info .video-attributes .video-attribute { | ||
513 | margin-bottom: 5px; | ||
514 | } | ||
515 | } | ||
516 | |||
517 | @media screen and (max-width: 1300px) { | ||
518 | .privacy-concerns { | ||
519 | font-size: 12px; | ||
520 | padding: 2px 5px; | ||
521 | |||
522 | .privacy-concerns-text { | ||
523 | margin: 0; | ||
524 | } | ||
525 | } | ||
526 | } | ||
527 | |||
528 | @media screen and (max-width: 1100px) { | ||
529 | #video-wrapper { | ||
530 | flex-direction: column; | ||
531 | justify-content: center; | ||
532 | |||
533 | my-video-watch-playlist ::ng-deep .playlist { | ||
534 | @include playlist-below-player; | ||
535 | } | ||
536 | } | ||
537 | |||
538 | .video-bottom { | ||
539 | flex-direction: column; | ||
540 | |||
541 | ::ng-deep .other-videos { | ||
542 | padding-left: 0 !important; | ||
543 | |||
544 | ::ng-deep .video-miniature { | ||
545 | flex-direction: row; | ||
546 | width: auto; | ||
547 | } | ||
548 | } | ||
549 | } | ||
550 | } | ||
551 | |||
552 | @media screen and (max-width: 600px) { | ||
553 | .video-bottom { | ||
554 | margin-top: 20px !important; | ||
555 | padding-bottom: 20px !important; | ||
556 | |||
557 | .video-info { | ||
558 | padding: 0; | ||
559 | |||
560 | .video-info-first-row { | ||
561 | |||
562 | .video-info-name { | ||
563 | font-size: 20px; | ||
564 | height: auto; | ||
565 | } | ||
566 | } | ||
567 | } | ||
568 | } | ||
569 | |||
570 | ::ng-deep .other-videos .video-miniature { | ||
571 | flex-direction: column; | ||
572 | } | ||
573 | |||
574 | .privacy-concerns { | ||
575 | width: 100%; | ||
576 | |||
577 | strong { | ||
578 | display: none; | ||
579 | } | ||
580 | } | ||
581 | } | ||
582 | |||
583 | @media screen and (max-width: 450px) { | ||
584 | .video-bottom { | ||
585 | .action-button .icon-text { | ||
586 | display: none !important; | ||
587 | } | ||
588 | |||
589 | .video-info .video-info-first-row { | ||
590 | .video-info-name { | ||
591 | font-size: 18px; | ||
592 | } | ||
593 | |||
594 | .video-info-date-views { | ||
595 | font-size: 14px; | ||
596 | } | ||
597 | |||
598 | .video-actions-rates { | ||
599 | margin-top: 10px; | ||
600 | } | ||
601 | } | ||
602 | |||
603 | .video-info-description { | ||
604 | font-size: 14px !important; | ||
605 | } | ||
606 | } | ||
607 | } | ||
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts deleted file mode 100644 index 5b0b34c80..000000000 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ /dev/null | |||
@@ -1,782 +0,0 @@ | |||
1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | ||
2 | import { forkJoin, Observable, Subscription } from 'rxjs' | ||
3 | import { catchError } from 'rxjs/operators' | ||
4 | import { PlatformLocation } from '@angular/common' | ||
5 | import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' | ||
6 | import { ActivatedRoute, Router } from '@angular/router' | ||
7 | import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core' | ||
8 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
9 | import { RedirectService } from '@app/core/routing/redirect.service' | ||
10 | import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers' | ||
11 | import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' | ||
12 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' | ||
13 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' | ||
14 | import { MetaService } from '@ngx-meta/core' | ||
15 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
16 | import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models' | ||
17 | import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage' | ||
18 | import { | ||
19 | CustomizationOptions, | ||
20 | P2PMediaLoaderOptions, | ||
21 | PeertubePlayerManager, | ||
22 | PeertubePlayerManagerOptions, | ||
23 | PlayerMode, | ||
24 | videojs | ||
25 | } from '../../../assets/player/peertube-player-manager' | ||
26 | import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils' | ||
27 | import { environment } from '../../../environments/environment' | ||
28 | import { VideoShareComponent } from './modal/video-share.component' | ||
29 | import { VideoSupportComponent } from './modal/video-support.component' | ||
30 | import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' | ||
31 | |||
32 | @Component({ | ||
33 | selector: 'my-video-watch', | ||
34 | templateUrl: './video-watch.component.html', | ||
35 | styleUrls: [ './video-watch.component.scss' ] | ||
36 | }) | ||
37 | export class VideoWatchComponent implements OnInit, OnDestroy { | ||
38 | private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' | ||
39 | |||
40 | @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent | ||
41 | @ViewChild('videoShareModal') videoShareModal: VideoShareComponent | ||
42 | @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent | ||
43 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent | ||
44 | |||
45 | player: any | ||
46 | playerElement: HTMLVideoElement | ||
47 | theaterEnabled = false | ||
48 | userRating: UserVideoRateType = null | ||
49 | descriptionLoading = false | ||
50 | |||
51 | video: VideoDetails = null | ||
52 | videoCaptions: VideoCaption[] = [] | ||
53 | |||
54 | playlist: VideoPlaylist = null | ||
55 | |||
56 | completeDescriptionShown = false | ||
57 | completeVideoDescription: string | ||
58 | shortVideoDescription: string | ||
59 | videoHTMLDescription = '' | ||
60 | likesBarTooltipText = '' | ||
61 | hasAlreadyAcceptedPrivacyConcern = false | ||
62 | remoteServerDown = false | ||
63 | hotkeys: Hotkey[] = [] | ||
64 | |||
65 | tooltipLike = '' | ||
66 | tooltipDislike = '' | ||
67 | tooltipSupport = '' | ||
68 | tooltipSaveToPlaylist = '' | ||
69 | |||
70 | private nextVideoUuid = '' | ||
71 | private nextVideoTitle = '' | ||
72 | private currentTime: number | ||
73 | private paramsSub: Subscription | ||
74 | private queryParamsSub: Subscription | ||
75 | private configSub: Subscription | ||
76 | |||
77 | private serverConfig: ServerConfig | ||
78 | |||
79 | constructor ( | ||
80 | private elementRef: ElementRef, | ||
81 | private changeDetector: ChangeDetectorRef, | ||
82 | private route: ActivatedRoute, | ||
83 | private router: Router, | ||
84 | private videoService: VideoService, | ||
85 | private playlistService: VideoPlaylistService, | ||
86 | private confirmService: ConfirmService, | ||
87 | private metaService: MetaService, | ||
88 | private authService: AuthService, | ||
89 | private userService: UserService, | ||
90 | private serverService: ServerService, | ||
91 | private restExtractor: RestExtractor, | ||
92 | private notifier: Notifier, | ||
93 | private markdownService: MarkdownService, | ||
94 | private zone: NgZone, | ||
95 | private redirectService: RedirectService, | ||
96 | private videoCaptionService: VideoCaptionService, | ||
97 | private i18n: I18n, | ||
98 | private hotkeysService: HotkeysService, | ||
99 | private hooks: HooksService, | ||
100 | private location: PlatformLocation, | ||
101 | @Inject(LOCALE_ID) private localeId: string | ||
102 | ) { | ||
103 | this.tooltipLike = this.i18n('Like this video') | ||
104 | this.tooltipDislike = this.i18n('Dislike this video') | ||
105 | this.tooltipSupport = this.i18n('Support options for this video') | ||
106 | this.tooltipSaveToPlaylist = this.i18n('Save to playlist') | ||
107 | } | ||
108 | |||
109 | get user () { | ||
110 | return this.authService.getUser() | ||
111 | } | ||
112 | |||
113 | get anonymousUser () { | ||
114 | return this.userService.getAnonymousUser() | ||
115 | } | ||
116 | |||
117 | async ngOnInit () { | ||
118 | this.serverConfig = this.serverService.getTmpConfig() | ||
119 | |||
120 | this.configSub = this.serverService.getConfig() | ||
121 | .subscribe(config => { | ||
122 | this.serverConfig = config | ||
123 | |||
124 | if ( | ||
125 | isWebRTCDisabled() || | ||
126 | this.serverConfig.tracker.enabled === false || | ||
127 | getStoredP2PEnabled() === false || | ||
128 | peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true' | ||
129 | ) { | ||
130 | this.hasAlreadyAcceptedPrivacyConcern = true | ||
131 | } | ||
132 | }) | ||
133 | |||
134 | this.paramsSub = this.route.params.subscribe(routeParams => { | ||
135 | const videoId = routeParams[ 'videoId' ] | ||
136 | if (videoId) this.loadVideo(videoId) | ||
137 | |||
138 | const playlistId = routeParams[ 'playlistId' ] | ||
139 | if (playlistId) this.loadPlaylist(playlistId) | ||
140 | }) | ||
141 | |||
142 | this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => { | ||
143 | const videoId = queryParams[ 'videoId' ] | ||
144 | if (videoId) this.loadVideo(videoId) | ||
145 | |||
146 | const start = queryParams[ 'start' ] | ||
147 | if (this.player && start) this.player.currentTime(parseInt(start, 10)) | ||
148 | }) | ||
149 | |||
150 | this.initHotkeys() | ||
151 | |||
152 | this.theaterEnabled = getStoredTheater() | ||
153 | |||
154 | this.hooks.runAction('action:video-watch.init', 'video-watch') | ||
155 | } | ||
156 | |||
157 | ngOnDestroy () { | ||
158 | this.flushPlayer() | ||
159 | |||
160 | // Unsubscribe subscriptions | ||
161 | if (this.paramsSub) this.paramsSub.unsubscribe() | ||
162 | if (this.queryParamsSub) this.queryParamsSub.unsubscribe() | ||
163 | |||
164 | // Unbind hotkeys | ||
165 | this.hotkeysService.remove(this.hotkeys) | ||
166 | } | ||
167 | |||
168 | setLike () { | ||
169 | if (this.isUserLoggedIn() === false) return | ||
170 | |||
171 | // Already liked this video | ||
172 | if (this.userRating === 'like') this.setRating('none') | ||
173 | else this.setRating('like') | ||
174 | } | ||
175 | |||
176 | setDislike () { | ||
177 | if (this.isUserLoggedIn() === false) return | ||
178 | |||
179 | // Already disliked this video | ||
180 | if (this.userRating === 'dislike') this.setRating('none') | ||
181 | else this.setRating('dislike') | ||
182 | } | ||
183 | |||
184 | getRatePopoverText () { | ||
185 | if (this.isUserLoggedIn()) return undefined | ||
186 | |||
187 | return this.i18n('You need to be connected to rate this content.') | ||
188 | } | ||
189 | |||
190 | showMoreDescription () { | ||
191 | if (this.completeVideoDescription === undefined) { | ||
192 | return this.loadCompleteDescription() | ||
193 | } | ||
194 | |||
195 | this.updateVideoDescription(this.completeVideoDescription) | ||
196 | this.completeDescriptionShown = true | ||
197 | } | ||
198 | |||
199 | showLessDescription () { | ||
200 | this.updateVideoDescription(this.shortVideoDescription) | ||
201 | this.completeDescriptionShown = false | ||
202 | } | ||
203 | |||
204 | loadCompleteDescription () { | ||
205 | this.descriptionLoading = true | ||
206 | |||
207 | this.videoService.loadCompleteDescription(this.video.descriptionPath) | ||
208 | .subscribe( | ||
209 | description => { | ||
210 | this.completeDescriptionShown = true | ||
211 | this.descriptionLoading = false | ||
212 | |||
213 | this.shortVideoDescription = this.video.description | ||
214 | this.completeVideoDescription = description | ||
215 | |||
216 | this.updateVideoDescription(this.completeVideoDescription) | ||
217 | }, | ||
218 | |||
219 | error => { | ||
220 | this.descriptionLoading = false | ||
221 | this.notifier.error(error.message) | ||
222 | } | ||
223 | ) | ||
224 | } | ||
225 | |||
226 | showSupportModal () { | ||
227 | this.pausePlayer() | ||
228 | |||
229 | this.videoSupportModal.show() | ||
230 | } | ||
231 | |||
232 | showShareModal () { | ||
233 | this.pausePlayer() | ||
234 | |||
235 | this.videoShareModal.show(this.currentTime) | ||
236 | } | ||
237 | |||
238 | isUserLoggedIn () { | ||
239 | return this.authService.isLoggedIn() | ||
240 | } | ||
241 | |||
242 | getVideoTags () { | ||
243 | if (!this.video || Array.isArray(this.video.tags) === false) return [] | ||
244 | |||
245 | return this.video.tags | ||
246 | } | ||
247 | |||
248 | onRecommendations (videos: Video[]) { | ||
249 | if (videos.length > 0) { | ||
250 | // The recommended videos's first element should be the next video | ||
251 | const video = videos[0] | ||
252 | this.nextVideoUuid = video.uuid | ||
253 | this.nextVideoTitle = video.name | ||
254 | } | ||
255 | } | ||
256 | |||
257 | onModalOpened () { | ||
258 | this.pausePlayer() | ||
259 | } | ||
260 | |||
261 | onVideoRemoved () { | ||
262 | this.redirectService.redirectToHomepage() | ||
263 | } | ||
264 | |||
265 | declinedPrivacyConcern () { | ||
266 | peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false') | ||
267 | this.hasAlreadyAcceptedPrivacyConcern = false | ||
268 | } | ||
269 | |||
270 | acceptedPrivacyConcern () { | ||
271 | peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true') | ||
272 | this.hasAlreadyAcceptedPrivacyConcern = true | ||
273 | } | ||
274 | |||
275 | isVideoToTranscode () { | ||
276 | return this.video && this.video.state.id === VideoState.TO_TRANSCODE | ||
277 | } | ||
278 | |||
279 | isVideoToImport () { | ||
280 | return this.video && this.video.state.id === VideoState.TO_IMPORT | ||
281 | } | ||
282 | |||
283 | hasVideoScheduledPublication () { | ||
284 | return this.video && this.video.scheduledUpdate !== undefined | ||
285 | } | ||
286 | |||
287 | isVideoBlur (video: Video) { | ||
288 | return video.isVideoNSFWForUser(this.user, this.serverConfig) | ||
289 | } | ||
290 | |||
291 | isAutoPlayEnabled () { | ||
292 | return ( | ||
293 | (this.user && this.user.autoPlayNextVideo) || | ||
294 | this.anonymousUser.autoPlayNextVideo | ||
295 | ) | ||
296 | } | ||
297 | |||
298 | handleTimestampClicked (timestamp: number) { | ||
299 | if (this.player) this.player.currentTime(timestamp) | ||
300 | scrollToTop() | ||
301 | } | ||
302 | |||
303 | isPlaylistAutoPlayEnabled () { | ||
304 | return ( | ||
305 | (this.user && this.user.autoPlayNextVideoPlaylist) || | ||
306 | this.anonymousUser.autoPlayNextVideoPlaylist | ||
307 | ) | ||
308 | } | ||
309 | |||
310 | private loadVideo (videoId: string) { | ||
311 | // Video did not change | ||
312 | if (this.video && this.video.uuid === videoId) return | ||
313 | |||
314 | if (this.player) this.player.pause() | ||
315 | |||
316 | const videoObs = this.hooks.wrapObsFun( | ||
317 | this.videoService.getVideo.bind(this.videoService), | ||
318 | { videoId }, | ||
319 | 'video-watch', | ||
320 | 'filter:api.video-watch.video.get.params', | ||
321 | 'filter:api.video-watch.video.get.result' | ||
322 | ) | ||
323 | |||
324 | // Video did change | ||
325 | forkJoin([ | ||
326 | videoObs, | ||
327 | this.videoCaptionService.listCaptions(videoId) | ||
328 | ]) | ||
329 | .pipe( | ||
330 | // If 401, the video is private or blocked so redirect to 404 | ||
331 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) | ||
332 | ) | ||
333 | .subscribe(([ video, captionsResult ]) => { | ||
334 | const queryParams = this.route.snapshot.queryParams | ||
335 | |||
336 | const urlOptions = { | ||
337 | startTime: queryParams.start, | ||
338 | stopTime: queryParams.stop, | ||
339 | |||
340 | muted: queryParams.muted, | ||
341 | loop: queryParams.loop, | ||
342 | subtitle: queryParams.subtitle, | ||
343 | |||
344 | playerMode: queryParams.mode, | ||
345 | peertubeLink: false | ||
346 | } | ||
347 | |||
348 | this.onVideoFetched(video, captionsResult.data, urlOptions) | ||
349 | .catch(err => this.handleError(err)) | ||
350 | }) | ||
351 | } | ||
352 | |||
353 | private loadPlaylist (playlistId: string) { | ||
354 | // Playlist did not change | ||
355 | if (this.playlist && this.playlist.uuid === playlistId) return | ||
356 | |||
357 | this.playlistService.getVideoPlaylist(playlistId) | ||
358 | .pipe( | ||
359 | // If 401, the video is private or blocked so redirect to 404 | ||
360 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) | ||
361 | ) | ||
362 | .subscribe(playlist => { | ||
363 | this.playlist = playlist | ||
364 | |||
365 | const videoId = this.route.snapshot.queryParams['videoId'] | ||
366 | this.videoWatchPlaylist.loadPlaylistElements(playlist, !videoId) | ||
367 | }) | ||
368 | } | ||
369 | |||
370 | private updateVideoDescription (description: string) { | ||
371 | this.video.description = description | ||
372 | this.setVideoDescriptionHTML() | ||
373 | .catch(err => console.error(err)) | ||
374 | } | ||
375 | |||
376 | private async setVideoDescriptionHTML () { | ||
377 | const html = await this.markdownService.textMarkdownToHTML(this.video.description) | ||
378 | this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html) | ||
379 | } | ||
380 | |||
381 | private setVideoLikesBarTooltipText () { | ||
382 | this.likesBarTooltipText = this.i18n('{{likesNumber}} likes / {{dislikesNumber}} dislikes', { | ||
383 | likesNumber: this.video.likes, | ||
384 | dislikesNumber: this.video.dislikes | ||
385 | }) | ||
386 | } | ||
387 | |||
388 | private handleError (err: any) { | ||
389 | const errorMessage: string = typeof err === 'string' ? err : err.message | ||
390 | if (!errorMessage) return | ||
391 | |||
392 | // Display a message in the video player instead of a notification | ||
393 | if (errorMessage.indexOf('from xs param') !== -1) { | ||
394 | this.flushPlayer() | ||
395 | this.remoteServerDown = true | ||
396 | this.changeDetector.detectChanges() | ||
397 | |||
398 | return | ||
399 | } | ||
400 | |||
401 | this.notifier.error(errorMessage) | ||
402 | } | ||
403 | |||
404 | private checkUserRating () { | ||
405 | // Unlogged users do not have ratings | ||
406 | if (this.isUserLoggedIn() === false) return | ||
407 | |||
408 | this.videoService.getUserVideoRating(this.video.id) | ||
409 | .subscribe( | ||
410 | ratingObject => { | ||
411 | if (ratingObject) { | ||
412 | this.userRating = ratingObject.rating | ||
413 | } | ||
414 | }, | ||
415 | |||
416 | err => this.notifier.error(err.message) | ||
417 | ) | ||
418 | } | ||
419 | |||
420 | private async onVideoFetched ( | ||
421 | video: VideoDetails, | ||
422 | videoCaptions: VideoCaption[], | ||
423 | urlOptions: CustomizationOptions & { playerMode: PlayerMode } | ||
424 | ) { | ||
425 | this.video = video | ||
426 | this.videoCaptions = videoCaptions | ||
427 | |||
428 | // Re init attributes | ||
429 | this.descriptionLoading = false | ||
430 | this.completeDescriptionShown = false | ||
431 | this.remoteServerDown = false | ||
432 | this.currentTime = undefined | ||
433 | |||
434 | this.videoWatchPlaylist.updatePlaylistIndex(video) | ||
435 | |||
436 | if (this.isVideoBlur(this.video)) { | ||
437 | const res = await this.confirmService.confirm( | ||
438 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), | ||
439 | this.i18n('Mature or explicit content') | ||
440 | ) | ||
441 | if (res === false) return this.location.back() | ||
442 | } | ||
443 | |||
444 | // Flush old player if needed | ||
445 | this.flushPlayer() | ||
446 | |||
447 | // Build video element, because videojs removes it on dispose | ||
448 | const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper') | ||
449 | this.playerElement = document.createElement('video') | ||
450 | this.playerElement.className = 'video-js vjs-peertube-skin' | ||
451 | this.playerElement.setAttribute('playsinline', 'true') | ||
452 | playerElementWrapper.appendChild(this.playerElement) | ||
453 | |||
454 | const params = { | ||
455 | video: this.video, | ||
456 | videoCaptions, | ||
457 | urlOptions, | ||
458 | user: this.user | ||
459 | } | ||
460 | const { playerMode, playerOptions } = await this.hooks.wrapFun( | ||
461 | this.buildPlayerManagerOptions.bind(this), | ||
462 | params, | ||
463 | 'video-watch', | ||
464 | 'filter:internal.video-watch.player.build-options.params', | ||
465 | 'filter:internal.video-watch.player.build-options.result' | ||
466 | ) | ||
467 | |||
468 | this.zone.runOutsideAngular(async () => { | ||
469 | this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) | ||
470 | this.player.focus() | ||
471 | |||
472 | this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) | ||
473 | |||
474 | this.player.on('timeupdate', () => { | ||
475 | this.currentTime = Math.floor(this.player.currentTime()) | ||
476 | }) | ||
477 | |||
478 | /** | ||
479 | * replaces this.player.one('ended') | ||
480 | * 'condition()': true to make the upnext functionality trigger, | ||
481 | * false to disable the upnext functionality | ||
482 | * go to the next video in 'condition()' if you don't want of the timer. | ||
483 | * 'next': function triggered at the end of the timer. | ||
484 | * 'suspended': function used at each clic of the timer checking if we need | ||
485 | * to reset progress and wait until 'suspended' becomes truthy again. | ||
486 | */ | ||
487 | this.player.upnext({ | ||
488 | timeout: 10000, // 10s | ||
489 | headText: this.i18n('Up Next'), | ||
490 | cancelText: this.i18n('Cancel'), | ||
491 | suspendedText: this.i18n('Autoplay is suspended'), | ||
492 | getTitle: () => this.nextVideoTitle, | ||
493 | next: () => this.zone.run(() => this.autoplayNext()), | ||
494 | condition: () => { | ||
495 | if (this.playlist) { | ||
496 | if (this.isPlaylistAutoPlayEnabled()) { | ||
497 | // upnext will not trigger, and instead the next video will play immediately | ||
498 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
499 | } | ||
500 | } else if (this.isAutoPlayEnabled()) { | ||
501 | return true // upnext will trigger | ||
502 | } | ||
503 | return false // upnext will not trigger, and instead leave the video stopping | ||
504 | }, | ||
505 | suspended: () => { | ||
506 | return ( | ||
507 | !isXPercentInViewport(this.player.el(), 80) || | ||
508 | !document.getElementById('content').contains(document.activeElement) | ||
509 | ) | ||
510 | } | ||
511 | }) | ||
512 | |||
513 | this.player.one('stopped', () => { | ||
514 | if (this.playlist) { | ||
515 | if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
516 | } | ||
517 | }) | ||
518 | |||
519 | this.player.on('theaterChange', (_: any, enabled: boolean) => { | ||
520 | this.zone.run(() => this.theaterEnabled = enabled) | ||
521 | }) | ||
522 | |||
523 | this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { player: this.player }) | ||
524 | }) | ||
525 | |||
526 | this.setVideoDescriptionHTML() | ||
527 | this.setVideoLikesBarTooltipText() | ||
528 | |||
529 | this.setOpenGraphTags() | ||
530 | this.checkUserRating() | ||
531 | |||
532 | this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', { videojs }) | ||
533 | } | ||
534 | |||
535 | private autoplayNext () { | ||
536 | if (this.playlist) { | ||
537 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
538 | } else if (this.nextVideoUuid) { | ||
539 | this.router.navigate([ '/videos/watch', this.nextVideoUuid ]) | ||
540 | } | ||
541 | } | ||
542 | |||
543 | private setRating (nextRating: UserVideoRateType) { | ||
544 | const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable<any> } = { | ||
545 | like: this.videoService.setVideoLike, | ||
546 | dislike: this.videoService.setVideoDislike, | ||
547 | none: this.videoService.unsetVideoLike | ||
548 | } | ||
549 | |||
550 | ratingMethods[nextRating].call(this.videoService, this.video.id) | ||
551 | .subscribe( | ||
552 | () => { | ||
553 | // Update the video like attribute | ||
554 | this.updateVideoRating(this.userRating, nextRating) | ||
555 | this.userRating = nextRating | ||
556 | }, | ||
557 | |||
558 | (err: { message: string }) => this.notifier.error(err.message) | ||
559 | ) | ||
560 | } | ||
561 | |||
562 | private updateVideoRating (oldRating: UserVideoRateType, newRating: UserVideoRateType) { | ||
563 | let likesToIncrement = 0 | ||
564 | let dislikesToIncrement = 0 | ||
565 | |||
566 | if (oldRating) { | ||
567 | if (oldRating === 'like') likesToIncrement-- | ||
568 | if (oldRating === 'dislike') dislikesToIncrement-- | ||
569 | } | ||
570 | |||
571 | if (newRating === 'like') likesToIncrement++ | ||
572 | if (newRating === 'dislike') dislikesToIncrement++ | ||
573 | |||
574 | this.video.likes += likesToIncrement | ||
575 | this.video.dislikes += dislikesToIncrement | ||
576 | |||
577 | this.video.buildLikeAndDislikePercents() | ||
578 | this.setVideoLikesBarTooltipText() | ||
579 | } | ||
580 | |||
581 | private setOpenGraphTags () { | ||
582 | this.metaService.setTitle(this.video.name) | ||
583 | |||
584 | this.metaService.setTag('og:type', 'video') | ||
585 | |||
586 | this.metaService.setTag('og:title', this.video.name) | ||
587 | this.metaService.setTag('name', this.video.name) | ||
588 | |||
589 | this.metaService.setTag('og:description', this.video.description) | ||
590 | this.metaService.setTag('description', this.video.description) | ||
591 | |||
592 | this.metaService.setTag('og:image', this.video.previewPath) | ||
593 | |||
594 | this.metaService.setTag('og:duration', this.video.duration.toString()) | ||
595 | |||
596 | this.metaService.setTag('og:site_name', 'PeerTube') | ||
597 | |||
598 | this.metaService.setTag('og:url', window.location.href) | ||
599 | this.metaService.setTag('url', window.location.href) | ||
600 | } | ||
601 | |||
602 | private isAutoplay () { | ||
603 | // We'll jump to the thread id, so do not play the video | ||
604 | if (this.route.snapshot.params['threadId']) return false | ||
605 | |||
606 | // Otherwise true by default | ||
607 | if (!this.user) return true | ||
608 | |||
609 | // Be sure the autoPlay is set to false | ||
610 | return this.user.autoPlayVideo !== false | ||
611 | } | ||
612 | |||
613 | private flushPlayer () { | ||
614 | // Remove player if it exists | ||
615 | if (this.player) { | ||
616 | try { | ||
617 | this.player.dispose() | ||
618 | this.player = undefined | ||
619 | } catch (err) { | ||
620 | console.error('Cannot dispose player.', err) | ||
621 | } | ||
622 | } | ||
623 | } | ||
624 | |||
625 | private buildPlayerManagerOptions (params: { | ||
626 | video: VideoDetails, | ||
627 | videoCaptions: VideoCaption[], | ||
628 | urlOptions: CustomizationOptions & { playerMode: PlayerMode }, | ||
629 | user?: AuthUser | ||
630 | }) { | ||
631 | const { video, videoCaptions, urlOptions, user } = params | ||
632 | const getStartTime = () => { | ||
633 | const byUrl = urlOptions.startTime !== undefined | ||
634 | const byHistory = video.userHistory && (!this.playlist || urlOptions.resume !== undefined) | ||
635 | |||
636 | if (byUrl) { | ||
637 | return timeToInt(urlOptions.startTime) | ||
638 | } else if (byHistory) { | ||
639 | return video.userHistory.currentTime | ||
640 | } else { | ||
641 | return 0 | ||
642 | } | ||
643 | } | ||
644 | |||
645 | let startTime = getStartTime() | ||
646 | // If we are at the end of the video, reset the timer | ||
647 | if (video.duration - startTime <= 1) startTime = 0 | ||
648 | |||
649 | const playerCaptions = videoCaptions.map(c => ({ | ||
650 | label: c.language.label, | ||
651 | language: c.language.id, | ||
652 | src: environment.apiUrl + c.captionPath | ||
653 | })) | ||
654 | |||
655 | const options: PeertubePlayerManagerOptions = { | ||
656 | common: { | ||
657 | autoplay: this.isAutoplay(), | ||
658 | nextVideo: () => this.zone.run(() => this.autoplayNext()), | ||
659 | |||
660 | playerElement: this.playerElement, | ||
661 | onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element, | ||
662 | |||
663 | videoDuration: video.duration, | ||
664 | enableHotkeys: true, | ||
665 | inactivityTimeout: 2500, | ||
666 | poster: video.previewUrl, | ||
667 | |||
668 | startTime, | ||
669 | stopTime: urlOptions.stopTime, | ||
670 | controls: urlOptions.controls, | ||
671 | muted: urlOptions.muted, | ||
672 | loop: urlOptions.loop, | ||
673 | subtitle: urlOptions.subtitle, | ||
674 | |||
675 | peertubeLink: urlOptions.peertubeLink, | ||
676 | |||
677 | theaterButton: true, | ||
678 | captions: videoCaptions.length !== 0, | ||
679 | |||
680 | videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE | ||
681 | ? this.videoService.getVideoViewUrl(video.uuid) | ||
682 | : null, | ||
683 | embedUrl: video.embedUrl, | ||
684 | |||
685 | language: this.localeId, | ||
686 | |||
687 | userWatching: user && user.videosHistoryEnabled === true ? { | ||
688 | url: this.videoService.getUserWatchingVideoUrl(video.uuid), | ||
689 | authorizationHeader: this.authService.getRequestHeaderValue() | ||
690 | } : undefined, | ||
691 | |||
692 | serverUrl: environment.apiUrl, | ||
693 | |||
694 | videoCaptions: playerCaptions | ||
695 | }, | ||
696 | |||
697 | webtorrent: { | ||
698 | videoFiles: video.files | ||
699 | } | ||
700 | } | ||
701 | |||
702 | let mode: PlayerMode | ||
703 | |||
704 | if (urlOptions.playerMode) { | ||
705 | if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' | ||
706 | else mode = 'webtorrent' | ||
707 | } else { | ||
708 | if (video.hasHlsPlaylist()) mode = 'p2p-media-loader' | ||
709 | else mode = 'webtorrent' | ||
710 | } | ||
711 | |||
712 | // p2p-media-loader needs TextEncoder, try to fallback on WebTorrent | ||
713 | if (typeof TextEncoder === 'undefined') { | ||
714 | mode = 'webtorrent' | ||
715 | } | ||
716 | |||
717 | if (mode === 'p2p-media-loader') { | ||
718 | const hlsPlaylist = video.getHlsPlaylist() | ||
719 | |||
720 | const p2pMediaLoader = { | ||
721 | playlistUrl: hlsPlaylist.playlistUrl, | ||
722 | segmentsSha256Url: hlsPlaylist.segmentsSha256Url, | ||
723 | redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), | ||
724 | trackerAnnounce: video.trackerUrls, | ||
725 | videoFiles: hlsPlaylist.files | ||
726 | } as P2PMediaLoaderOptions | ||
727 | |||
728 | Object.assign(options, { p2pMediaLoader }) | ||
729 | } | ||
730 | |||
731 | return { playerMode: mode, playerOptions: options } | ||
732 | } | ||
733 | |||
734 | private pausePlayer () { | ||
735 | if (!this.player) return | ||
736 | |||
737 | this.player.pause() | ||
738 | } | ||
739 | |||
740 | private initHotkeys () { | ||
741 | this.hotkeys = [ | ||
742 | // These hotkeys are managed by the player | ||
743 | new Hotkey('f', e => e, undefined, this.i18n('Enter/exit fullscreen (requires player focus)')), | ||
744 | new Hotkey('space', e => e, undefined, this.i18n('Play/Pause the video (requires player focus)')), | ||
745 | new Hotkey('m', e => e, undefined, this.i18n('Mute/unmute the video (requires player focus)')), | ||
746 | |||
747 | 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)')), | ||
748 | |||
749 | new Hotkey('up', e => e, undefined, this.i18n('Increase the volume (requires player focus)')), | ||
750 | new Hotkey('down', e => e, undefined, this.i18n('Decrease the volume (requires player focus)')), | ||
751 | |||
752 | new Hotkey('right', e => e, undefined, this.i18n('Seek the video forward (requires player focus)')), | ||
753 | new Hotkey('left', e => e, undefined, this.i18n('Seek the video backward (requires player focus)')), | ||
754 | |||
755 | new Hotkey('>', e => e, undefined, this.i18n('Increase playback rate (requires player focus)')), | ||
756 | new Hotkey('<', e => e, undefined, this.i18n('Decrease playback rate (requires player focus)')), | ||
757 | |||
758 | new Hotkey('.', e => e, undefined, this.i18n('Navigate in the video frame by frame (requires player focus)')) | ||
759 | ] | ||
760 | |||
761 | if (this.isUserLoggedIn()) { | ||
762 | this.hotkeys = this.hotkeys.concat([ | ||
763 | new Hotkey('shift+l', () => { | ||
764 | this.setLike() | ||
765 | return false | ||
766 | }, undefined, this.i18n('Like the video')), | ||
767 | |||
768 | new Hotkey('shift+d', () => { | ||
769 | this.setDislike() | ||
770 | return false | ||
771 | }, undefined, this.i18n('Dislike the video')), | ||
772 | |||
773 | new Hotkey('shift+s', () => { | ||
774 | this.subscribeButton.subscribed ? this.subscribeButton.unsubscribe() : this.subscribeButton.subscribe() | ||
775 | return false | ||
776 | }, undefined, this.i18n('Subscribe to the account')) | ||
777 | ]) | ||
778 | } | ||
779 | |||
780 | this.hotkeysService.add(this.hotkeys) | ||
781 | } | ||
782 | } | ||
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts deleted file mode 100644 index a1c54f065..000000000 --- a/client/src/app/videos/+video-watch/video-watch.module.ts +++ /dev/null | |||
@@ -1,65 +0,0 @@ | |||
1 | import { QRCodeModule } from 'angularx-qrcode' | ||
2 | import { NgModule } from '@angular/core' | ||
3 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
4 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | ||
5 | import { SharedMainModule } from '@app/shared/shared-main' | ||
6 | import { SharedModerationModule } from '@app/shared/shared-moderation' | ||
7 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' | ||
8 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | ||
9 | import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' | ||
10 | import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' | ||
11 | import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | ||
12 | import { VideoCommentAddComponent } from './comment/video-comment-add.component' | ||
13 | import { VideoCommentComponent } from './comment/video-comment.component' | ||
14 | import { VideoCommentService } from './comment/video-comment.service' | ||
15 | import { VideoCommentsComponent } from './comment/video-comments.component' | ||
16 | import { VideoShareComponent } from './modal/video-share.component' | ||
17 | import { VideoSupportComponent } from './modal/video-support.component' | ||
18 | import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' | ||
19 | import { VideoDurationPipe } from './video-duration-formatter.pipe' | ||
20 | import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' | ||
21 | import { VideoWatchRoutingModule } from './video-watch-routing.module' | ||
22 | import { VideoWatchComponent } from './video-watch.component' | ||
23 | |||
24 | @NgModule({ | ||
25 | imports: [ | ||
26 | VideoWatchRoutingModule, | ||
27 | NgbTooltipModule, | ||
28 | QRCodeModule, | ||
29 | RecommendationsModule, | ||
30 | |||
31 | SharedMainModule, | ||
32 | SharedFormModule, | ||
33 | SharedVideoMiniatureModule, | ||
34 | SharedVideoPlaylistModule, | ||
35 | SharedUserSubscriptionModule, | ||
36 | SharedModerationModule, | ||
37 | SharedGlobalIconModule | ||
38 | ], | ||
39 | |||
40 | declarations: [ | ||
41 | VideoWatchComponent, | ||
42 | VideoWatchPlaylistComponent, | ||
43 | |||
44 | VideoShareComponent, | ||
45 | VideoSupportComponent, | ||
46 | VideoCommentsComponent, | ||
47 | VideoCommentAddComponent, | ||
48 | VideoCommentComponent, | ||
49 | |||
50 | TimestampRouteTransformerDirective, | ||
51 | VideoDurationPipe, | ||
52 | TimestampRouteTransformerDirective | ||
53 | ], | ||
54 | |||
55 | exports: [ | ||
56 | VideoWatchComponent, | ||
57 | |||
58 | TimestampRouteTransformerDirective | ||
59 | ], | ||
60 | |||
61 | providers: [ | ||
62 | VideoCommentService | ||
63 | ] | ||
64 | }) | ||
65 | export class VideoWatchModule { } | ||