aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+videos/+video-watch
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/+videos/+video-watch')
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.html56
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss82
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts149
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts7
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.html95
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.scss189
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.ts131
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.model.ts48
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.service.ts149
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.html98
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.scss53
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.ts232
-rw-r--r--client/src/app/+videos/+video-watch/modal/video-share.component.html187
-rw-r--r--client/src/app/+videos/+video-watch/modal/video-share.component.scss79
-rw-r--r--client/src/app/+videos/+video-watch/modal/video-share.component.ts126
-rw-r--r--client/src/app/+videos/+video-watch/modal/video-support.component.html15
-rw-r--r--client/src/app/+videos/+video-watch/modal/video-support.component.scss3
-rw-r--r--client/src/app/+videos/+video-watch/modal/video-support.component.ts29
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts81
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts4
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts34
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts7
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html24
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss31
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts91
-rw-r--r--client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts37
-rw-r--r--client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts39
-rw-r--r--client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts28
-rw-r--r--client/src/app/+videos/+video-watch/video-watch-playlist.component.html46
-rw-r--r--client/src/app/+videos/+video-watch/video-watch-playlist.component.scss83
-rw-r--r--client/src/app/+videos/+video-watch/video-watch-playlist.component.ts201
-rw-r--r--client/src/app/+videos/+video-watch/video-watch-routing.module.ts27
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html277
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.scss607
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts782
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.module.ts65
36 files changed, 4192 insertions, 0 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
new file mode 100644
index 000000000..9b43d91da
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html
@@ -0,0 +1,56 @@
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
new file mode 100644
index 000000000..b3725ab94
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss
@@ -0,0 +1,82 @@
1@import '_variables';
2@import '_mixins';
3
4form {
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
new file mode 100644
index 000000000..79505c779
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
@@ -0,0 +1,149 @@
1import { Observable } from 'rxjs'
2import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
3import { Router } from '@angular/router'
4import { Notifier, User } from '@app/core'
5import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
6import { Video } from '@app/shared/shared-main'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { VideoCommentCreate } from '@shared/models'
9import { VideoComment } from './video-comment.model'
10import { 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})
17export 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
new file mode 100644
index 000000000..7c2aaeadd
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts
@@ -0,0 +1,7 @@
1import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
2import { VideoComment } from './video-comment.model'
3
4export 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
new file mode 100644
index 000000000..002de57e4
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.html
@@ -0,0 +1,95 @@
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
new file mode 100644
index 000000000..e7ef79561
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss
@@ -0,0 +1,189 @@
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
new file mode 100644
index 000000000..27846c1ad
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
@@ -0,0 +1,131 @@
1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
2import { MarkdownService, Notifier, UserService } from '@app/core'
3import { AuthService } from '@app/core/auth'
4import { Account, Actor, Video } from '@app/shared/shared-main'
5import { User, UserRight } from '@shared/models'
6import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
7import { 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})
14export 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
new file mode 100644
index 000000000..e85443196
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts
@@ -0,0 +1,48 @@
1import { getAbsoluteAPIUrl } from '@app/helpers'
2import { Actor } from '@app/shared/shared-main'
3import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models'
4
5export 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
new file mode 100644
index 000000000..a73fb9ca8
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts
@@ -0,0 +1,149 @@
1import { Observable } from 'rxjs'
2import { catchError, map } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
6import { objectLineFeedToHtml } from '@app/helpers'
7import {
8 FeedFormat,
9 ResultList,
10 VideoComment as VideoCommentServerModel,
11 VideoCommentCreate,
12 VideoCommentThreadTree as VideoCommentThreadTreeServerModel
13} from '@shared/models'
14import { environment } from '../../../../environments/environment'
15import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
16import { VideoComment } from './video-comment.model'
17
18@Injectable()
19export 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
new file mode 100644
index 000000000..dd1d43560
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.html
@@ -0,0 +1,98 @@
1<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
new file mode 100644
index 000000000..df42fae73
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.scss
@@ -0,0 +1,53 @@
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
new file mode 100644
index 000000000..df0018ec6
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
@@ -0,0 +1,232 @@
1import { Subject, Subscription } from 'rxjs'
2import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
3import { ActivatedRoute } from '@angular/router'
4import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
5import { HooksService } from '@app/core/plugins/hooks.service'
6import { Syndication, VideoDetails } from '@app/shared/shared-main'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
9import { VideoComment } from './video-comment.model'
10import { 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})
17export 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
new file mode 100644
index 000000000..5e6a2d518
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/modal/video-share.component.html
@@ -0,0 +1,187 @@
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
new file mode 100644
index 000000000..091d4dc3b
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/modal/video-share.component.scss
@@ -0,0 +1,79 @@
1@import '_mixins';
2@import '_variables';
3
4my-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
new file mode 100644
index 000000000..b42b775c1
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/modal/video-share.component.ts
@@ -0,0 +1,126 @@
1import { Component, ElementRef, Input, ViewChild } from '@angular/core'
2import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { VideoCaption } from '@shared/models'
5import { VideoDetails } from '@app/shared/shared-main'
6import { VideoPlaylist } from '@app/shared/shared-video-playlist'
7
8type 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})
31export 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
new file mode 100644
index 000000000..935656d23
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/modal/video-support.component.html
@@ -0,0 +1,15 @@
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
new file mode 100644
index 000000000..184e09027
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/modal/video-support.component.scss
@@ -0,0 +1,3 @@
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
new file mode 100644
index 000000000..48d5f2948
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/modal/video-support.component.ts
@@ -0,0 +1,29 @@
1import { Component, Input, ViewChild } from '@angular/core'
2import { MarkdownService } from '@app/core'
3import { VideoDetails } from '@app/shared/shared-main'
4import { 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})
11export 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/recommendations/recent-videos-recommendation.service.ts b/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts
new file mode 100644
index 000000000..29fa268f4
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts
@@ -0,0 +1,81 @@
1import { Observable, of } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators'
3import { Injectable } from '@angular/core'
4import { ServerService, UserService } from '@app/core'
5import { Video, VideoService } from '@app/shared/shared-main'
6import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
7import { ServerConfig } from '@shared/models'
8import { RecommendationInfo } from './recommendation-info.model'
9import { RecommendationService } from './recommendations.service'
10
11/**
12 * Provides "recommendations" by providing the most recently uploaded videos.
13 */
14@Injectable()
15export class RecentVideosRecommendationService implements RecommendationService {
16 readonly pageSize = 5
17
18 private config: ServerConfig
19
20 constructor (
21 private videos: VideoService,
22 private searchService: SearchService,
23 private userService: UserService,
24 private serverService: ServerService
25 ) {
26 this.config = this.serverService.getTmpConfig()
27
28 this.serverService.getConfig()
29 .subscribe(config => this.config = config)
30 }
31
32 getRecommendations (recommendation: RecommendationInfo): Observable<Video[]> {
33 return this.fetchPage(1, recommendation)
34 .pipe(
35 map(videos => {
36 const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid)
37 return otherVideos.slice(0, this.pageSize)
38 })
39 )
40 }
41
42 private fetchPage (page: number, recommendation: RecommendationInfo): Observable<Video[]> {
43 const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 }
44 const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' })
45 .pipe(map(v => v.data))
46
47 const tags = recommendation.tags
48 const searchIndexConfig = this.config.search.searchIndex
49 if (
50 !tags || tags.length === 0 ||
51 (searchIndexConfig.enabled === true && searchIndexConfig.disableLocalSearch === true)
52 ) {
53 return defaultSubscription
54 }
55
56 return this.userService.getAnonymousOrLoggedUser()
57 .pipe(
58 map(user => {
59 return {
60 search: '',
61 componentPagination: pagination,
62 advancedSearch: new AdvancedSearch({
63 tagsOneOf: recommendation.tags.join(','),
64 sort: '-createdAt',
65 searchTarget: 'local',
66 nsfw: user.nsfwPolicy
67 ? this.videos.nsfwPolicyToParam(user.nsfwPolicy)
68 : undefined
69 })
70 }
71 }),
72 switchMap(params => this.searchService.searchVideos(params)),
73 map(v => v.data),
74 switchMap(videos => {
75 if (videos.length <= 1) return defaultSubscription
76
77 return of(videos)
78 })
79 )
80 }
81}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts b/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts
new file mode 100644
index 000000000..0233563bb
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts
@@ -0,0 +1,4 @@
1export interface RecommendationInfo {
2 uuid: string
3 tags?: string[]
4}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts
new file mode 100644
index 000000000..259afb196
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts
@@ -0,0 +1,34 @@
1import { InputSwitchModule } from 'primeng/inputswitch'
2import { CommonModule } from '@angular/common'
3import { NgModule } from '@angular/core'
4import { SharedMainModule } from '@app/shared/shared-main'
5import { SharedSearchModule } from '@app/shared/shared-search'
6import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
7import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
8import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
9import { RecommendedVideosComponent } from './recommended-videos.component'
10import { RecommendedVideosStore } from './recommended-videos.store'
11
12@NgModule({
13 imports: [
14 CommonModule,
15 InputSwitchModule,
16
17 SharedMainModule,
18 SharedSearchModule,
19 SharedVideoPlaylistModule,
20 SharedVideoMiniatureModule
21 ],
22 declarations: [
23 RecommendedVideosComponent
24 ],
25 exports: [
26 RecommendedVideosComponent
27 ],
28 providers: [
29 RecommendedVideosStore,
30 RecentVideosRecommendationService
31 ]
32})
33export class RecommendationsModule {
34}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts
new file mode 100644
index 000000000..1d79d35f6
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts
@@ -0,0 +1,7 @@
1import { Observable } from 'rxjs'
2import { Video } from '@app/shared/shared-main'
3import { RecommendationInfo } from './recommendation-info.model'
4
5export interface RecommendationService {
6 getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
7}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html
new file mode 100644
index 000000000..0467cabf5
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html
@@ -0,0 +1,24 @@
1<div class="other-videos">
2 <ng-container *ngIf="hasVideos$ | async">
3 <div class="title-page-container">
4 <h2 i18n class="title-page title-page-single">
5 Other videos
6 </h2>
7 <div *ngIf="!playlist" class="title-page-autoplay"
8 [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto"
9 >
10 <span i18n>AUTOPLAY</span>
11 <p-inputSwitch class="small" [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></p-inputSwitch>
12 </div>
13 </div>
14
15 <ng-container *ngFor="let video of (videos$ | async); let i = index; let length = count">
16 <my-video-miniature
17 [displayOptions]="displayOptions" [video]="video" [user]="userMiniature"
18 (videoBlocked)="onVideoRemoved()" (videoRemoved)="onVideoRemoved()">
19 </my-video-miniature>
20
21 <hr *ngIf="!playlist && i == 0 && length > 1" />
22 </ng-container>
23 </ng-container>
24</div>
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss
new file mode 100644
index 000000000..b278c9654
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss
@@ -0,0 +1,31 @@
1.title-page-container {
2 display: flex;
3 justify-content: space-between;
4 align-items: baseline;
5 margin-bottom: 25px;
6 flex-wrap: wrap-reverse;
7
8 .title-page.active, .title-page.title-page-single {
9 margin-bottom: unset;
10 margin-right: .5rem !important;
11 }
12}
13
14.title-page-autoplay {
15 display: flex;
16 width: max-content;
17 height: max-content;
18 align-items: center;
19 margin-left: auto;
20
21 span {
22 margin-right: 0.3rem;
23 text-transform: uppercase;
24 font-size: 85%;
25 font-weight: 600;
26 }
27}
28
29hr {
30 margin-top: 0;
31}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts
new file mode 100644
index 000000000..016975341
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts
@@ -0,0 +1,91 @@
1import { Observable } from 'rxjs'
2import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
3import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core'
4import { Video } from '@app/shared/shared-main'
5import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
6import { VideoPlaylist } from '@app/shared/shared-video-playlist'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { RecommendationInfo } from './recommendation-info.model'
9import { RecommendedVideosStore } from './recommended-videos.store'
10
11@Component({
12 selector: 'my-recommended-videos',
13 templateUrl: './recommended-videos.component.html',
14 styleUrls: [ './recommended-videos.component.scss' ]
15})
16export class RecommendedVideosComponent implements OnInit, OnChanges {
17 @Input() inputRecommendation: RecommendationInfo
18 @Input() playlist: VideoPlaylist
19 @Output() gotRecommendations = new EventEmitter<Video[]>()
20
21 autoPlayNextVideo: boolean
22 autoPlayNextVideoTooltip: string
23
24 displayOptions: MiniatureDisplayOptions = {
25 date: true,
26 views: true,
27 by: true,
28 avatar: true
29 }
30
31 userMiniature: User
32
33 readonly hasVideos$: Observable<boolean>
34 readonly videos$: Observable<Video[]>
35
36 constructor (
37 private userService: UserService,
38 private authService: AuthService,
39 private notifier: Notifier,
40 private i18n: I18n,
41 private store: RecommendedVideosStore,
42 private sessionStorageService: SessionStorageService
43 ) {
44 this.videos$ = this.store.recommendations$
45 this.hasVideos$ = this.store.hasRecommendations$
46 this.videos$.subscribe(videos => this.gotRecommendations.emit(videos))
47
48 if (this.authService.isLoggedIn()) {
49 this.autoPlayNextVideo = this.authService.getUser().autoPlayNextVideo
50 } else {
51 this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' || false
52 this.sessionStorageService.watch([User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO]).subscribe(
53 () => this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
54 )
55 }
56
57 this.autoPlayNextVideoTooltip = this.i18n('When active, the next video is automatically played after the current one.')
58 }
59
60 ngOnInit () {
61 this.userService.getAnonymousOrLoggedUser()
62 .subscribe(user => this.userMiniature = user)
63 }
64
65 ngOnChanges () {
66 if (this.inputRecommendation) {
67 this.store.requestNewRecommendations(this.inputRecommendation)
68 }
69 }
70
71 onVideoRemoved () {
72 this.store.requestNewRecommendations(this.inputRecommendation)
73 }
74
75 switchAutoPlayNextVideo () {
76 this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString())
77
78 if (this.authService.isLoggedIn()) {
79 const details = {
80 autoPlayNextVideo: this.autoPlayNextVideo
81 }
82
83 this.userService.updateMyProfile(details).subscribe(
84 () => {
85 this.authService.refreshUserInformation()
86 },
87 err => this.notifier.error(err.message)
88 )
89 }
90 }
91}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts
new file mode 100644
index 000000000..8c3fb6480
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts
@@ -0,0 +1,37 @@
1import { Observable, ReplaySubject } from 'rxjs'
2import { map, shareReplay, switchMap, take } from 'rxjs/operators'
3import { Inject, Injectable } from '@angular/core'
4import { Video } from '@app/shared/shared-main'
5import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
6import { RecommendationInfo } from './recommendation-info.model'
7import { RecommendationService } from './recommendations.service'
8
9/**
10 * This store is intended to provide data for the RecommendedVideosComponent.
11 */
12@Injectable()
13export class RecommendedVideosStore {
14 public readonly recommendations$: Observable<Video[]>
15 public readonly hasRecommendations$: Observable<boolean>
16 private readonly requestsForLoad$$ = new ReplaySubject<RecommendationInfo>(1)
17
18 constructor (
19 @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService
20 ) {
21 this.recommendations$ = this.requestsForLoad$$.pipe(
22 switchMap(requestedRecommendation => {
23 return this.recommendations.getRecommendations(requestedRecommendation)
24 .pipe(take(1))
25 }),
26 shareReplay()
27 )
28
29 this.hasRecommendations$ = this.recommendations$.pipe(
30 map(otherVideos => otherVideos.length > 0)
31 )
32 }
33
34 requestNewRecommendations (recommend: RecommendationInfo) {
35 this.requestsForLoad$$.next(recommend)
36 }
37}
diff --git a/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts b/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts
new file mode 100644
index 000000000..45e023695
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts
@@ -0,0 +1,39 @@
1import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
2
3@Directive({
4 selector: '[timestampRouteTransformer]'
5})
6export 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
new file mode 100644
index 000000000..4b6767415
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts
@@ -0,0 +1,28 @@
1import { Pipe, PipeTransform } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3
4@Pipe({
5 name: 'myVideoDurationFormatter'
6})
7export 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
new file mode 100644
index 000000000..246ef83cf
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch-playlist.component.html
@@ -0,0 +1,46 @@
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
new file mode 100644
index 000000000..0b0a2a899
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss
@@ -0,0 +1,83 @@
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
new file mode 100644
index 000000000..2c21be643
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts
@@ -0,0 +1,201 @@
1import { Component, Input } from '@angular/core'
2import { Router } from '@angular/router'
3import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core'
4import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage'
5import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { 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})
14export 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
new file mode 100644
index 000000000..d8fecb87d
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch-routing.module.ts
@@ -0,0 +1,27 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { VideoWatchComponent } from './video-watch.component'
5
6const 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})
27export class VideoWatchRoutingModule {}
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html
new file mode 100644
index 000000000..0447268f0
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch.component.html
@@ -0,0 +1,277 @@
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
new file mode 100644
index 000000000..2e083982e
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch.component.scss
@@ -0,0 +1,607 @@
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
437my-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
new file mode 100644
index 000000000..5b0b34c80
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -0,0 +1,782 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { forkJoin, Observable, Subscription } from 'rxjs'
3import { catchError } from 'rxjs/operators'
4import { PlatformLocation } from '@angular/common'
5import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
6import { ActivatedRoute, Router } from '@angular/router'
7import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core'
8import { HooksService } from '@app/core/plugins/hooks.service'
9import { RedirectService } from '@app/core/routing/redirect.service'
10import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers'
11import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
12import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
13import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
14import { MetaService } from '@ngx-meta/core'
15import { I18n } from '@ngx-translate/i18n-polyfill'
16import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
17import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
18import {
19 CustomizationOptions,
20 P2PMediaLoaderOptions,
21 PeertubePlayerManager,
22 PeertubePlayerManagerOptions,
23 PlayerMode,
24 videojs
25} from '../../../assets/player/peertube-player-manager'
26import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
27import { environment } from '../../../environments/environment'
28import { VideoShareComponent } from './modal/video-share.component'
29import { VideoSupportComponent } from './modal/video-support.component'
30import { 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})
37export 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
new file mode 100644
index 000000000..421170d81
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/video-watch.module.ts
@@ -0,0 +1,65 @@
1import { QRCodeModule } from 'angularx-qrcode'
2import { NgModule } from '@angular/core'
3import { SharedFormModule } from '@app/shared/shared-forms'
4import { SharedGlobalIconModule } from '@app/shared/shared-icons'
5import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedModerationModule } from '@app/shared/shared-moderation'
7import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
8import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
9import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
10import { RecommendationsModule } from './recommendations/recommendations.module'
11import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
12import { VideoCommentAddComponent } from './comment/video-comment-add.component'
13import { VideoCommentComponent } from './comment/video-comment.component'
14import { VideoCommentService } from './comment/video-comment.service'
15import { VideoCommentsComponent } from './comment/video-comments.component'
16import { VideoShareComponent } from './modal/video-share.component'
17import { VideoSupportComponent } from './modal/video-support.component'
18import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
19import { VideoDurationPipe } from './video-duration-formatter.pipe'
20import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
21import { VideoWatchRoutingModule } from './video-watch-routing.module'
22import { 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})
65export class VideoWatchModule { }