diff options
Diffstat (limited to 'client/src/app/+videos')
81 files changed, 7684 insertions, 0 deletions
diff --git a/client/src/app/+videos/+video-edit/shared/i18n-primeng-calendar.service.ts b/client/src/app/+videos/+video-edit/shared/i18n-primeng-calendar.service.ts new file mode 100644 index 000000000..b05852ff8 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/i18n-primeng-calendar.service.ts | |||
@@ -0,0 +1,94 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | |||
4 | @Injectable() | ||
5 | export class I18nPrimengCalendarService { | ||
6 | private readonly calendarLocale: any = {} | ||
7 | |||
8 | constructor (private i18n: I18n) { | ||
9 | this.calendarLocale = { | ||
10 | firstDayOfWeek: 0, | ||
11 | dayNames: [ | ||
12 | this.i18n('Sunday'), | ||
13 | this.i18n('Monday'), | ||
14 | this.i18n('Tuesday'), | ||
15 | this.i18n('Wednesday'), | ||
16 | this.i18n('Thursday'), | ||
17 | this.i18n('Friday'), | ||
18 | this.i18n('Saturday') | ||
19 | ], | ||
20 | |||
21 | dayNamesShort: [ | ||
22 | this.i18n({ value: 'Sun', description: 'Day name short' }), | ||
23 | this.i18n({ value: 'Mon', description: 'Day name short' }), | ||
24 | this.i18n({ value: 'Tue', description: 'Day name short' }), | ||
25 | this.i18n({ value: 'Wed', description: 'Day name short' }), | ||
26 | this.i18n({ value: 'Thu', description: 'Day name short' }), | ||
27 | this.i18n({ value: 'Fri', description: 'Day name short' }), | ||
28 | this.i18n({ value: 'Sat', description: 'Day name short' }) | ||
29 | ], | ||
30 | |||
31 | dayNamesMin: [ | ||
32 | this.i18n({ value: 'Su', description: 'Day name min' }), | ||
33 | this.i18n({ value: 'Mo', description: 'Day name min' }), | ||
34 | this.i18n({ value: 'Tu', description: 'Day name min' }), | ||
35 | this.i18n({ value: 'We', description: 'Day name min' }), | ||
36 | this.i18n({ value: 'Th', description: 'Day name min' }), | ||
37 | this.i18n({ value: 'Fr', description: 'Day name min' }), | ||
38 | this.i18n({ value: 'Sa', description: 'Day name min' }) | ||
39 | ], | ||
40 | |||
41 | monthNames: [ | ||
42 | this.i18n('January'), | ||
43 | this.i18n('February'), | ||
44 | this.i18n('March'), | ||
45 | this.i18n('April'), | ||
46 | this.i18n('May'), | ||
47 | this.i18n('June'), | ||
48 | this.i18n('July'), | ||
49 | this.i18n('August'), | ||
50 | this.i18n('September'), | ||
51 | this.i18n('October'), | ||
52 | this.i18n('November'), | ||
53 | this.i18n('December') | ||
54 | ], | ||
55 | |||
56 | monthNamesShort: [ | ||
57 | this.i18n({ value: 'Jan', description: 'Month name short' }), | ||
58 | this.i18n({ value: 'Feb', description: 'Month name short' }), | ||
59 | this.i18n({ value: 'Mar', description: 'Month name short' }), | ||
60 | this.i18n({ value: 'Apr', description: 'Month name short' }), | ||
61 | this.i18n({ value: 'May', description: 'Month name short' }), | ||
62 | this.i18n({ value: 'Jun', description: 'Month name short' }), | ||
63 | this.i18n({ value: 'Jul', description: 'Month name short' }), | ||
64 | this.i18n({ value: 'Aug', description: 'Month name short' }), | ||
65 | this.i18n({ value: 'Sep', description: 'Month name short' }), | ||
66 | this.i18n({ value: 'Oct', description: 'Month name short' }), | ||
67 | this.i18n({ value: 'Nov', description: 'Month name short' }), | ||
68 | this.i18n({ value: 'Dec', description: 'Month name short' }) | ||
69 | ], | ||
70 | |||
71 | today: this.i18n('Today'), | ||
72 | |||
73 | clear: this.i18n('Clear') | ||
74 | } | ||
75 | } | ||
76 | |||
77 | getCalendarLocale () { | ||
78 | return this.calendarLocale | ||
79 | } | ||
80 | |||
81 | getTimezone () { | ||
82 | const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1] | ||
83 | const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone | ||
84 | |||
85 | return `${timezone} - ${gmt}` | ||
86 | } | ||
87 | |||
88 | getDateFormat () { | ||
89 | return this.i18n({ | ||
90 | value: 'yy-mm-dd ', | ||
91 | description: 'Date format in this locale.' | ||
92 | }) | ||
93 | } | ||
94 | } | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html new file mode 100644 index 000000000..6a9e31b5a --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html | |||
@@ -0,0 +1,47 @@ | |||
1 | <ng-template #modal> | ||
2 | <ng-container [formGroup]="form"> | ||
3 | |||
4 | <div class="modal-header"> | ||
5 | <h4 i18n class="modal-title">Add caption</h4> | ||
6 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | ||
7 | </div> | ||
8 | |||
9 | <div class="modal-body"> | ||
10 | <label i18n for="language">Language</label> | ||
11 | <div class="peertube-select-container"> | ||
12 | <select id="language" formControlName="language" class="form-control"> | ||
13 | <option></option> | ||
14 | <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option> | ||
15 | </select> | ||
16 | </div> | ||
17 | |||
18 | <div *ngIf="formErrors.language" class="form-error"> | ||
19 | {{ formErrors.language }} | ||
20 | </div> | ||
21 | |||
22 | <div class="caption-file"> | ||
23 | <my-reactive-file | ||
24 | formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file" | ||
25 | [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true" | ||
26 | i18n-ngbTooltip [ngbTooltip]="'(extensions: ' + videoCaptionExtensions.join(', ') + ')'" | ||
27 | ></my-reactive-file> | ||
28 | </div> | ||
29 | |||
30 | <div *ngIf="isReplacingExistingCaption()" class="warning-replace-caption" i18n> | ||
31 | This will replace an existing caption! | ||
32 | </div> | ||
33 | </div> | ||
34 | |||
35 | <div class="modal-footer inputs"> | ||
36 | <input | ||
37 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
38 | (click)="hide()" (key.enter)="hide()" | ||
39 | > | ||
40 | |||
41 | <input | ||
42 | type="submit" i18n-value value="Add this caption" class="action-button-submit" | ||
43 | [disabled]="!form.valid" (click)="addCaption()" | ||
44 | > | ||
45 | </div> | ||
46 | </ng-container> | ||
47 | </ng-template> | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss new file mode 100644 index 000000000..b257a16a9 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss | |||
@@ -0,0 +1,20 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .peertube-select-container { | ||
5 | @include peertube-select-container(auto); | ||
6 | } | ||
7 | |||
8 | .caption-file { | ||
9 | margin-top: 20px; | ||
10 | width: max-content; | ||
11 | |||
12 | ::ng-deep .root { | ||
13 | width: max-content; | ||
14 | } | ||
15 | } | ||
16 | |||
17 | .warning-replace-caption { | ||
18 | color: red; | ||
19 | margin-top: 10px; | ||
20 | } \ No newline at end of file | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts new file mode 100644 index 000000000..a90d04ce8 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts | |||
@@ -0,0 +1,85 @@ | |||
1 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | ||
2 | import { ServerService } from '@app/core' | ||
3 | import { FormReactive, FormValidatorService, VideoCaptionsValidatorsService } from '@app/shared/shared-forms' | ||
4 | import { VideoCaptionEdit } from '@app/shared/shared-main' | ||
5 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' | ||
6 | import { ServerConfig, VideoConstant } from '@shared/models' | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-video-caption-add-modal', | ||
10 | styleUrls: [ './video-caption-add-modal.component.scss' ], | ||
11 | templateUrl: './video-caption-add-modal.component.html' | ||
12 | }) | ||
13 | |||
14 | export class VideoCaptionAddModalComponent extends FormReactive implements OnInit { | ||
15 | @Input() existingCaptions: string[] | ||
16 | @Input() serverConfig: ServerConfig | ||
17 | |||
18 | @Output() captionAdded = new EventEmitter<VideoCaptionEdit>() | ||
19 | |||
20 | @ViewChild('modal', { static: true }) modal: ElementRef | ||
21 | |||
22 | videoCaptionLanguages: VideoConstant<string>[] = [] | ||
23 | |||
24 | private openedModal: NgbModalRef | ||
25 | private closingModal = false | ||
26 | |||
27 | constructor ( | ||
28 | protected formValidatorService: FormValidatorService, | ||
29 | private modalService: NgbModal, | ||
30 | private serverService: ServerService, | ||
31 | private videoCaptionsValidatorsService: VideoCaptionsValidatorsService | ||
32 | ) { | ||
33 | super() | ||
34 | } | ||
35 | |||
36 | get videoCaptionExtensions () { | ||
37 | return this.serverConfig.videoCaption.file.extensions | ||
38 | } | ||
39 | |||
40 | get videoCaptionMaxSize () { | ||
41 | return this.serverConfig.videoCaption.file.size.max | ||
42 | } | ||
43 | |||
44 | ngOnInit () { | ||
45 | this.serverService.getVideoLanguages() | ||
46 | .subscribe(languages => this.videoCaptionLanguages = languages) | ||
47 | |||
48 | this.buildForm({ | ||
49 | language: this.videoCaptionsValidatorsService.VIDEO_CAPTION_LANGUAGE, | ||
50 | captionfile: this.videoCaptionsValidatorsService.VIDEO_CAPTION_FILE | ||
51 | }) | ||
52 | } | ||
53 | |||
54 | show () { | ||
55 | this.closingModal = false | ||
56 | |||
57 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) | ||
58 | } | ||
59 | |||
60 | hide () { | ||
61 | this.closingModal = true | ||
62 | this.openedModal.close() | ||
63 | this.form.reset() | ||
64 | } | ||
65 | |||
66 | isReplacingExistingCaption () { | ||
67 | if (this.closingModal === true) return false | ||
68 | |||
69 | const languageId = this.form.value[ 'language' ] | ||
70 | |||
71 | return languageId && this.existingCaptions.indexOf(languageId) !== -1 | ||
72 | } | ||
73 | |||
74 | async addCaption () { | ||
75 | const languageId = this.form.value[ 'language' ] | ||
76 | const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId) | ||
77 | |||
78 | this.captionAdded.emit({ | ||
79 | language: languageObject, | ||
80 | captionfile: this.form.value[ 'captionfile' ] | ||
81 | }) | ||
82 | |||
83 | this.hide() | ||
84 | } | ||
85 | } | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html new file mode 100644 index 000000000..c11a60dce --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html | |||
@@ -0,0 +1,280 @@ | |||
1 | <div class="video-edit" [formGroup]="form"> | ||
2 | <div ngbNav #nav="ngbNav" class="nav-tabs"> | ||
3 | |||
4 | <ng-container ngbNavItem> | ||
5 | <a ngbNavLink i18n>Basic info</a> | ||
6 | |||
7 | <ng-template ngbNavContent> | ||
8 | <div class="row"> | ||
9 | <div class="col-video-edit"> | ||
10 | <div class="form-group"> | ||
11 | <label i18n for="name">Title</label> | ||
12 | <input type="text" id="name" class="form-control" formControlName="name" /> | ||
13 | <div *ngIf="formErrors.name" class="form-error"> | ||
14 | {{ formErrors.name }} | ||
15 | </div> | ||
16 | </div> | ||
17 | |||
18 | <div class="form-group"> | ||
19 | <label i18n class="label-tags">Tags</label> | ||
20 | |||
21 | <my-help> | ||
22 | <ng-template ptTemplate="customHtml"> | ||
23 | <ng-container i18n> | ||
24 | Tags could be used to suggest relevant recommendations. <br /> | ||
25 | There is a maximum of 5 tags. <br /> | ||
26 | Press Enter to add a new tag. | ||
27 | </ng-container> | ||
28 | </ng-template> | ||
29 | </my-help> | ||
30 | |||
31 | <tag-input | ||
32 | [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" | ||
33 | i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag" | ||
34 | formControlName="tags" [maxItems]="5" [modelAsStrings]="true" | ||
35 | ></tag-input> | ||
36 | </div> | ||
37 | |||
38 | <div class="form-group"> | ||
39 | <label i18n for="description">Description</label> | ||
40 | |||
41 | <my-help helpType="markdownText"> | ||
42 | <ng-template ptTemplate="preHtml"> | ||
43 | <ng-container i18n> | ||
44 | Video descriptions are truncated by default and require manual action to expand them. | ||
45 | </ng-container> | ||
46 | </ng-template> | ||
47 | </my-help> | ||
48 | |||
49 | <my-markdown-textarea [truncate]="250" formControlName="description" [markdownVideo]="true"></my-markdown-textarea> | ||
50 | |||
51 | <div *ngIf="formErrors.description" class="form-error"> | ||
52 | {{ formErrors.description }} | ||
53 | </div> | ||
54 | </div> | ||
55 | </div> | ||
56 | |||
57 | <div class="col-video-edit"> | ||
58 | <div class="form-group"> | ||
59 | <label i18n>Channel</label> | ||
60 | <div class="peertube-select-container"> | ||
61 | <select formControlName="channelId" class="form-control"> | ||
62 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | ||
63 | </select> | ||
64 | </div> | ||
65 | </div> | ||
66 | |||
67 | <div class="form-group"> | ||
68 | <label i18n for="category">Category</label> | ||
69 | <div class="peertube-select-container"> | ||
70 | <select id="category" formControlName="category" class="form-control"> | ||
71 | <option></option> | ||
72 | <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> | ||
73 | </select> | ||
74 | </div> | ||
75 | |||
76 | <div *ngIf="formErrors.category" class="form-error"> | ||
77 | {{ formErrors.category }} | ||
78 | </div> | ||
79 | </div> | ||
80 | |||
81 | <div class="form-group"> | ||
82 | <label i18n for="licence">Licence</label> | ||
83 | <div class="peertube-select-container"> | ||
84 | <select id="licence" formControlName="licence" class="form-control"> | ||
85 | <option></option> | ||
86 | <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> | ||
87 | </select> | ||
88 | </div> | ||
89 | |||
90 | <div *ngIf="formErrors.licence" class="form-error"> | ||
91 | {{ formErrors.licence }} | ||
92 | </div> | ||
93 | </div> | ||
94 | |||
95 | <div class="form-group"> | ||
96 | <label i18n for="language">Language</label> | ||
97 | <div class="peertube-select-container"> | ||
98 | <select id="language" formControlName="language" class="form-control"> | ||
99 | <option></option> | ||
100 | <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> | ||
101 | </select> | ||
102 | </div> | ||
103 | |||
104 | <div *ngIf="formErrors.language" class="form-error"> | ||
105 | {{ formErrors.language }} | ||
106 | </div> | ||
107 | </div> | ||
108 | |||
109 | <div class="form-group"> | ||
110 | <label i18n for="privacy">Privacy</label> | ||
111 | <div class="peertube-select-container"> | ||
112 | <select id="privacy" formControlName="privacy" class="form-control"> | ||
113 | <option></option> | ||
114 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | ||
115 | <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> | ||
116 | </select> | ||
117 | </div> | ||
118 | |||
119 | <div *ngIf="formErrors.privacy" class="form-error"> | ||
120 | {{ formErrors.privacy }} | ||
121 | </div> | ||
122 | </div> | ||
123 | |||
124 | <div *ngIf="schedulePublicationEnabled" class="form-group"> | ||
125 | <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> | ||
126 | <p-calendar | ||
127 | id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" | ||
128 | [locale]="calendarLocale" [minDate]="minScheduledDate" [showTime]="true" [hideOnDateTimeSelect]="true" | ||
129 | > | ||
130 | </p-calendar> | ||
131 | |||
132 | <div *ngIf="formErrors.schedulePublicationAt" class="form-error"> | ||
133 | {{ formErrors.schedulePublicationAt }} | ||
134 | </div> | ||
135 | </div> | ||
136 | |||
137 | <my-peertube-checkbox inputName="nsfw" formControlName="nsfw" helpPlacement="bottom-right"> | ||
138 | <ng-template ptTemplate="label"> | ||
139 | <ng-container i18n>This video contains mature or explicit content</ng-container> | ||
140 | </ng-template> | ||
141 | |||
142 | <ng-template ptTemplate="help"> | ||
143 | <ng-container i18n>Some instances do not list videos containing mature or explicit content by default.</ng-container> | ||
144 | </ng-template> | ||
145 | </my-peertube-checkbox> | ||
146 | |||
147 | <my-peertube-checkbox *ngIf="waitTranscodingEnabled" inputName="waitTranscoding" formControlName="waitTranscoding" helpPlacement="bottom-right"> | ||
148 | <ng-template ptTemplate="label"> | ||
149 | <ng-container i18n>Wait transcoding before publishing the video</ng-container> | ||
150 | </ng-template> | ||
151 | |||
152 | <ng-template ptTemplate="help"> | ||
153 | <ng-container i18n>If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends.</ng-container> | ||
154 | </ng-template> | ||
155 | </my-peertube-checkbox> | ||
156 | |||
157 | </div> | ||
158 | </div> | ||
159 | </ng-template> | ||
160 | </ng-container> | ||
161 | |||
162 | <ng-container ngbNavItem> | ||
163 | <a ngbNavLink i18n>Captions</a> | ||
164 | |||
165 | <ng-template ngbNavContent> | ||
166 | <div class="captions"> | ||
167 | |||
168 | <div class="captions-header"> | ||
169 | <a (click)="openAddCaptionModal()" class="create-caption"> | ||
170 | <my-global-icon iconName="add" aria-hidden="true"></my-global-icon> | ||
171 | <ng-container i18n>Add another caption</ng-container> | ||
172 | </a> | ||
173 | </div> | ||
174 | |||
175 | <div class="form-group" *ngFor="let videoCaption of videoCaptions"> | ||
176 | |||
177 | <div class="caption-entry"> | ||
178 | <ng-container *ngIf="!videoCaption.action"> | ||
179 | <a | ||
180 | i18n-title title="See the subtitle file" class="caption-entry-label" target="_blank" rel="noopener noreferrer" | ||
181 | [href]="videoCaption.captionPath" | ||
182 | >{{ videoCaption.language.label }}</a> | ||
183 | |||
184 | <div i18n class="caption-entry-state">Already uploaded ✔</div> | ||
185 | |||
186 | <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span> | ||
187 | </ng-container> | ||
188 | |||
189 | <ng-container *ngIf="videoCaption.action === 'CREATE'"> | ||
190 | <span class="caption-entry-label">{{ videoCaption.language.label }}</span> | ||
191 | |||
192 | <div i18n class="caption-entry-state caption-entry-state-create">Will be created on update</div> | ||
193 | |||
194 | <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel create</span> | ||
195 | </ng-container> | ||
196 | |||
197 | <ng-container *ngIf="videoCaption.action === 'REMOVE'"> | ||
198 | <span class="caption-entry-label">{{ videoCaption.language.label }}</span> | ||
199 | |||
200 | <div i18n class="caption-entry-state caption-entry-state-delete">Will be deleted on update</div> | ||
201 | |||
202 | <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel deletion</span> | ||
203 | </ng-container> | ||
204 | </div> | ||
205 | </div> | ||
206 | |||
207 | <div i18n class="no-caption" *ngIf="videoCaptions?.length === 0"> | ||
208 | No captions for now. | ||
209 | </div> | ||
210 | |||
211 | </div> | ||
212 | </ng-template> | ||
213 | </ng-container> | ||
214 | |||
215 | <ng-container ngbNavItem> | ||
216 | <a ngbNavLink i18n>Advanced settings</a> | ||
217 | |||
218 | <ng-template ngbNavContent> | ||
219 | <div class="row advanced-settings"> | ||
220 | <div class="col-md-12 col-xl-8"> | ||
221 | |||
222 | <div class="form-group"> | ||
223 | <label i18n for="previewfile">Video preview</label> | ||
224 | |||
225 | <my-preview-upload | ||
226 | i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile" | ||
227 | previewWidth="360px" previewHeight="200px" | ||
228 | ></my-preview-upload> | ||
229 | </div> | ||
230 | |||
231 | <div class="form-group"> | ||
232 | <label i18n for="support">Support</label> | ||
233 | <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help> | ||
234 | <my-markdown-textarea | ||
235 | id="support" formControlName="support" markdownType="enhanced" | ||
236 | [classes]="{ 'input-error': formErrors['support'] }" | ||
237 | ></my-markdown-textarea> | ||
238 | <div *ngIf="formErrors.support" class="form-error"> | ||
239 | {{ formErrors.support }} | ||
240 | </div> | ||
241 | </div> | ||
242 | </div> | ||
243 | |||
244 | <div class="col-md-12 col-xl-4"> | ||
245 | <div class="form-group originally-published-at"> | ||
246 | <label i18n for="originallyPublishedAt">Original publication date</label> | ||
247 | <my-help i18n-preHtml preHtml="This is the date when the content was originally published (e.g. the release date for a film)"></my-help> | ||
248 | <p-calendar | ||
249 | id="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat" | ||
250 | [locale]="calendarLocale" [showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange" | ||
251 | > | ||
252 | </p-calendar> | ||
253 | |||
254 | <div *ngIf="formErrors.originallyPublishedAt" class="form-error"> | ||
255 | {{ formErrors.originallyPublishedAt }} | ||
256 | </div> | ||
257 | </div> | ||
258 | |||
259 | <my-peertube-checkbox | ||
260 | inputName="commentsEnabled" formControlName="commentsEnabled" | ||
261 | i18n-labelText labelText="Enable video comments" | ||
262 | ></my-peertube-checkbox> | ||
263 | |||
264 | <my-peertube-checkbox | ||
265 | inputName="downloadEnabled" formControlName="downloadEnabled" | ||
266 | i18n-labelText labelText="Enable download" | ||
267 | ></my-peertube-checkbox> | ||
268 | </div> | ||
269 | </div> | ||
270 | </ng-template> | ||
271 | </ng-container> | ||
272 | |||
273 | </div> | ||
274 | |||
275 | <div [ngbNavOutlet]="nav"></div> | ||
276 | </div> | ||
277 | |||
278 | <my-video-caption-add-modal | ||
279 | #videoCaptionAddModal [existingCaptions]="existingCaptions" [serverConfig]="serverConfig" (captionAdded)="onCaptionAdded($event)" | ||
280 | ></my-video-caption-add-modal> | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss new file mode 100644 index 000000000..69b907288 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss | |||
@@ -0,0 +1,197 @@ | |||
1 | // Bootstrap grid utilities require functions, variables and mixins | ||
2 | @import 'node_modules/bootstrap/scss/functions'; | ||
3 | @import 'node_modules/bootstrap/scss/variables'; | ||
4 | @import 'node_modules/bootstrap/scss/mixins'; | ||
5 | @import 'node_modules/bootstrap/scss/grid'; | ||
6 | |||
7 | @import 'variables'; | ||
8 | @import 'mixins'; | ||
9 | |||
10 | label { | ||
11 | font-weight: $font-regular; | ||
12 | font-size: 100%; | ||
13 | } | ||
14 | |||
15 | .peertube-select-container { | ||
16 | @include peertube-select-container(auto); | ||
17 | } | ||
18 | |||
19 | .title-page a { | ||
20 | color: pvar(--mainForegroundColor); | ||
21 | |||
22 | &:hover { | ||
23 | text-decoration: none; | ||
24 | opacity: .8; | ||
25 | } | ||
26 | } | ||
27 | |||
28 | my-peertube-checkbox { | ||
29 | display: block; | ||
30 | margin-bottom: 1rem; | ||
31 | } | ||
32 | |||
33 | .nav-tabs { | ||
34 | margin-bottom: 15px; | ||
35 | } | ||
36 | |||
37 | .video-edit { | ||
38 | height: 100%; | ||
39 | min-height: 300px; | ||
40 | |||
41 | .form-group { | ||
42 | margin-bottom: 25px; | ||
43 | } | ||
44 | |||
45 | input { | ||
46 | @include peertube-input-text(100%); | ||
47 | display: block; | ||
48 | } | ||
49 | |||
50 | .label-tags + span { | ||
51 | font-size: 15px; | ||
52 | } | ||
53 | |||
54 | .advanced-settings .form-group { | ||
55 | margin-bottom: 20px; | ||
56 | } | ||
57 | } | ||
58 | |||
59 | .captions { | ||
60 | |||
61 | .captions-header { | ||
62 | text-align: right; | ||
63 | margin-bottom: 1rem; | ||
64 | |||
65 | .create-caption { | ||
66 | @include create-button; | ||
67 | } | ||
68 | } | ||
69 | |||
70 | .caption-entry { | ||
71 | display: flex; | ||
72 | height: 40px; | ||
73 | align-items: center; | ||
74 | |||
75 | a.caption-entry-label { | ||
76 | @include disable-default-a-behaviour; | ||
77 | |||
78 | flex-grow: 1; | ||
79 | color: #000; | ||
80 | |||
81 | &:hover { | ||
82 | opacity: 0.8; | ||
83 | } | ||
84 | } | ||
85 | |||
86 | .caption-entry-label { | ||
87 | font-size: 15px; | ||
88 | font-weight: bold; | ||
89 | |||
90 | margin-right: 20px; | ||
91 | width: 150px; | ||
92 | } | ||
93 | |||
94 | .caption-entry-state { | ||
95 | width: 200px; | ||
96 | |||
97 | &.caption-entry-state-create { | ||
98 | color: #39CC0B; | ||
99 | } | ||
100 | |||
101 | &.caption-entry-state-delete { | ||
102 | color: #FF0000; | ||
103 | } | ||
104 | } | ||
105 | |||
106 | .caption-entry-delete { | ||
107 | @include peertube-button; | ||
108 | @include grey-button; | ||
109 | } | ||
110 | } | ||
111 | |||
112 | .no-caption { | ||
113 | text-align: center; | ||
114 | font-size: 15px; | ||
115 | } | ||
116 | } | ||
117 | |||
118 | .submit-container { | ||
119 | text-align: right; | ||
120 | |||
121 | .message-submit { | ||
122 | display: inline-block; | ||
123 | margin-right: 25px; | ||
124 | |||
125 | color: pvar(--greyForegroundColor); | ||
126 | font-size: 15px; | ||
127 | } | ||
128 | |||
129 | .submit-button { | ||
130 | @include peertube-button; | ||
131 | @include orange-button; | ||
132 | @include button-with-icon(20px, 1px); | ||
133 | |||
134 | display: inline-block; | ||
135 | |||
136 | input { | ||
137 | cursor: inherit; | ||
138 | background-color: inherit; | ||
139 | border: none; | ||
140 | padding: 0; | ||
141 | outline: 0; | ||
142 | color: inherit; | ||
143 | font-weight: $font-semibold; | ||
144 | } | ||
145 | } | ||
146 | } | ||
147 | |||
148 | p-calendar { | ||
149 | display: block; | ||
150 | |||
151 | ::ng-deep { | ||
152 | input, | ||
153 | .ui-calendar { | ||
154 | width: 100%; | ||
155 | } | ||
156 | |||
157 | input { | ||
158 | @include peertube-input-text(100%); | ||
159 | color: #000; | ||
160 | } | ||
161 | } | ||
162 | } | ||
163 | |||
164 | @include ng2-tags; | ||
165 | |||
166 | // columns for the video | ||
167 | .col-video-edit { | ||
168 | @include make-col-ready(); | ||
169 | |||
170 | @include media-breakpoint-up(md) { | ||
171 | @include make-col(7); | ||
172 | |||
173 | & + .col-video-edit { | ||
174 | @include make-col(5); | ||
175 | } | ||
176 | } | ||
177 | |||
178 | @include media-breakpoint-up(xl) { | ||
179 | @include make-col(8); | ||
180 | |||
181 | & + .col-video-edit { | ||
182 | @include make-col(4); | ||
183 | } | ||
184 | } | ||
185 | } | ||
186 | |||
187 | :host-context(.expanded) { | ||
188 | .col-video-edit { | ||
189 | @include media-breakpoint-up(md) { | ||
190 | @include make-col(8); | ||
191 | |||
192 | & + .col-video-edit { | ||
193 | @include make-col(4); | ||
194 | } | ||
195 | } | ||
196 | } | ||
197 | } | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts new file mode 100644 index 000000000..239e453ad --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts | |||
@@ -0,0 +1,274 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' | ||
3 | import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms' | ||
4 | import { ServerService } from '@app/core' | ||
5 | import { removeElementFromArray } from '@app/helpers' | ||
6 | import { FormReactiveValidationMessages, FormValidatorService, VideoValidatorsService } from '@app/shared/shared-forms' | ||
7 | import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main' | ||
8 | import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models' | ||
9 | import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' | ||
10 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-edit', | ||
14 | styleUrls: [ './video-edit.component.scss' ], | ||
15 | templateUrl: './video-edit.component.html' | ||
16 | }) | ||
17 | export class VideoEditComponent implements OnInit, OnDestroy { | ||
18 | @Input() form: FormGroup | ||
19 | @Input() formErrors: { [ id: string ]: string } = {} | ||
20 | @Input() validationMessages: FormReactiveValidationMessages = {} | ||
21 | @Input() userVideoChannels: { id: number, label: string, support: string }[] = [] | ||
22 | @Input() schedulePublicationPossible = true | ||
23 | @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = [] | ||
24 | @Input() waitTranscodingEnabled = true | ||
25 | |||
26 | @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent | ||
27 | |||
28 | // So that it can be accessed in the template | ||
29 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY | ||
30 | |||
31 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] | ||
32 | videoCategories: VideoConstant<number>[] = [] | ||
33 | videoLicences: VideoConstant<number>[] = [] | ||
34 | videoLanguages: VideoConstant<string>[] = [] | ||
35 | |||
36 | tagValidators: ValidatorFn[] | ||
37 | tagValidatorsMessages: { [ name: string ]: string } | ||
38 | |||
39 | schedulePublicationEnabled = false | ||
40 | |||
41 | calendarLocale: any = {} | ||
42 | minScheduledDate = new Date() | ||
43 | myYearRange = '1880:' + (new Date()).getFullYear() | ||
44 | |||
45 | calendarTimezone: string | ||
46 | calendarDateFormat: string | ||
47 | |||
48 | serverConfig: ServerConfig | ||
49 | |||
50 | private schedulerInterval: any | ||
51 | private firstPatchDone = false | ||
52 | private initialVideoCaptions: string[] = [] | ||
53 | |||
54 | constructor ( | ||
55 | private formValidatorService: FormValidatorService, | ||
56 | private videoValidatorsService: VideoValidatorsService, | ||
57 | private videoService: VideoService, | ||
58 | private serverService: ServerService, | ||
59 | private i18nPrimengCalendarService: I18nPrimengCalendarService, | ||
60 | private ngZone: NgZone | ||
61 | ) { | ||
62 | this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS | ||
63 | this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES | ||
64 | |||
65 | this.calendarLocale = this.i18nPrimengCalendarService.getCalendarLocale() | ||
66 | this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone() | ||
67 | this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat() | ||
68 | } | ||
69 | |||
70 | get existingCaptions () { | ||
71 | return this.videoCaptions | ||
72 | .filter(c => c.action !== 'REMOVE') | ||
73 | .map(c => c.language.id) | ||
74 | } | ||
75 | |||
76 | updateForm () { | ||
77 | const defaultValues: any = { | ||
78 | nsfw: 'false', | ||
79 | commentsEnabled: 'true', | ||
80 | downloadEnabled: 'true', | ||
81 | waitTranscoding: 'true', | ||
82 | tags: [] | ||
83 | } | ||
84 | const obj: any = { | ||
85 | name: this.videoValidatorsService.VIDEO_NAME, | ||
86 | privacy: this.videoValidatorsService.VIDEO_PRIVACY, | ||
87 | channelId: this.videoValidatorsService.VIDEO_CHANNEL, | ||
88 | nsfw: null, | ||
89 | commentsEnabled: null, | ||
90 | downloadEnabled: null, | ||
91 | waitTranscoding: null, | ||
92 | category: this.videoValidatorsService.VIDEO_CATEGORY, | ||
93 | licence: this.videoValidatorsService.VIDEO_LICENCE, | ||
94 | language: this.videoValidatorsService.VIDEO_LANGUAGE, | ||
95 | description: this.videoValidatorsService.VIDEO_DESCRIPTION, | ||
96 | tags: null, | ||
97 | previewfile: null, | ||
98 | support: this.videoValidatorsService.VIDEO_SUPPORT, | ||
99 | schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT, | ||
100 | originallyPublishedAt: this.videoValidatorsService.VIDEO_ORIGINALLY_PUBLISHED_AT | ||
101 | } | ||
102 | |||
103 | this.formValidatorService.updateForm( | ||
104 | this.form, | ||
105 | this.formErrors, | ||
106 | this.validationMessages, | ||
107 | obj, | ||
108 | defaultValues | ||
109 | ) | ||
110 | |||
111 | this.form.addControl('captions', new FormArray([ | ||
112 | new FormGroup({ | ||
113 | language: new FormControl(), | ||
114 | captionfile: new FormControl() | ||
115 | }) | ||
116 | ])) | ||
117 | |||
118 | this.trackChannelChange() | ||
119 | this.trackPrivacyChange() | ||
120 | } | ||
121 | |||
122 | ngOnInit () { | ||
123 | this.updateForm() | ||
124 | |||
125 | this.serverService.getVideoCategories() | ||
126 | .subscribe(res => this.videoCategories = res) | ||
127 | this.serverService.getVideoLicences() | ||
128 | .subscribe(res => this.videoLicences = res) | ||
129 | this.serverService.getVideoLanguages() | ||
130 | .subscribe(res => this.videoLanguages = res) | ||
131 | |||
132 | this.serverService.getVideoPrivacies() | ||
133 | .subscribe(privacies => this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies)) | ||
134 | |||
135 | this.serverConfig = this.serverService.getTmpConfig() | ||
136 | this.serverService.getConfig() | ||
137 | .subscribe(config => this.serverConfig = config) | ||
138 | |||
139 | this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id) | ||
140 | |||
141 | this.ngZone.runOutsideAngular(() => { | ||
142 | this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute | ||
143 | }) | ||
144 | } | ||
145 | |||
146 | ngOnDestroy () { | ||
147 | if (this.schedulerInterval) clearInterval(this.schedulerInterval) | ||
148 | } | ||
149 | |||
150 | onCaptionAdded (caption: VideoCaptionEdit) { | ||
151 | const existingCaption = this.videoCaptions.find(c => c.language.id === caption.language.id) | ||
152 | |||
153 | // Replace existing caption? | ||
154 | if (existingCaption) { | ||
155 | Object.assign(existingCaption, caption, { action: 'CREATE' as 'CREATE' }) | ||
156 | } else { | ||
157 | this.videoCaptions.push( | ||
158 | Object.assign(caption, { action: 'CREATE' as 'CREATE' }) | ||
159 | ) | ||
160 | } | ||
161 | |||
162 | this.sortVideoCaptions() | ||
163 | } | ||
164 | |||
165 | async deleteCaption (caption: VideoCaptionEdit) { | ||
166 | // Caption recovers his former state | ||
167 | if (caption.action && this.initialVideoCaptions.indexOf(caption.language.id) !== -1) { | ||
168 | caption.action = undefined | ||
169 | return | ||
170 | } | ||
171 | |||
172 | // This caption is not on the server, just remove it from our array | ||
173 | if (caption.action === 'CREATE') { | ||
174 | removeElementFromArray(this.videoCaptions, caption) | ||
175 | return | ||
176 | } | ||
177 | |||
178 | caption.action = 'REMOVE' as 'REMOVE' | ||
179 | } | ||
180 | |||
181 | openAddCaptionModal () { | ||
182 | this.videoCaptionAddModal.show() | ||
183 | } | ||
184 | |||
185 | private sortVideoCaptions () { | ||
186 | this.videoCaptions.sort((v1, v2) => { | ||
187 | if (v1.language.label < v2.language.label) return -1 | ||
188 | if (v1.language.label === v2.language.label) return 0 | ||
189 | |||
190 | return 1 | ||
191 | }) | ||
192 | } | ||
193 | |||
194 | private trackPrivacyChange () { | ||
195 | // We will update the schedule input and the wait transcoding checkbox validators | ||
196 | this.form.controls[ 'privacy' ] | ||
197 | .valueChanges | ||
198 | .pipe(map(res => parseInt(res.toString(), 10))) | ||
199 | .subscribe( | ||
200 | newPrivacyId => { | ||
201 | |||
202 | this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY | ||
203 | |||
204 | // Value changed | ||
205 | const scheduleControl = this.form.get('schedulePublicationAt') | ||
206 | const waitTranscodingControl = this.form.get('waitTranscoding') | ||
207 | |||
208 | if (this.schedulePublicationEnabled) { | ||
209 | scheduleControl.setValidators([ Validators.required ]) | ||
210 | |||
211 | waitTranscodingControl.disable() | ||
212 | waitTranscodingControl.setValue(false) | ||
213 | } else { | ||
214 | scheduleControl.clearValidators() | ||
215 | |||
216 | waitTranscodingControl.enable() | ||
217 | |||
218 | // Do not update the control value on first patch (values come from the server) | ||
219 | if (this.firstPatchDone === true) { | ||
220 | waitTranscodingControl.setValue(true) | ||
221 | } | ||
222 | } | ||
223 | |||
224 | scheduleControl.updateValueAndValidity() | ||
225 | waitTranscodingControl.updateValueAndValidity() | ||
226 | |||
227 | this.firstPatchDone = true | ||
228 | |||
229 | } | ||
230 | ) | ||
231 | } | ||
232 | |||
233 | private trackChannelChange () { | ||
234 | // We will update the "support" field depending on the channel | ||
235 | this.form.controls[ 'channelId' ] | ||
236 | .valueChanges | ||
237 | .pipe(map(res => parseInt(res.toString(), 10))) | ||
238 | .subscribe( | ||
239 | newChannelId => { | ||
240 | const oldChannelId = parseInt(this.form.value[ 'channelId' ], 10) | ||
241 | |||
242 | // Not initialized yet | ||
243 | if (isNaN(newChannelId)) return | ||
244 | const newChannel = this.userVideoChannels.find(c => c.id === newChannelId) | ||
245 | if (!newChannel) return | ||
246 | |||
247 | // Wait support field update | ||
248 | setTimeout(() => { | ||
249 | const currentSupport = this.form.value[ 'support' ] | ||
250 | |||
251 | // First time we set the channel? | ||
252 | if (isNaN(oldChannelId) && !currentSupport) return this.updateSupportField(newChannel.support) | ||
253 | |||
254 | const oldChannel = this.userVideoChannels.find(c => c.id === oldChannelId) | ||
255 | if (!newChannel || !oldChannel) { | ||
256 | console.error('Cannot find new or old channel.') | ||
257 | return | ||
258 | } | ||
259 | |||
260 | // If the current support text is not the same than the old channel, the user updated it. | ||
261 | // We don't want the user to lose his text, so stop here | ||
262 | if (currentSupport && currentSupport !== oldChannel.support) return | ||
263 | |||
264 | // Update the support text with our new channel | ||
265 | this.updateSupportField(newChannel.support) | ||
266 | }) | ||
267 | } | ||
268 | ) | ||
269 | } | ||
270 | |||
271 | private updateSupportField (support: string) { | ||
272 | return this.form.patchValue({ support: support || '' }) | ||
273 | } | ||
274 | } | ||
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts new file mode 100644 index 000000000..96061a300 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts | |||
@@ -0,0 +1,38 @@ | |||
1 | import { TagInputModule } from 'ngx-chips' | ||
2 | import { CalendarModule } from 'primeng/calendar' | ||
3 | import { NgModule } from '@angular/core' | ||
4 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
5 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | ||
6 | import { SharedMainModule } from '@app/shared/shared-main' | ||
7 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' | ||
8 | import { VideoEditComponent } from './video-edit.component' | ||
9 | |||
10 | @NgModule({ | ||
11 | imports: [ | ||
12 | TagInputModule, | ||
13 | CalendarModule, | ||
14 | |||
15 | SharedMainModule, | ||
16 | SharedFormModule, | ||
17 | SharedGlobalIconModule | ||
18 | ], | ||
19 | |||
20 | declarations: [ | ||
21 | VideoEditComponent, | ||
22 | VideoCaptionAddModalComponent | ||
23 | ], | ||
24 | |||
25 | exports: [ | ||
26 | TagInputModule, | ||
27 | CalendarModule, | ||
28 | |||
29 | SharedMainModule, | ||
30 | SharedFormModule, | ||
31 | SharedGlobalIconModule, | ||
32 | |||
33 | VideoEditComponent | ||
34 | ], | ||
35 | |||
36 | providers: [] | ||
37 | }) | ||
38 | export class VideoEditModule { } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts b/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts new file mode 100644 index 000000000..7b1a38c62 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts | |||
@@ -0,0 +1,30 @@ | |||
1 | import { Directive, Output, EventEmitter, HostBinding, HostListener } from '@angular/core' | ||
2 | |||
3 | @Directive({ | ||
4 | selector: '[dragDrop]' | ||
5 | }) | ||
6 | export class DragDropDirective { | ||
7 | @Output() fileDropped = new EventEmitter<FileList>() | ||
8 | |||
9 | @HostBinding('class.dragover') dragover = false | ||
10 | |||
11 | @HostListener('dragover', ['$event']) onDragOver (e: Event) { | ||
12 | e.preventDefault() | ||
13 | e.stopPropagation() | ||
14 | this.dragover = true | ||
15 | } | ||
16 | |||
17 | @HostListener('dragleave', ['$event']) public onDragLeave (e: Event) { | ||
18 | e.preventDefault() | ||
19 | e.stopPropagation() | ||
20 | this.dragover = false | ||
21 | } | ||
22 | |||
23 | @HostListener('drop', ['$event']) public ondrop (e: DragEvent) { | ||
24 | e.preventDefault() | ||
25 | e.stopPropagation() | ||
26 | this.dragover = false | ||
27 | const files = e.dataTransfer.files | ||
28 | if (files.length > 0) this.fileDropped.emit(files) | ||
29 | } | ||
30 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html new file mode 100644 index 000000000..7287f799d --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html | |||
@@ -0,0 +1,76 @@ | |||
1 | <div *ngIf="!hasImportedVideo" class="upload-video-container" dragDrop (fileDropped)="setTorrentFile($event)"> | ||
2 | <div class="first-step-block"> | ||
3 | <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> | ||
4 | |||
5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: .torrent)'"> | ||
6 | <span i18n>Select the torrent to import</span> | ||
7 | <input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" /> | ||
8 | </div> | ||
9 | |||
10 | <div class="torrent-or-magnet" i18n-data-content data-content="OR"></div> | ||
11 | |||
12 | <div class="form-group form-group-magnet-uri"> | ||
13 | <label i18n for="magnetUri">Paste magnet URI</label> | ||
14 | <my-help> | ||
15 | <ng-template ptTemplate="customHtml"> | ||
16 | <ng-container i18n> | ||
17 | You can import any torrent file that points to a mp4 file. | ||
18 | You should make sure you have diffusion rights over the content it points to, otherwise it could cause legal trouble to yourself and your instance. | ||
19 | </ng-container> | ||
20 | </ng-template> | ||
21 | </my-help> | ||
22 | |||
23 | <input type="text" id="magnetUri" [(ngModel)]="magnetUri" class="form-control" /> | ||
24 | </div> | ||
25 | |||
26 | <div class="form-group"> | ||
27 | <label i18n for="first-step-channel">Channel</label> | ||
28 | <div class="peertube-select-container"> | ||
29 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control"> | ||
30 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | ||
31 | </select> | ||
32 | </div> | ||
33 | </div> | ||
34 | |||
35 | <div class="form-group"> | ||
36 | <label i18n for="first-step-privacy">Privacy</label> | ||
37 | <div class="peertube-select-container"> | ||
38 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control"> | ||
39 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | ||
40 | </select> | ||
41 | </div> | ||
42 | </div> | ||
43 | |||
44 | <input | ||
45 | type="button" i18n-value value="Import" | ||
46 | [disabled]="!isMagnetUrlValid() || isImportingVideo" (click)="importVideo()" | ||
47 | /> | ||
48 | </div> | ||
49 | </div> | ||
50 | |||
51 | <div *ngIf="error" class="alert alert-danger"> | ||
52 | <div i18n>Sorry, but something went wrong</div> | ||
53 | {{ error }} | ||
54 | </div> | ||
55 | |||
56 | <div *ngIf="hasImportedVideo && !error" class="alert alert-info" i18n> | ||
57 | Congratulations, the video will be imported with BitTorrent! You can already add information about this video. | ||
58 | </div> | ||
59 | |||
60 | <!-- Hidden because we want to load the component --> | ||
61 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> | ||
62 | <my-video-edit | ||
63 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" | ||
64 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" | ||
65 | ></my-video-edit> | ||
66 | |||
67 | <div class="submit-container"> | ||
68 | <div class="submit-button" | ||
69 | (click)="updateSecondStep()" | ||
70 | [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }" | ||
71 | > | ||
72 | <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon> | ||
73 | <input type="button" i18n-value value="Update" /> | ||
74 | </div> | ||
75 | </div> | ||
76 | </form> | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.scss new file mode 100644 index 000000000..1fef74994 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.scss | |||
@@ -0,0 +1,18 @@ | |||
1 | @import 'variables'; | ||
2 | @import 'mixins'; | ||
3 | |||
4 | .first-step-block { | ||
5 | .torrent-or-magnet { | ||
6 | @include divider($color: pvar(--inputPlaceholderColor), $background: pvar(--submenuColor)); | ||
7 | |||
8 | &[data-content] { | ||
9 | margin: 1.5rem 0; | ||
10 | } | ||
11 | } | ||
12 | |||
13 | .form-group-magnet-uri { | ||
14 | margin-bottom: 40px; | ||
15 | } | ||
16 | } | ||
17 | |||
18 | |||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts new file mode 100644 index 000000000..538a187a8 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts | |||
@@ -0,0 +1,147 @@ | |||
1 | import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' | ||
4 | import { scrollToTop } from '@app/helpers' | ||
5 | import { FormValidatorService } from '@app/shared/shared-forms' | ||
6 | import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' | ||
7 | import { VideoSend } from './video-send' | ||
8 | import { LoadingBarService } from '@ngx-loading-bar/core' | ||
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
10 | import { VideoPrivacy, VideoUpdate } from '@shared/models' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-import-torrent', | ||
14 | templateUrl: './video-import-torrent.component.html', | ||
15 | styleUrls: [ | ||
16 | '../shared/video-edit.component.scss', | ||
17 | './video-import-torrent.component.scss', | ||
18 | './video-send.scss' | ||
19 | ] | ||
20 | }) | ||
21 | export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate { | ||
22 | @Output() firstStepDone = new EventEmitter<string>() | ||
23 | @Output() firstStepError = new EventEmitter<void>() | ||
24 | @ViewChild('torrentfileInput') torrentfileInput: ElementRef<HTMLInputElement> | ||
25 | |||
26 | magnetUri = '' | ||
27 | |||
28 | isImportingVideo = false | ||
29 | hasImportedVideo = false | ||
30 | isUpdatingVideo = false | ||
31 | |||
32 | video: VideoEdit | ||
33 | error: string | ||
34 | |||
35 | protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC | ||
36 | |||
37 | constructor ( | ||
38 | protected formValidatorService: FormValidatorService, | ||
39 | protected loadingBar: LoadingBarService, | ||
40 | protected notifier: Notifier, | ||
41 | protected authService: AuthService, | ||
42 | protected serverService: ServerService, | ||
43 | protected videoService: VideoService, | ||
44 | protected videoCaptionService: VideoCaptionService, | ||
45 | private router: Router, | ||
46 | private videoImportService: VideoImportService, | ||
47 | private i18n: I18n | ||
48 | ) { | ||
49 | super() | ||
50 | } | ||
51 | |||
52 | ngOnInit () { | ||
53 | super.ngOnInit() | ||
54 | } | ||
55 | |||
56 | canDeactivate () { | ||
57 | return { canDeactivate: true } | ||
58 | } | ||
59 | |||
60 | isMagnetUrlValid () { | ||
61 | return !!this.magnetUri | ||
62 | } | ||
63 | |||
64 | fileChange () { | ||
65 | const torrentfile = this.torrentfileInput.nativeElement.files[0] | ||
66 | if (!torrentfile) return | ||
67 | |||
68 | this.importVideo(torrentfile) | ||
69 | } | ||
70 | |||
71 | setTorrentFile (files: FileList) { | ||
72 | this.torrentfileInput.nativeElement.files = files | ||
73 | this.fileChange() | ||
74 | } | ||
75 | |||
76 | importVideo (torrentfile?: Blob) { | ||
77 | this.isImportingVideo = true | ||
78 | |||
79 | const videoUpdate: VideoUpdate = { | ||
80 | privacy: this.firstStepPrivacyId, | ||
81 | waitTranscoding: false, | ||
82 | commentsEnabled: true, | ||
83 | downloadEnabled: true, | ||
84 | channelId: this.firstStepChannelId | ||
85 | } | ||
86 | |||
87 | this.loadingBar.start() | ||
88 | |||
89 | this.videoImportService.importVideoTorrent(torrentfile || this.magnetUri, videoUpdate).subscribe( | ||
90 | res => { | ||
91 | this.loadingBar.complete() | ||
92 | this.firstStepDone.emit(res.video.name) | ||
93 | this.isImportingVideo = false | ||
94 | this.hasImportedVideo = true | ||
95 | |||
96 | this.video = new VideoEdit(Object.assign(res.video, { | ||
97 | commentsEnabled: videoUpdate.commentsEnabled, | ||
98 | downloadEnabled: videoUpdate.downloadEnabled, | ||
99 | support: null, | ||
100 | thumbnailUrl: null, | ||
101 | previewUrl: null | ||
102 | })) | ||
103 | |||
104 | this.hydrateFormFromVideo() | ||
105 | }, | ||
106 | |||
107 | err => { | ||
108 | this.loadingBar.complete() | ||
109 | this.isImportingVideo = false | ||
110 | this.firstStepError.emit() | ||
111 | this.notifier.error(err.message) | ||
112 | } | ||
113 | ) | ||
114 | } | ||
115 | |||
116 | updateSecondStep () { | ||
117 | if (this.checkForm() === false) { | ||
118 | return | ||
119 | } | ||
120 | |||
121 | this.video.patch(this.form.value) | ||
122 | |||
123 | this.isUpdatingVideo = true | ||
124 | |||
125 | // Update the video | ||
126 | this.updateVideoAndCaptions(this.video) | ||
127 | .subscribe( | ||
128 | () => { | ||
129 | this.isUpdatingVideo = false | ||
130 | this.notifier.success(this.i18n('Video to import updated.')) | ||
131 | |||
132 | this.router.navigate([ '/my-account', 'video-imports' ]) | ||
133 | }, | ||
134 | |||
135 | err => { | ||
136 | this.error = err.message | ||
137 | scrollToTop() | ||
138 | console.error(err) | ||
139 | } | ||
140 | ) | ||
141 | |||
142 | } | ||
143 | |||
144 | private hydrateFormFromVideo () { | ||
145 | this.form.patchValue(this.video.toFormPatch()) | ||
146 | } | ||
147 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html new file mode 100644 index 000000000..1910da403 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html | |||
@@ -0,0 +1,72 @@ | |||
1 | <div *ngIf="!hasImportedVideo" class="upload-video-container"> | ||
2 | <div class="first-step-block"> | ||
3 | <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> | ||
4 | |||
5 | <div class="form-group"> | ||
6 | <label i18n for="targetUrl">URL</label> | ||
7 | |||
8 | <my-help> | ||
9 | <ng-template ptTemplate="customHtml"> | ||
10 | <ng-container i18n> | ||
11 | You can import any URL <a href='https://rg3.github.io/youtube-dl/supportedsites.html' target='_blank' rel='noopener noreferrer'>supported by youtube-dl</a> | ||
12 | or URL that points to a raw MP4 file. | ||
13 | You should make sure you have diffusion rights over the content it points to, otherwise it could cause legal trouble to yourself and your instance. | ||
14 | </ng-container> | ||
15 | </ng-template> | ||
16 | </my-help> | ||
17 | |||
18 | <input type="text" id="targetUrl" [(ngModel)]="targetUrl" class="form-control" /> | ||
19 | </div> | ||
20 | |||
21 | <div class="form-group"> | ||
22 | <label i18n for="first-step-channel">Channel</label> | ||
23 | <div class="peertube-select-container"> | ||
24 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control"> | ||
25 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | ||
26 | </select> | ||
27 | </div> | ||
28 | </div> | ||
29 | |||
30 | <div class="form-group"> | ||
31 | <label i18n for="first-step-privacy">Privacy</label> | ||
32 | <div class="peertube-select-container"> | ||
33 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control"> | ||
34 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | ||
35 | </select> | ||
36 | </div> | ||
37 | </div> | ||
38 | |||
39 | <input | ||
40 | type="button" i18n-value value="Import" | ||
41 | [disabled]="!isTargetUrlValid() || isImportingVideo" (click)="importVideo()" | ||
42 | /> | ||
43 | </div> | ||
44 | </div> | ||
45 | |||
46 | |||
47 | <div *ngIf="error" class="alert alert-danger"> | ||
48 | <div i18n>Sorry, but something went wrong</div> | ||
49 | {{ error }} | ||
50 | </div> | ||
51 | |||
52 | <div *ngIf="!error && hasImportedVideo" class="alert alert-info" i18n> | ||
53 | Congratulations, the video behind {{ targetUrl }} will be imported! You can already add information about this video. | ||
54 | </div> | ||
55 | |||
56 | <!-- Hidden because we want to load the component --> | ||
57 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> | ||
58 | <my-video-edit | ||
59 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" | ||
60 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" | ||
61 | ></my-video-edit> | ||
62 | |||
63 | <div class="submit-container"> | ||
64 | <div class="submit-button" | ||
65 | (click)="updateSecondStep()" | ||
66 | [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }" | ||
67 | > | ||
68 | <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon> | ||
69 | <input type="button" i18n-value value="Update" /> | ||
70 | </div> | ||
71 | </div> | ||
72 | </form> | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts new file mode 100644 index 000000000..6508eef7e --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts | |||
@@ -0,0 +1,178 @@ | |||
1 | import { map, switchMap } from 'rxjs/operators' | ||
2 | import { Component, EventEmitter, OnInit, Output } from '@angular/core' | ||
3 | import { Router } from '@angular/router' | ||
4 | import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' | ||
5 | import { getAbsoluteAPIUrl, scrollToTop } from '@app/helpers' | ||
6 | import { FormValidatorService } from '@app/shared/shared-forms' | ||
7 | import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' | ||
8 | import { VideoSend } from './video-send' | ||
9 | import { LoadingBarService } from '@ngx-loading-bar/core' | ||
10 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
11 | import { VideoPrivacy, VideoUpdate } from '@shared/models' | ||
12 | |||
13 | @Component({ | ||
14 | selector: 'my-video-import-url', | ||
15 | templateUrl: './video-import-url.component.html', | ||
16 | styleUrls: [ | ||
17 | '../shared/video-edit.component.scss', | ||
18 | './video-send.scss' | ||
19 | ] | ||
20 | }) | ||
21 | export class VideoImportUrlComponent extends VideoSend implements OnInit, CanComponentDeactivate { | ||
22 | @Output() firstStepDone = new EventEmitter<string>() | ||
23 | @Output() firstStepError = new EventEmitter<void>() | ||
24 | |||
25 | targetUrl = '' | ||
26 | |||
27 | isImportingVideo = false | ||
28 | hasImportedVideo = false | ||
29 | isUpdatingVideo = false | ||
30 | |||
31 | video: VideoEdit | ||
32 | error: string | ||
33 | |||
34 | protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC | ||
35 | |||
36 | constructor ( | ||
37 | protected formValidatorService: FormValidatorService, | ||
38 | protected loadingBar: LoadingBarService, | ||
39 | protected notifier: Notifier, | ||
40 | protected authService: AuthService, | ||
41 | protected serverService: ServerService, | ||
42 | protected videoService: VideoService, | ||
43 | protected videoCaptionService: VideoCaptionService, | ||
44 | private router: Router, | ||
45 | private videoImportService: VideoImportService, | ||
46 | private i18n: I18n | ||
47 | ) { | ||
48 | super() | ||
49 | } | ||
50 | |||
51 | ngOnInit () { | ||
52 | super.ngOnInit() | ||
53 | } | ||
54 | |||
55 | canDeactivate () { | ||
56 | return { canDeactivate: true } | ||
57 | } | ||
58 | |||
59 | isTargetUrlValid () { | ||
60 | return this.targetUrl && this.targetUrl.match(/https?:\/\//) | ||
61 | } | ||
62 | |||
63 | importVideo () { | ||
64 | this.isImportingVideo = true | ||
65 | |||
66 | const videoUpdate: VideoUpdate = { | ||
67 | privacy: this.firstStepPrivacyId, | ||
68 | waitTranscoding: false, | ||
69 | commentsEnabled: true, | ||
70 | downloadEnabled: true, | ||
71 | channelId: this.firstStepChannelId | ||
72 | } | ||
73 | |||
74 | this.loadingBar.start() | ||
75 | |||
76 | this.videoImportService | ||
77 | .importVideoUrl(this.targetUrl, videoUpdate) | ||
78 | .pipe( | ||
79 | switchMap(res => { | ||
80 | return this.videoCaptionService | ||
81 | .listCaptions(res.video.id) | ||
82 | .pipe( | ||
83 | map(result => ({ video: res.video, videoCaptions: result.data })) | ||
84 | ) | ||
85 | }) | ||
86 | ) | ||
87 | .subscribe( | ||
88 | ({ video, videoCaptions }) => { | ||
89 | this.loadingBar.complete() | ||
90 | this.firstStepDone.emit(video.name) | ||
91 | this.isImportingVideo = false | ||
92 | this.hasImportedVideo = true | ||
93 | |||
94 | const absoluteAPIUrl = getAbsoluteAPIUrl() | ||
95 | |||
96 | const thumbnailUrl = video.thumbnailPath | ||
97 | ? absoluteAPIUrl + video.thumbnailPath | ||
98 | : null | ||
99 | |||
100 | const previewUrl = video.previewPath | ||
101 | ? absoluteAPIUrl + video.previewPath | ||
102 | : null | ||
103 | |||
104 | this.video = new VideoEdit(Object.assign(video, { | ||
105 | commentsEnabled: videoUpdate.commentsEnabled, | ||
106 | downloadEnabled: videoUpdate.downloadEnabled, | ||
107 | support: null, | ||
108 | thumbnailUrl, | ||
109 | previewUrl | ||
110 | })) | ||
111 | |||
112 | this.videoCaptions = videoCaptions | ||
113 | |||
114 | this.hydrateFormFromVideo() | ||
115 | }, | ||
116 | |||
117 | err => { | ||
118 | this.loadingBar.complete() | ||
119 | this.isImportingVideo = false | ||
120 | this.firstStepError.emit() | ||
121 | this.notifier.error(err.message) | ||
122 | } | ||
123 | ) | ||
124 | } | ||
125 | |||
126 | updateSecondStep () { | ||
127 | if (this.checkForm() === false) { | ||
128 | return | ||
129 | } | ||
130 | |||
131 | this.video.patch(this.form.value) | ||
132 | |||
133 | this.isUpdatingVideo = true | ||
134 | |||
135 | // Update the video | ||
136 | this.updateVideoAndCaptions(this.video) | ||
137 | .subscribe( | ||
138 | () => { | ||
139 | this.isUpdatingVideo = false | ||
140 | this.notifier.success(this.i18n('Video to import updated.')) | ||
141 | |||
142 | this.router.navigate([ '/my-account', 'video-imports' ]) | ||
143 | }, | ||
144 | |||
145 | err => { | ||
146 | this.error = err.message | ||
147 | scrollToTop() | ||
148 | console.error(err) | ||
149 | } | ||
150 | ) | ||
151 | |||
152 | } | ||
153 | |||
154 | private hydrateFormFromVideo () { | ||
155 | this.form.patchValue(this.video.toFormPatch()) | ||
156 | |||
157 | const objects = [ | ||
158 | { | ||
159 | url: 'thumbnailUrl', | ||
160 | name: 'thumbnailfile' | ||
161 | }, | ||
162 | { | ||
163 | url: 'previewUrl', | ||
164 | name: 'previewfile' | ||
165 | } | ||
166 | ] | ||
167 | |||
168 | for (const obj of objects) { | ||
169 | fetch(this.video[obj.url]) | ||
170 | .then(response => response.blob()) | ||
171 | .then(data => { | ||
172 | this.form.patchValue({ | ||
173 | [ obj.name ]: data | ||
174 | }) | ||
175 | }) | ||
176 | } | ||
177 | } | ||
178 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.scss b/client/src/app/+videos/+video-edit/video-add-components/video-send.scss new file mode 100644 index 000000000..ebe14c59e --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-send.scss | |||
@@ -0,0 +1,46 @@ | |||
1 | @import 'variables'; | ||
2 | @import 'mixins'; | ||
3 | |||
4 | $width-size: 190px; | ||
5 | |||
6 | .alert.alert-danger { | ||
7 | text-align: center; | ||
8 | |||
9 | & > div { | ||
10 | font-weight: $font-semibold; | ||
11 | } | ||
12 | } | ||
13 | |||
14 | .first-step-block { | ||
15 | display: flex; | ||
16 | flex-direction: column; | ||
17 | align-items: center; | ||
18 | |||
19 | .upload-icon { | ||
20 | width: 90px; | ||
21 | margin-bottom: 25px; | ||
22 | |||
23 | @include apply-svg-color(#C6C6C6); | ||
24 | } | ||
25 | |||
26 | .peertube-select-container { | ||
27 | @include peertube-select-container($width-size); | ||
28 | } | ||
29 | |||
30 | input[type=text] { | ||
31 | @include peertube-input-text($width-size); | ||
32 | display: block; | ||
33 | } | ||
34 | |||
35 | input[type=button] { | ||
36 | @include peertube-button; | ||
37 | @include orange-button; | ||
38 | |||
39 | width: $width-size; | ||
40 | margin-top: 30px; | ||
41 | } | ||
42 | |||
43 | .button-file { | ||
44 | @include peertube-button-file(max-content); | ||
45 | } | ||
46 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts new file mode 100644 index 000000000..94479321d --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts | |||
@@ -0,0 +1,71 @@ | |||
1 | import { catchError, switchMap, tap } from 'rxjs/operators' | ||
2 | import { EventEmitter, OnInit } from '@angular/core' | ||
3 | import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' | ||
4 | import { populateAsyncUserVideoChannels } from '@app/helpers' | ||
5 | import { FormReactive } from '@app/shared/shared-forms' | ||
6 | import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | ||
7 | import { LoadingBarService } from '@ngx-loading-bar/core' | ||
8 | import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models' | ||
9 | |||
10 | export abstract class VideoSend extends FormReactive implements OnInit { | ||
11 | userVideoChannels: { id: number, label: string, support: string }[] = [] | ||
12 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] | ||
13 | videoCaptions: VideoCaptionEdit[] = [] | ||
14 | |||
15 | firstStepPrivacyId = 0 | ||
16 | firstStepChannelId = 0 | ||
17 | |||
18 | abstract firstStepDone: EventEmitter<string> | ||
19 | abstract firstStepError: EventEmitter<void> | ||
20 | protected abstract readonly DEFAULT_VIDEO_PRIVACY: VideoPrivacy | ||
21 | |||
22 | protected loadingBar: LoadingBarService | ||
23 | protected notifier: Notifier | ||
24 | protected authService: AuthService | ||
25 | protected serverService: ServerService | ||
26 | protected videoService: VideoService | ||
27 | protected videoCaptionService: VideoCaptionService | ||
28 | protected serverConfig: ServerConfig | ||
29 | |||
30 | abstract canDeactivate (): CanComponentDeactivateResult | ||
31 | |||
32 | ngOnInit () { | ||
33 | this.buildForm({}) | ||
34 | |||
35 | populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) | ||
36 | .then(() => this.firstStepChannelId = this.userVideoChannels[ 0 ].id) | ||
37 | |||
38 | this.serverConfig = this.serverService.getTmpConfig() | ||
39 | this.serverService.getConfig() | ||
40 | .subscribe(config => this.serverConfig = config) | ||
41 | |||
42 | this.serverService.getVideoPrivacies() | ||
43 | .subscribe( | ||
44 | privacies => { | ||
45 | this.videoPrivacies = privacies | ||
46 | |||
47 | this.firstStepPrivacyId = this.DEFAULT_VIDEO_PRIVACY | ||
48 | }) | ||
49 | } | ||
50 | |||
51 | checkForm () { | ||
52 | this.forceCheck() | ||
53 | |||
54 | return this.form.valid | ||
55 | } | ||
56 | |||
57 | protected updateVideoAndCaptions (video: VideoEdit) { | ||
58 | this.loadingBar.start() | ||
59 | |||
60 | return this.videoService.updateVideo(video) | ||
61 | .pipe( | ||
62 | // Then update captions | ||
63 | switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)), | ||
64 | tap(() => this.loadingBar.complete()), | ||
65 | catchError(err => { | ||
66 | this.loadingBar.complete() | ||
67 | throw err | ||
68 | }) | ||
69 | ) | ||
70 | } | ||
71 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html new file mode 100644 index 000000000..dad88a661 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html | |||
@@ -0,0 +1,90 @@ | |||
1 | <div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)"> | ||
2 | <div class="first-step-block"> | ||
3 | <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> | ||
4 | |||
5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'"> | ||
6 | <span i18n>Select the file to upload</span> | ||
7 | <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus /> | ||
8 | </div> | ||
9 | |||
10 | <div class="form-group form-group-channel"> | ||
11 | <label i18n for="first-step-channel">Channel</label> | ||
12 | <div class="peertube-select-container"> | ||
13 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control"> | ||
14 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | ||
15 | </select> | ||
16 | </div> | ||
17 | </div> | ||
18 | |||
19 | <div class="form-group"> | ||
20 | <label i18n for="first-step-privacy">Privacy</label> | ||
21 | <div class="peertube-select-container"> | ||
22 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control"> | ||
23 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | ||
24 | <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> | ||
25 | </select> | ||
26 | </div> | ||
27 | </div> | ||
28 | |||
29 | <ng-container *ngIf="isUploadingAudioFile"> | ||
30 | <div class="form-group audio-preview"> | ||
31 | <label i18n for="previewfileUpload">Video background image</label> | ||
32 | |||
33 | <div i18n class="audio-image-info"> | ||
34 | Image that will be merged with your audio file. | ||
35 | <br /> | ||
36 | The chosen image will be definitive and cannot be modified. | ||
37 | </div> | ||
38 | |||
39 | <my-preview-upload | ||
40 | i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="previewfileUpload" | ||
41 | previewWidth="360px" previewHeight="200px" | ||
42 | ></my-preview-upload> | ||
43 | </div> | ||
44 | |||
45 | <div class="form-group upload-audio-button"> | ||
46 | <my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button> | ||
47 | </div> | ||
48 | </ng-container> | ||
49 | </div> | ||
50 | </div> | ||
51 | |||
52 | <div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel"> | ||
53 | <div class="progress" i18n-title title="Total video quota"> | ||
54 | <div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100"> | ||
55 | <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span> | ||
56 | <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span> | ||
57 | </div> | ||
58 | </div> | ||
59 | <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" /> | ||
60 | </div> | ||
61 | |||
62 | <div *ngIf="error" class="alert alert-danger"> | ||
63 | <div i18n>Sorry, but something went wrong</div> | ||
64 | {{ error }} | ||
65 | </div> | ||
66 | |||
67 | <div *ngIf="videoUploaded && !error" class="alert alert-info" i18n> | ||
68 | Congratulations! Your video is now available in your private library. | ||
69 | </div> | ||
70 | |||
71 | <!-- Hidden because we want to load the component --> | ||
72 | <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form" class="mb-3"> | ||
73 | <my-video-edit | ||
74 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" | ||
75 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" | ||
76 | [waitTranscodingEnabled]="waitTranscodingEnabled" | ||
77 | ></my-video-edit> | ||
78 | |||
79 | <div class="submit-container"> | ||
80 | <div i18n *ngIf="videoUploaded === false" class="message-submit">Publish will be available when upload is finished</div> | ||
81 | |||
82 | <div class="submit-button" | ||
83 | (click)="updateSecondStep()" | ||
84 | [ngClass]="{ disabled: isPublishingButtonDisabled() }" | ||
85 | > | ||
86 | <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon> | ||
87 | <input [disabled]="isPublishingButtonDisabled()" type="button" i18n-value value="Publish" /> | ||
88 | </div> | ||
89 | </div> | ||
90 | </form> | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss new file mode 100644 index 000000000..a4f87b0b8 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss | |||
@@ -0,0 +1,49 @@ | |||
1 | @import 'variables'; | ||
2 | @import 'mixins'; | ||
3 | |||
4 | .first-step-block { | ||
5 | .form-group-channel { | ||
6 | margin-bottom: 20px; | ||
7 | margin-top: 35px; | ||
8 | } | ||
9 | |||
10 | .audio-image-info { | ||
11 | margin-bottom: 10px; | ||
12 | } | ||
13 | |||
14 | .audio-preview { | ||
15 | margin: 30px 0; | ||
16 | } | ||
17 | } | ||
18 | |||
19 | .upload-progress-cancel { | ||
20 | display: flex; | ||
21 | margin-top: 25px; | ||
22 | margin-bottom: 40px; | ||
23 | |||
24 | .progress { | ||
25 | @include progressbar; | ||
26 | flex-grow: 1; | ||
27 | height: 30px; | ||
28 | font-size: 15px; | ||
29 | background-color: rgba(11, 204, 41, 0.16); | ||
30 | |||
31 | .progress-bar { | ||
32 | background-color: $green; | ||
33 | line-height: 30px; | ||
34 | text-align: left; | ||
35 | font-weight: $font-bold; | ||
36 | |||
37 | span { | ||
38 | margin-left: 18px; | ||
39 | } | ||
40 | } | ||
41 | } | ||
42 | |||
43 | input { | ||
44 | @include peertube-button; | ||
45 | @include grey-button; | ||
46 | |||
47 | margin-left: 10px; | ||
48 | } | ||
49 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts new file mode 100644 index 000000000..e46ce6599 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts | |||
@@ -0,0 +1,306 @@ | |||
1 | import { BytesPipe } from 'ngx-pipes' | ||
2 | import { Subscription } from 'rxjs' | ||
3 | import { HttpEventType, HttpResponse } from '@angular/common/http' | ||
4 | import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' | ||
5 | import { Router } from '@angular/router' | ||
6 | import { AuthService, CanComponentDeactivate, Notifier, ServerService, UserService } from '@app/core' | ||
7 | import { scrollToTop } from '@app/helpers' | ||
8 | import { FormValidatorService } from '@app/shared/shared-forms' | ||
9 | import { VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | ||
10 | import { LoadingBarService } from '@ngx-loading-bar/core' | ||
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
12 | import { VideoPrivacy } from '@shared/models' | ||
13 | import { VideoSend } from './video-send' | ||
14 | |||
15 | @Component({ | ||
16 | selector: 'my-video-upload', | ||
17 | templateUrl: './video-upload.component.html', | ||
18 | styleUrls: [ | ||
19 | '../shared/video-edit.component.scss', | ||
20 | './video-upload.component.scss', | ||
21 | './video-send.scss' | ||
22 | ] | ||
23 | }) | ||
24 | export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate { | ||
25 | @Output() firstStepDone = new EventEmitter<string>() | ||
26 | @Output() firstStepError = new EventEmitter<void>() | ||
27 | @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement> | ||
28 | |||
29 | // So that it can be accessed in the template | ||
30 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY | ||
31 | |||
32 | userVideoQuotaUsed = 0 | ||
33 | userVideoQuotaUsedDaily = 0 | ||
34 | |||
35 | isUploadingAudioFile = false | ||
36 | isUploadingVideo = false | ||
37 | isUpdatingVideo = false | ||
38 | |||
39 | videoUploaded = false | ||
40 | videoUploadObservable: Subscription = null | ||
41 | videoUploadPercents = 0 | ||
42 | videoUploadedIds = { | ||
43 | id: 0, | ||
44 | uuid: '' | ||
45 | } | ||
46 | |||
47 | waitTranscodingEnabled = true | ||
48 | previewfileUpload: File | ||
49 | |||
50 | error: string | ||
51 | |||
52 | protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC | ||
53 | |||
54 | constructor ( | ||
55 | protected formValidatorService: FormValidatorService, | ||
56 | protected loadingBar: LoadingBarService, | ||
57 | protected notifier: Notifier, | ||
58 | protected authService: AuthService, | ||
59 | protected serverService: ServerService, | ||
60 | protected videoService: VideoService, | ||
61 | protected videoCaptionService: VideoCaptionService, | ||
62 | private userService: UserService, | ||
63 | private router: Router, | ||
64 | private i18n: I18n | ||
65 | ) { | ||
66 | super() | ||
67 | } | ||
68 | |||
69 | get videoExtensions () { | ||
70 | return this.serverConfig.video.file.extensions.join(', ') | ||
71 | } | ||
72 | |||
73 | ngOnInit () { | ||
74 | super.ngOnInit() | ||
75 | |||
76 | this.userService.getMyVideoQuotaUsed() | ||
77 | .subscribe(data => { | ||
78 | this.userVideoQuotaUsed = data.videoQuotaUsed | ||
79 | this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily | ||
80 | }) | ||
81 | } | ||
82 | |||
83 | ngOnDestroy () { | ||
84 | if (this.videoUploadObservable) this.videoUploadObservable.unsubscribe() | ||
85 | } | ||
86 | |||
87 | canDeactivate () { | ||
88 | let text = '' | ||
89 | |||
90 | if (this.videoUploaded === true) { | ||
91 | // FIXME: cannot concatenate strings inside i18n service :/ | ||
92 | text = this.i18n('Your video was uploaded to your account and is private.') + ' ' + | ||
93 | this.i18n('But associated data (tags, description...) will be lost, are you sure you want to leave this page?') | ||
94 | } else { | ||
95 | text = this.i18n('Your video is not uploaded yet, are you sure you want to leave this page?') | ||
96 | } | ||
97 | |||
98 | return { | ||
99 | canDeactivate: !this.isUploadingVideo, | ||
100 | text | ||
101 | } | ||
102 | } | ||
103 | |||
104 | getVideoFile () { | ||
105 | return this.videofileInput.nativeElement.files[0] | ||
106 | } | ||
107 | |||
108 | setVideoFile (files: FileList) { | ||
109 | this.videofileInput.nativeElement.files = files | ||
110 | this.fileChange() | ||
111 | } | ||
112 | |||
113 | getAudioUploadLabel () { | ||
114 | const videofile = this.getVideoFile() | ||
115 | if (!videofile) return this.i18n('Upload') | ||
116 | |||
117 | return this.i18n('Upload {{videofileName}}', { videofileName: videofile.name }) | ||
118 | } | ||
119 | |||
120 | fileChange () { | ||
121 | this.uploadFirstStep() | ||
122 | } | ||
123 | |||
124 | cancelUpload () { | ||
125 | if (this.videoUploadObservable !== null) { | ||
126 | this.videoUploadObservable.unsubscribe() | ||
127 | |||
128 | this.isUploadingVideo = false | ||
129 | this.videoUploadPercents = 0 | ||
130 | this.videoUploadObservable = null | ||
131 | |||
132 | this.firstStepError.emit() | ||
133 | |||
134 | this.notifier.info(this.i18n('Upload cancelled')) | ||
135 | } | ||
136 | } | ||
137 | |||
138 | uploadFirstStep (clickedOnButton = false) { | ||
139 | const videofile = this.getVideoFile() | ||
140 | if (!videofile) return | ||
141 | |||
142 | if (!this.checkGlobalUserQuota(videofile)) return | ||
143 | if (!this.checkDailyUserQuota(videofile)) return | ||
144 | |||
145 | if (clickedOnButton === false && this.isAudioFile(videofile.name)) { | ||
146 | this.isUploadingAudioFile = true | ||
147 | return | ||
148 | } | ||
149 | |||
150 | // Build name field | ||
151 | const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '') | ||
152 | let name: string | ||
153 | |||
154 | // If the name of the file is very small, keep the extension | ||
155 | if (nameWithoutExtension.length < 3) name = videofile.name | ||
156 | else name = nameWithoutExtension | ||
157 | |||
158 | // Force user to wait transcoding for unsupported video types in web browsers | ||
159 | if (!videofile.name.endsWith('.mp4') && !videofile.name.endsWith('.webm') && !videofile.name.endsWith('.ogv')) { | ||
160 | this.waitTranscodingEnabled = false | ||
161 | } | ||
162 | |||
163 | const privacy = this.firstStepPrivacyId.toString() | ||
164 | const nsfw = this.serverConfig.instance.isNSFW | ||
165 | const waitTranscoding = true | ||
166 | const commentsEnabled = true | ||
167 | const downloadEnabled = true | ||
168 | const channelId = this.firstStepChannelId.toString() | ||
169 | |||
170 | const formData = new FormData() | ||
171 | formData.append('name', name) | ||
172 | // Put the video "private" -> we are waiting the user validation of the second step | ||
173 | formData.append('privacy', VideoPrivacy.PRIVATE.toString()) | ||
174 | formData.append('nsfw', '' + nsfw) | ||
175 | formData.append('commentsEnabled', '' + commentsEnabled) | ||
176 | formData.append('downloadEnabled', '' + downloadEnabled) | ||
177 | formData.append('waitTranscoding', '' + waitTranscoding) | ||
178 | formData.append('channelId', '' + channelId) | ||
179 | formData.append('videofile', videofile) | ||
180 | |||
181 | if (this.previewfileUpload) { | ||
182 | formData.append('previewfile', this.previewfileUpload) | ||
183 | formData.append('thumbnailfile', this.previewfileUpload) | ||
184 | } | ||
185 | |||
186 | this.isUploadingVideo = true | ||
187 | this.firstStepDone.emit(name) | ||
188 | |||
189 | this.form.patchValue({ | ||
190 | name, | ||
191 | privacy, | ||
192 | nsfw, | ||
193 | channelId, | ||
194 | previewfile: this.previewfileUpload | ||
195 | }) | ||
196 | |||
197 | this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe( | ||
198 | event => { | ||
199 | if (event.type === HttpEventType.UploadProgress) { | ||
200 | this.videoUploadPercents = Math.round(100 * event.loaded / event.total) | ||
201 | } else if (event instanceof HttpResponse) { | ||
202 | this.videoUploaded = true | ||
203 | |||
204 | this.videoUploadedIds = event.body.video | ||
205 | |||
206 | this.videoUploadObservable = null | ||
207 | } | ||
208 | }, | ||
209 | |||
210 | err => { | ||
211 | // Reset progress | ||
212 | this.isUploadingVideo = false | ||
213 | this.videoUploadPercents = 0 | ||
214 | this.videoUploadObservable = null | ||
215 | this.firstStepError.emit() | ||
216 | this.notifier.error(err.message) | ||
217 | } | ||
218 | ) | ||
219 | } | ||
220 | |||
221 | isPublishingButtonDisabled () { | ||
222 | return !this.form.valid || | ||
223 | this.isUpdatingVideo === true || | ||
224 | this.videoUploaded !== true | ||
225 | } | ||
226 | |||
227 | updateSecondStep () { | ||
228 | if (this.checkForm() === false) { | ||
229 | return | ||
230 | } | ||
231 | |||
232 | const video = new VideoEdit() | ||
233 | video.patch(this.form.value) | ||
234 | video.id = this.videoUploadedIds.id | ||
235 | video.uuid = this.videoUploadedIds.uuid | ||
236 | |||
237 | this.isUpdatingVideo = true | ||
238 | |||
239 | this.updateVideoAndCaptions(video) | ||
240 | .subscribe( | ||
241 | () => { | ||
242 | this.isUpdatingVideo = false | ||
243 | this.isUploadingVideo = false | ||
244 | |||
245 | this.notifier.success(this.i18n('Video published.')) | ||
246 | this.router.navigate([ '/videos/watch', video.uuid ]) | ||
247 | }, | ||
248 | |||
249 | err => { | ||
250 | this.error = err.message | ||
251 | scrollToTop() | ||
252 | console.error(err) | ||
253 | } | ||
254 | ) | ||
255 | } | ||
256 | |||
257 | private checkGlobalUserQuota (videofile: File) { | ||
258 | const bytePipes = new BytesPipe() | ||
259 | |||
260 | // Check global user quota | ||
261 | const videoQuota = this.authService.getUser().videoQuota | ||
262 | if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { | ||
263 | const msg = this.i18n( | ||
264 | 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})', | ||
265 | { | ||
266 | videoSize: bytePipes.transform(videofile.size, 0), | ||
267 | videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0), | ||
268 | videoQuota: bytePipes.transform(videoQuota, 0) | ||
269 | } | ||
270 | ) | ||
271 | this.notifier.error(msg) | ||
272 | |||
273 | return false | ||
274 | } | ||
275 | |||
276 | return true | ||
277 | } | ||
278 | |||
279 | private checkDailyUserQuota (videofile: File) { | ||
280 | const bytePipes = new BytesPipe() | ||
281 | |||
282 | // Check daily user quota | ||
283 | const videoQuotaDaily = this.authService.getUser().videoQuotaDaily | ||
284 | if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) { | ||
285 | const msg = this.i18n( | ||
286 | 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})', | ||
287 | { | ||
288 | videoSize: bytePipes.transform(videofile.size, 0), | ||
289 | quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0), | ||
290 | quotaDaily: bytePipes.transform(videoQuotaDaily, 0) | ||
291 | } | ||
292 | ) | ||
293 | this.notifier.error(msg) | ||
294 | |||
295 | return false | ||
296 | } | ||
297 | |||
298 | return true | ||
299 | } | ||
300 | |||
301 | private isAudioFile (filename: string) { | ||
302 | const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ] | ||
303 | |||
304 | return extensions.some(e => filename.endsWith(e)) | ||
305 | } | ||
306 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-routing.module.ts b/client/src/app/+videos/+video-edit/video-add-routing.module.ts new file mode 100644 index 000000000..9ff66bea0 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-routing.module.ts | |||
@@ -0,0 +1,20 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { CanDeactivateGuard, LoginGuard } from '@app/core' | ||
4 | import { MetaGuard } from '@ngx-meta/core' | ||
5 | import { VideoAddComponent } from './video-add.component' | ||
6 | |||
7 | const videoAddRoutes: Routes = [ | ||
8 | { | ||
9 | path: '', | ||
10 | component: VideoAddComponent, | ||
11 | canActivate: [ MetaGuard, LoginGuard ], | ||
12 | canDeactivate: [ CanDeactivateGuard ] | ||
13 | } | ||
14 | ] | ||
15 | |||
16 | @NgModule({ | ||
17 | imports: [ RouterModule.forChild(videoAddRoutes) ], | ||
18 | exports: [ RouterModule ] | ||
19 | }) | ||
20 | export class VideoAddRoutingModule {} | ||
diff --git a/client/src/app/+videos/+video-edit/video-add.component.html b/client/src/app/+videos/+video-edit/video-add.component.html new file mode 100644 index 000000000..79bfc6e5c --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add.component.html | |||
@@ -0,0 +1,46 @@ | |||
1 | <div class="margin-content"> | ||
2 | <div class="alert alert-warning" *ngIf="isRootUser()" i18n> | ||
3 | We recommend you to not use the <strong>root</strong> user to publish your videos, since it's the super-admin account of your instance. | ||
4 | <br /> | ||
5 | Instead, <a routerLink="/admin/users">create a dedicated account</a> to upload your videos. | ||
6 | </div> | ||
7 | |||
8 | <div class="title-page title-page-single" *ngIf="isInSecondStep()"> | ||
9 | <ng-container *ngIf="secondStepType === 'import-url' || secondStepType === 'import-torrent'" i18n>Import {{ videoName }}</ng-container> | ||
10 | <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container> | ||
11 | </div> | ||
12 | |||
13 | <div ngbNav #nav="ngbNav" class="nav-tabs video-add-nav" [ngClass]="{ 'hide-nav': secondStepType !== undefined }"> | ||
14 | <ng-container ngbNavItem> | ||
15 | <a ngbNavLink> | ||
16 | <span i18n>Upload a file</span> | ||
17 | </a> | ||
18 | |||
19 | <ng-template ngbNavContent> | ||
20 | <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)" (firstStepError)="onError()"></my-video-upload> | ||
21 | </ng-template> | ||
22 | </ng-container> | ||
23 | |||
24 | <ng-container ngbNavItem *ngIf="isVideoImportHttpEnabled()"> | ||
25 | <a ngbNavLink> | ||
26 | <span i18n>Import with URL</span> | ||
27 | </a> | ||
28 | |||
29 | <ng-template ngbNavContent> | ||
30 | <my-video-import-url #videoImportUrl (firstStepDone)="onFirstStepDone('import-url', $event)" (firstStepError)="onError()"></my-video-import-url> | ||
31 | </ng-template> | ||
32 | </ng-container> | ||
33 | |||
34 | <ng-container ngbNavItem *ngIf="isVideoImportTorrentEnabled()"> | ||
35 | <a ngbNavLink> | ||
36 | <span i18n>Import with torrent</span> | ||
37 | </a> | ||
38 | |||
39 | <ng-template ngbNavContent> | ||
40 | <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent> | ||
41 | </ng-template> | ||
42 | </ng-container> | ||
43 | </div> | ||
44 | |||
45 | <div [ngbNavOutlet]="nav"></div> | ||
46 | </div> | ||
diff --git a/client/src/app/+videos/+video-edit/video-add.component.scss b/client/src/app/+videos/+video-edit/video-add.component.scss new file mode 100644 index 000000000..0ad57d897 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add.component.scss | |||
@@ -0,0 +1,89 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | $border-width: 3px; | ||
5 | $border-type: solid; | ||
6 | $border-color: #EAEAEA; | ||
7 | $nav-link-height: 40px; | ||
8 | |||
9 | .margin-content { | ||
10 | padding-top: 50px; | ||
11 | } | ||
12 | |||
13 | .alert { | ||
14 | font-size: 15px; | ||
15 | } | ||
16 | |||
17 | ::ng-deep .video-add-nav { | ||
18 | border-bottom: $border-width $border-type $border-color; | ||
19 | margin: 50px 0 0 0 !important; | ||
20 | |||
21 | &.hide-nav { | ||
22 | display: none !important; | ||
23 | } | ||
24 | |||
25 | a.nav-link { | ||
26 | @include disable-default-a-behaviour; | ||
27 | |||
28 | margin-bottom: -$border-width; | ||
29 | height: $nav-link-height !important; | ||
30 | padding: 0 30px !important; | ||
31 | font-size: 15px; | ||
32 | |||
33 | &.active { | ||
34 | border: $border-width $border-type $border-color; | ||
35 | border-bottom: none; | ||
36 | background-color: pvar(--submenuColor) !important; | ||
37 | |||
38 | span { | ||
39 | border-bottom: 2px solid pvar(--mainColor); | ||
40 | font-weight: $font-bold; | ||
41 | } | ||
42 | } | ||
43 | } | ||
44 | } | ||
45 | |||
46 | ::ng-deep .upload-video-container { | ||
47 | border: $border-width $border-type $border-color; | ||
48 | border-top: transparent; | ||
49 | |||
50 | background-color: pvar(--submenuColor); | ||
51 | border-bottom-left-radius: 3px; | ||
52 | border-bottom-right-radius: 3px; | ||
53 | width: 100%; | ||
54 | min-height: 440px; | ||
55 | padding-bottom: 20px; | ||
56 | display: flex; | ||
57 | justify-content: center; | ||
58 | align-items: center; | ||
59 | |||
60 | &.dragover { | ||
61 | border: 3px dashed pvar(--mainColor); | ||
62 | } | ||
63 | } | ||
64 | |||
65 | @mixin nav-scroll { | ||
66 | ::ng-deep .video-add-nav { | ||
67 | height: #{$nav-link-height + $border-width * 2}; | ||
68 | overflow-x: auto; | ||
69 | white-space: nowrap; | ||
70 | flex-wrap: unset; | ||
71 | |||
72 | /* Hide active tab style to not have a moving tab effect */ | ||
73 | a.nav-link.active { | ||
74 | border: none; | ||
75 | background-color: pvar(--mainBackgroundColor) !important; | ||
76 | } | ||
77 | } | ||
78 | } | ||
79 | |||
80 | /* Make .video-add-nav tabs scrollable on small devices */ | ||
81 | @media screen and (max-width: $small-view) { | ||
82 | @include nav-scroll(); | ||
83 | } | ||
84 | |||
85 | @media screen and (max-width: #{$small-view + $menu-width}) { | ||
86 | :host-context(.main-col:not(.expanded)) { | ||
87 | @include nav-scroll(); | ||
88 | } | ||
89 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add.component.ts b/client/src/app/+videos/+video-edit/video-add.component.ts new file mode 100644 index 000000000..5bd768809 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add.component.ts | |||
@@ -0,0 +1,77 @@ | |||
1 | import { Component, HostListener, OnInit, ViewChild } from '@angular/core' | ||
2 | import { AuthService, CanComponentDeactivate, ServerService } from '@app/core' | ||
3 | import { ServerConfig } from '@shared/models' | ||
4 | import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' | ||
5 | import { VideoImportUrlComponent } from './video-add-components/video-import-url.component' | ||
6 | import { VideoUploadComponent } from './video-add-components/video-upload.component' | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-videos-add', | ||
10 | templateUrl: './video-add.component.html', | ||
11 | styleUrls: [ './video-add.component.scss' ] | ||
12 | }) | ||
13 | export class VideoAddComponent implements OnInit, CanComponentDeactivate { | ||
14 | @ViewChild('videoUpload') videoUpload: VideoUploadComponent | ||
15 | @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent | ||
16 | @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent | ||
17 | |||
18 | secondStepType: 'upload' | 'import-url' | 'import-torrent' | ||
19 | videoName: string | ||
20 | serverConfig: ServerConfig | ||
21 | |||
22 | constructor ( | ||
23 | private auth: AuthService, | ||
24 | private serverService: ServerService | ||
25 | ) {} | ||
26 | |||
27 | ngOnInit () { | ||
28 | this.serverConfig = this.serverService.getTmpConfig() | ||
29 | |||
30 | this.serverService.getConfig() | ||
31 | .subscribe(config => this.serverConfig = config) | ||
32 | } | ||
33 | |||
34 | onFirstStepDone (type: 'upload' | 'import-url' | 'import-torrent', videoName: string) { | ||
35 | this.secondStepType = type | ||
36 | this.videoName = videoName | ||
37 | } | ||
38 | |||
39 | onError () { | ||
40 | this.videoName = undefined | ||
41 | this.secondStepType = undefined | ||
42 | } | ||
43 | |||
44 | @HostListener('window:beforeunload', [ '$event' ]) | ||
45 | onUnload (event: any) { | ||
46 | const { text, canDeactivate } = this.canDeactivate() | ||
47 | |||
48 | if (canDeactivate) return | ||
49 | |||
50 | event.returnValue = text | ||
51 | return text | ||
52 | } | ||
53 | |||
54 | canDeactivate (): { canDeactivate: boolean, text?: string} { | ||
55 | if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate() | ||
56 | if (this.secondStepType === 'import-url') return this.videoImportUrl.canDeactivate() | ||
57 | if (this.secondStepType === 'import-torrent') return this.videoImportTorrent.canDeactivate() | ||
58 | |||
59 | return { canDeactivate: true } | ||
60 | } | ||
61 | |||
62 | isVideoImportHttpEnabled () { | ||
63 | return this.serverConfig.import.videos.http.enabled | ||
64 | } | ||
65 | |||
66 | isVideoImportTorrentEnabled () { | ||
67 | return this.serverConfig.import.videos.torrent.enabled | ||
68 | } | ||
69 | |||
70 | isInSecondStep () { | ||
71 | return !!this.secondStepType | ||
72 | } | ||
73 | |||
74 | isRootUser () { | ||
75 | return this.auth.getUser().username === 'root' | ||
76 | } | ||
77 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add.module.ts b/client/src/app/+videos/+video-edit/video-add.module.ts new file mode 100644 index 000000000..477c1cf5e --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add.module.ts | |||
@@ -0,0 +1,32 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { CanDeactivateGuard } from '@app/core' | ||
3 | import { VideoEditModule } from './shared/video-edit.module' | ||
4 | import { DragDropDirective } from './video-add-components/drag-drop.directive' | ||
5 | import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' | ||
6 | import { VideoImportUrlComponent } from './video-add-components/video-import-url.component' | ||
7 | import { VideoUploadComponent } from './video-add-components/video-upload.component' | ||
8 | import { VideoAddRoutingModule } from './video-add-routing.module' | ||
9 | import { VideoAddComponent } from './video-add.component' | ||
10 | |||
11 | @NgModule({ | ||
12 | imports: [ | ||
13 | VideoAddRoutingModule, | ||
14 | |||
15 | VideoEditModule | ||
16 | ], | ||
17 | |||
18 | declarations: [ | ||
19 | VideoAddComponent, | ||
20 | VideoUploadComponent, | ||
21 | VideoImportUrlComponent, | ||
22 | VideoImportTorrentComponent, | ||
23 | DragDropDirective | ||
24 | ], | ||
25 | |||
26 | exports: [ ], | ||
27 | |||
28 | providers: [ | ||
29 | CanDeactivateGuard | ||
30 | ] | ||
31 | }) | ||
32 | export class VideoAddModule { } | ||
diff --git a/client/src/app/+videos/+video-edit/video-update-routing.module.ts b/client/src/app/+videos/+video-edit/video-update-routing.module.ts new file mode 100644 index 000000000..a04351b05 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update-routing.module.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { CanDeactivateGuard, LoginGuard } from '@app/core' | ||
4 | import { MetaGuard } from '@ngx-meta/core' | ||
5 | import { VideoUpdateComponent } from './video-update.component' | ||
6 | import { VideoUpdateResolver } from './video-update.resolver' | ||
7 | |||
8 | const videoUpdateRoutes: Routes = [ | ||
9 | { | ||
10 | path: '', | ||
11 | component: VideoUpdateComponent, | ||
12 | canActivate: [ MetaGuard, LoginGuard ], | ||
13 | canDeactivate: [ CanDeactivateGuard ], | ||
14 | resolve: { | ||
15 | videoData: VideoUpdateResolver | ||
16 | } | ||
17 | } | ||
18 | ] | ||
19 | |||
20 | @NgModule({ | ||
21 | imports: [ RouterModule.forChild(videoUpdateRoutes) ], | ||
22 | exports: [ RouterModule ] | ||
23 | }) | ||
24 | export class VideoUpdateRoutingModule {} | ||
diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html new file mode 100644 index 000000000..fbc642db9 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update.component.html | |||
@@ -0,0 +1,22 @@ | |||
1 | <div class="margin-content"> | ||
2 | <div class="title-page title-page-single"> | ||
3 | <span class="mr-1" i18n>Update</span> | ||
4 | <a [routerLink]="[ '/videos/watch', video.uuid ]">{{ video?.name }}</a> | ||
5 | </div> | ||
6 | |||
7 | <form novalidate [formGroup]="form"> | ||
8 | |||
9 | <my-video-edit | ||
10 | [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible" | ||
11 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" | ||
12 | [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled" | ||
13 | ></my-video-edit> | ||
14 | |||
15 | <div class="submit-container"> | ||
16 | <div class="submit-button" (click)="update()" [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }"> | ||
17 | <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon> | ||
18 | <input type="button" i18n-value value="Update" /> | ||
19 | </div> | ||
20 | </div> | ||
21 | </form> | ||
22 | </div> | ||
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts new file mode 100644 index 000000000..7bd6eb553 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update.component.ts | |||
@@ -0,0 +1,155 @@ | |||
1 | import { map, switchMap } from 'rxjs/operators' | ||
2 | import { Component, HostListener, OnInit } from '@angular/core' | ||
3 | import { ActivatedRoute, Router } from '@angular/router' | ||
4 | import { Notifier } from '@app/core' | ||
5 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | ||
6 | import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' | ||
7 | import { LoadingBarService } from '@ngx-loading-bar/core' | ||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
9 | import { VideoPrivacy } from '@shared/models' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-videos-update', | ||
13 | styleUrls: [ './shared/video-edit.component.scss' ], | ||
14 | templateUrl: './video-update.component.html' | ||
15 | }) | ||
16 | export class VideoUpdateComponent extends FormReactive implements OnInit { | ||
17 | video: VideoEdit | ||
18 | |||
19 | isUpdatingVideo = false | ||
20 | userVideoChannels: { id: number, label: string, support: string }[] = [] | ||
21 | schedulePublicationPossible = false | ||
22 | videoCaptions: VideoCaptionEdit[] = [] | ||
23 | waitTranscodingEnabled = true | ||
24 | |||
25 | private updateDone = false | ||
26 | |||
27 | constructor ( | ||
28 | protected formValidatorService: FormValidatorService, | ||
29 | private route: ActivatedRoute, | ||
30 | private router: Router, | ||
31 | private notifier: Notifier, | ||
32 | private videoService: VideoService, | ||
33 | private loadingBar: LoadingBarService, | ||
34 | private videoCaptionService: VideoCaptionService, | ||
35 | private i18n: I18n | ||
36 | ) { | ||
37 | super() | ||
38 | } | ||
39 | |||
40 | ngOnInit () { | ||
41 | this.buildForm({}) | ||
42 | |||
43 | this.route.data | ||
44 | .pipe(map(data => data.videoData)) | ||
45 | .subscribe(({ video, videoChannels, videoCaptions }) => { | ||
46 | this.video = new VideoEdit(video) | ||
47 | this.userVideoChannels = videoChannels | ||
48 | this.videoCaptions = videoCaptions | ||
49 | |||
50 | this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE | ||
51 | |||
52 | const videoFiles = (video as VideoDetails).getFiles() | ||
53 | if (videoFiles.length > 1) { // Already transcoded | ||
54 | this.waitTranscodingEnabled = false | ||
55 | } | ||
56 | |||
57 | // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout | ||
58 | setTimeout(() => this.hydrateFormFromVideo()) | ||
59 | }, | ||
60 | |||
61 | err => { | ||
62 | console.error(err) | ||
63 | this.notifier.error(err.message) | ||
64 | } | ||
65 | ) | ||
66 | } | ||
67 | |||
68 | @HostListener('window:beforeunload', [ '$event' ]) | ||
69 | onUnload (event: any) { | ||
70 | const { text, canDeactivate } = this.canDeactivate() | ||
71 | |||
72 | if (canDeactivate) return | ||
73 | |||
74 | event.returnValue = text | ||
75 | return text | ||
76 | } | ||
77 | |||
78 | canDeactivate (): { canDeactivate: boolean, text?: string } { | ||
79 | if (this.updateDone === true) return { canDeactivate: true } | ||
80 | |||
81 | const text = this.i18n('You have unsaved changes! If you leave, your changes will be lost.') | ||
82 | |||
83 | for (const caption of this.videoCaptions) { | ||
84 | if (caption.action) return { canDeactivate: false, text } | ||
85 | } | ||
86 | |||
87 | return { canDeactivate: this.formChanged === false, text } | ||
88 | } | ||
89 | |||
90 | checkForm () { | ||
91 | this.forceCheck() | ||
92 | |||
93 | return this.form.valid | ||
94 | } | ||
95 | |||
96 | update () { | ||
97 | if (this.checkForm() === false | ||
98 | || this.isUpdatingVideo === true) { | ||
99 | return | ||
100 | } | ||
101 | |||
102 | this.video.patch(this.form.value) | ||
103 | |||
104 | this.loadingBar.start() | ||
105 | this.isUpdatingVideo = true | ||
106 | |||
107 | // Update the video | ||
108 | this.videoService.updateVideo(this.video) | ||
109 | .pipe( | ||
110 | // Then update captions | ||
111 | switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions)) | ||
112 | ) | ||
113 | .subscribe( | ||
114 | () => { | ||
115 | this.updateDone = true | ||
116 | this.isUpdatingVideo = false | ||
117 | this.loadingBar.complete() | ||
118 | this.notifier.success(this.i18n('Video updated.')) | ||
119 | this.router.navigate([ '/videos/watch', this.video.uuid ]) | ||
120 | }, | ||
121 | |||
122 | err => { | ||
123 | this.loadingBar.complete() | ||
124 | this.isUpdatingVideo = false | ||
125 | this.notifier.error(err.message) | ||
126 | console.error(err) | ||
127 | } | ||
128 | ) | ||
129 | } | ||
130 | |||
131 | private hydrateFormFromVideo () { | ||
132 | this.form.patchValue(this.video.toFormPatch()) | ||
133 | |||
134 | const objects = [ | ||
135 | { | ||
136 | url: 'thumbnailUrl', | ||
137 | name: 'thumbnailfile' | ||
138 | }, | ||
139 | { | ||
140 | url: 'previewUrl', | ||
141 | name: 'previewfile' | ||
142 | } | ||
143 | ] | ||
144 | |||
145 | for (const obj of objects) { | ||
146 | fetch(this.video[obj.url]) | ||
147 | .then(response => response.blob()) | ||
148 | .then(data => { | ||
149 | this.form.patchValue({ | ||
150 | [ obj.name ]: data | ||
151 | }) | ||
152 | }) | ||
153 | } | ||
154 | } | ||
155 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-update.module.ts b/client/src/app/+videos/+video-edit/video-update.module.ts new file mode 100644 index 000000000..99cd8bea1 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update.module.ts | |||
@@ -0,0 +1,26 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { CanDeactivateGuard } from '@app/core' | ||
3 | import { VideoEditModule } from './shared/video-edit.module' | ||
4 | import { VideoUpdateRoutingModule } from './video-update-routing.module' | ||
5 | import { VideoUpdateComponent } from './video-update.component' | ||
6 | import { VideoUpdateResolver } from './video-update.resolver' | ||
7 | |||
8 | @NgModule({ | ||
9 | imports: [ | ||
10 | VideoUpdateRoutingModule, | ||
11 | |||
12 | VideoEditModule | ||
13 | ], | ||
14 | |||
15 | declarations: [ | ||
16 | VideoUpdateComponent | ||
17 | ], | ||
18 | |||
19 | exports: [ ], | ||
20 | |||
21 | providers: [ | ||
22 | VideoUpdateResolver, | ||
23 | CanDeactivateGuard | ||
24 | ] | ||
25 | }) | ||
26 | export class VideoUpdateModule { } | ||
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts new file mode 100644 index 000000000..30bcf4d74 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts | |||
@@ -0,0 +1,44 @@ | |||
1 | import { forkJoin } from 'rxjs' | ||
2 | import { map, switchMap } from 'rxjs/operators' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' | ||
5 | import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main' | ||
6 | |||
7 | @Injectable() | ||
8 | export class VideoUpdateResolver implements Resolve<any> { | ||
9 | constructor ( | ||
10 | private videoService: VideoService, | ||
11 | private videoChannelService: VideoChannelService, | ||
12 | private videoCaptionService: VideoCaptionService | ||
13 | ) { | ||
14 | } | ||
15 | |||
16 | resolve (route: ActivatedRouteSnapshot) { | ||
17 | const uuid: string = route.params[ 'uuid' ] | ||
18 | |||
19 | return this.videoService.getVideo({ videoId: uuid }) | ||
20 | .pipe( | ||
21 | switchMap(video => { | ||
22 | return forkJoin([ | ||
23 | this.videoService | ||
24 | .loadCompleteDescription(video.descriptionPath) | ||
25 | .pipe(map(description => Object.assign(video, { description }))), | ||
26 | |||
27 | this.videoChannelService | ||
28 | .listAccountVideoChannels(video.account) | ||
29 | .pipe( | ||
30 | map(result => result.data), | ||
31 | map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))) | ||
32 | ), | ||
33 | |||
34 | this.videoCaptionService | ||
35 | .listCaptions(video.id) | ||
36 | .pipe( | ||
37 | map(result => result.data) | ||
38 | ) | ||
39 | ]) | ||
40 | }), | ||
41 | map(([ video, videoChannels, videoCaptions ]) => ({ video, videoChannels, videoCaptions })) | ||
42 | ) | ||
43 | } | ||
44 | } | ||
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 | |||
4 | form { | ||
5 | margin-bottom: 30px; | ||
6 | } | ||
7 | |||
8 | .avatar-and-textarea { | ||
9 | display: flex; | ||
10 | margin-bottom: 10px; | ||
11 | |||
12 | img { | ||
13 | @include avatar(25px); | ||
14 | |||
15 | vertical-align: top; | ||
16 | margin-right: 10px; | ||
17 | } | ||
18 | |||
19 | .form-group { | ||
20 | flex-grow: 1; | ||
21 | margin: 0; | ||
22 | |||
23 | textarea { | ||
24 | @include peertube-textarea(100%, 60px); | ||
25 | |||
26 | &:focus::placeholder { | ||
27 | opacity: 0; | ||
28 | } | ||
29 | } | ||
30 | } | ||
31 | } | ||
32 | |||
33 | .comment-buttons { | ||
34 | display: flex; | ||
35 | justify-content: flex-end; | ||
36 | |||
37 | button { | ||
38 | @include peertube-button; | ||
39 | @include disable-outline; | ||
40 | @include disable-default-a-behaviour; | ||
41 | |||
42 | &:not(:last-child) { | ||
43 | margin-right: .5rem; | ||
44 | } | ||
45 | |||
46 | &:last-child { | ||
47 | @include orange-button; | ||
48 | } | ||
49 | } | ||
50 | |||
51 | .cancel-button { | ||
52 | @include tertiary-button; | ||
53 | |||
54 | font-weight: $font-semibold; | ||
55 | display: inline-block; | ||
56 | padding: 0 10px 0 10px; | ||
57 | white-space: nowrap; | ||
58 | background: transparent; | ||
59 | } | ||
60 | } | ||
61 | |||
62 | @media screen and (max-width: 600px) { | ||
63 | textarea, .comment-buttons button { | ||
64 | font-size: 14px !important; | ||
65 | } | ||
66 | |||
67 | textarea { | ||
68 | padding: 5px !important; | ||
69 | } | ||
70 | } | ||
71 | |||
72 | .modal-body { | ||
73 | .btn { | ||
74 | @include peertube-button; | ||
75 | @include orange-button; | ||
76 | } | ||
77 | |||
78 | span { | ||
79 | float: left; | ||
80 | margin-bottom: 20px; | ||
81 | } | ||
82 | } | ||
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts 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 @@ | |||
1 | import { Observable } from 'rxjs' | ||
2 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | ||
3 | import { Router } from '@angular/router' | ||
4 | import { Notifier, User } from '@app/core' | ||
5 | import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms' | ||
6 | import { Video } from '@app/shared/shared-main' | ||
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
8 | import { VideoCommentCreate } from '@shared/models' | ||
9 | import { VideoComment } from './video-comment.model' | ||
10 | import { VideoCommentService } from './video-comment.service' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-comment-add', | ||
14 | templateUrl: './video-comment-add.component.html', | ||
15 | styleUrls: ['./video-comment-add.component.scss'] | ||
16 | }) | ||
17 | export class VideoCommentAddComponent extends FormReactive implements OnInit { | ||
18 | @Input() user: User | ||
19 | @Input() video: Video | ||
20 | @Input() parentComment: VideoComment | ||
21 | @Input() parentComments: VideoComment[] | ||
22 | @Input() focusOnInit = false | ||
23 | |||
24 | @Output() commentCreated = new EventEmitter<VideoComment>() | ||
25 | @Output() cancel = new EventEmitter() | ||
26 | |||
27 | @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal | ||
28 | @ViewChild('textarea', { static: true }) textareaElement: ElementRef | ||
29 | |||
30 | addingComment = false | ||
31 | |||
32 | constructor ( | ||
33 | protected formValidatorService: FormValidatorService, | ||
34 | private videoCommentValidatorsService: VideoCommentValidatorsService, | ||
35 | private notifier: Notifier, | ||
36 | private videoCommentService: VideoCommentService, | ||
37 | private modalService: NgbModal, | ||
38 | private router: Router | ||
39 | ) { | ||
40 | super() | ||
41 | } | ||
42 | |||
43 | ngOnInit () { | ||
44 | this.buildForm({ | ||
45 | text: this.videoCommentValidatorsService.VIDEO_COMMENT_TEXT | ||
46 | }) | ||
47 | |||
48 | if (this.user) { | ||
49 | if (this.focusOnInit === true) { | ||
50 | this.textareaElement.nativeElement.focus() | ||
51 | } | ||
52 | |||
53 | if (this.parentComment) { | ||
54 | const mentions = this.parentComments | ||
55 | .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves | ||
56 | .map(c => '@' + c.by) | ||
57 | |||
58 | const mentionsSet = new Set(mentions) | ||
59 | const mentionsText = Array.from(mentionsSet).join(' ') + ' ' | ||
60 | |||
61 | this.form.patchValue({ text: mentionsText }) | ||
62 | } | ||
63 | } | ||
64 | } | ||
65 | |||
66 | onValidKey () { | ||
67 | this.check() | ||
68 | if (!this.form.valid) return | ||
69 | |||
70 | this.formValidated() | ||
71 | } | ||
72 | |||
73 | openVisitorModal (event: any) { | ||
74 | if (this.user === null) { // we only open it for visitors | ||
75 | // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error | ||
76 | event.srcElement.blur() | ||
77 | event.preventDefault() | ||
78 | |||
79 | this.modalService.open(this.visitorModal) | ||
80 | } | ||
81 | } | ||
82 | |||
83 | hideVisitorModal () { | ||
84 | this.modalService.dismissAll() | ||
85 | } | ||
86 | |||
87 | formValidated () { | ||
88 | // If we validate very quickly the comment form, we might comment twice | ||
89 | if (this.addingComment) return | ||
90 | |||
91 | this.addingComment = true | ||
92 | |||
93 | const commentCreate: VideoCommentCreate = this.form.value | ||
94 | let obs: Observable<VideoComment> | ||
95 | |||
96 | if (this.parentComment) { | ||
97 | obs = this.addCommentReply(commentCreate) | ||
98 | } else { | ||
99 | obs = this.addCommentThread(commentCreate) | ||
100 | } | ||
101 | |||
102 | obs.subscribe( | ||
103 | comment => { | ||
104 | this.addingComment = false | ||
105 | this.commentCreated.emit(comment) | ||
106 | this.form.reset() | ||
107 | }, | ||
108 | |||
109 | err => { | ||
110 | this.addingComment = false | ||
111 | |||
112 | this.notifier.error(err.text) | ||
113 | } | ||
114 | ) | ||
115 | } | ||
116 | |||
117 | isAddButtonDisplayed () { | ||
118 | return this.form.value['text'] | ||
119 | } | ||
120 | |||
121 | getUri () { | ||
122 | return window.location.href | ||
123 | } | ||
124 | |||
125 | getAvatarUrl () { | ||
126 | if (this.user) return this.user.accountAvatarUrl | ||
127 | return window.location.origin + '/client/assets/images/default-avatar.png' | ||
128 | } | ||
129 | |||
130 | gotoLogin () { | ||
131 | this.hideVisitorModal() | ||
132 | this.router.navigate([ '/login' ]) | ||
133 | } | ||
134 | |||
135 | cancelCommentReply () { | ||
136 | this.cancel.emit(null) | ||
137 | this.form.value['text'] = this.textareaElement.nativeElement.value = '' | ||
138 | } | ||
139 | |||
140 | private addCommentReply (commentCreate: VideoCommentCreate) { | ||
141 | return this.videoCommentService | ||
142 | .addCommentReply(this.video.id, this.parentComment.id, commentCreate) | ||
143 | } | ||
144 | |||
145 | private addCommentThread (commentCreate: VideoCommentCreate) { | ||
146 | return this.videoCommentService | ||
147 | .addCommentThread(this.video.id, commentCreate) | ||
148 | } | ||
149 | } | ||
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts 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 @@ | |||
1 | import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models' | ||
2 | import { VideoComment } from './video-comment.model' | ||
3 | |||
4 | export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { | ||
5 | comment: VideoComment | ||
6 | children: VideoCommentThreadTree[] | ||
7 | } | ||
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/comment/video-comment.component.html 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 @@ | |||
1 | import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' | ||
2 | import { MarkdownService, Notifier, UserService } from '@app/core' | ||
3 | import { AuthService } from '@app/core/auth' | ||
4 | import { Account, Actor, Video } from '@app/shared/shared-main' | ||
5 | import { User, UserRight } from '@shared/models' | ||
6 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | ||
7 | import { VideoComment } from './video-comment.model' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-video-comment', | ||
11 | templateUrl: './video-comment.component.html', | ||
12 | styleUrls: ['./video-comment.component.scss'] | ||
13 | }) | ||
14 | export class VideoCommentComponent implements OnInit, OnChanges { | ||
15 | @Input() video: Video | ||
16 | @Input() comment: VideoComment | ||
17 | @Input() parentComments: VideoComment[] = [] | ||
18 | @Input() commentTree: VideoCommentThreadTree | ||
19 | @Input() inReplyToCommentId: number | ||
20 | @Input() highlightedComment = false | ||
21 | @Input() firstInThread = false | ||
22 | |||
23 | @Output() wantedToDelete = new EventEmitter<VideoComment>() | ||
24 | @Output() wantedToReply = new EventEmitter<VideoComment>() | ||
25 | @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>() | ||
26 | @Output() resetReply = new EventEmitter() | ||
27 | @Output() timestampClicked = new EventEmitter<number>() | ||
28 | |||
29 | sanitizedCommentHTML = '' | ||
30 | newParentComments: VideoComment[] = [] | ||
31 | |||
32 | commentAccount: Account | ||
33 | commentUser: User | ||
34 | |||
35 | constructor ( | ||
36 | private markdownService: MarkdownService, | ||
37 | private authService: AuthService, | ||
38 | private userService: UserService, | ||
39 | private notifier: Notifier | ||
40 | ) {} | ||
41 | |||
42 | get user () { | ||
43 | return this.authService.getUser() | ||
44 | } | ||
45 | |||
46 | ngOnInit () { | ||
47 | this.init() | ||
48 | } | ||
49 | |||
50 | ngOnChanges () { | ||
51 | this.init() | ||
52 | } | ||
53 | |||
54 | onCommentReplyCreated (createdComment: VideoComment) { | ||
55 | if (!this.commentTree) { | ||
56 | this.commentTree = { | ||
57 | comment: this.comment, | ||
58 | children: [] | ||
59 | } | ||
60 | |||
61 | this.threadCreated.emit(this.commentTree) | ||
62 | } | ||
63 | |||
64 | this.commentTree.children.unshift({ | ||
65 | comment: createdComment, | ||
66 | children: [] | ||
67 | }) | ||
68 | this.resetReply.emit() | ||
69 | } | ||
70 | |||
71 | onWantToReply (comment?: VideoComment) { | ||
72 | this.wantedToReply.emit(comment || this.comment) | ||
73 | } | ||
74 | |||
75 | onWantToDelete (comment?: VideoComment) { | ||
76 | this.wantedToDelete.emit(comment || this.comment) | ||
77 | } | ||
78 | |||
79 | isUserLoggedIn () { | ||
80 | return this.authService.isLoggedIn() | ||
81 | } | ||
82 | |||
83 | onResetReply () { | ||
84 | this.resetReply.emit() | ||
85 | } | ||
86 | |||
87 | handleTimestampClicked (timestamp: number) { | ||
88 | this.timestampClicked.emit(timestamp) | ||
89 | } | ||
90 | |||
91 | isRemovableByUser () { | ||
92 | return this.comment.account && this.isUserLoggedIn() && | ||
93 | ( | ||
94 | this.user.account.id === this.comment.account.id || | ||
95 | this.user.account.id === this.video.account.id || | ||
96 | this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) | ||
97 | ) | ||
98 | } | ||
99 | |||
100 | switchToDefaultAvatar ($event: Event) { | ||
101 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() | ||
102 | } | ||
103 | |||
104 | private getUserIfNeeded (account: Account) { | ||
105 | if (!account.userId) return | ||
106 | if (!this.authService.isLoggedIn()) return | ||
107 | |||
108 | const user = this.authService.getUser() | ||
109 | if (user.hasRight(UserRight.MANAGE_USERS)) { | ||
110 | this.userService.getUserWithCache(account.userId) | ||
111 | .subscribe( | ||
112 | user => this.commentUser = user, | ||
113 | |||
114 | err => this.notifier.error(err.message) | ||
115 | ) | ||
116 | } | ||
117 | } | ||
118 | |||
119 | private async init () { | ||
120 | const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) | ||
121 | this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) | ||
122 | this.newParentComments = this.parentComments.concat([ this.comment ]) | ||
123 | |||
124 | if (this.comment.account) { | ||
125 | this.commentAccount = new Account(this.comment.account) | ||
126 | this.getUserIfNeeded(this.commentAccount) | ||
127 | } else { | ||
128 | this.comment.account = null | ||
129 | } | ||
130 | } | ||
131 | } | ||
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts 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 @@ | |||
1 | import { getAbsoluteAPIUrl } from '@app/helpers' | ||
2 | import { Actor } from '@app/shared/shared-main' | ||
3 | import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models' | ||
4 | |||
5 | export class VideoComment implements VideoCommentServerModel { | ||
6 | id: number | ||
7 | url: string | ||
8 | text: string | ||
9 | threadId: number | ||
10 | inReplyToCommentId: number | ||
11 | videoId: number | ||
12 | createdAt: Date | string | ||
13 | updatedAt: Date | string | ||
14 | deletedAt: Date | string | ||
15 | isDeleted: boolean | ||
16 | account: AccountInterface | ||
17 | totalRepliesFromVideoAuthor: number | ||
18 | totalReplies: number | ||
19 | by: string | ||
20 | accountAvatarUrl: string | ||
21 | |||
22 | isLocal: boolean | ||
23 | |||
24 | constructor (hash: VideoCommentServerModel) { | ||
25 | this.id = hash.id | ||
26 | this.url = hash.url | ||
27 | this.text = hash.text | ||
28 | this.threadId = hash.threadId | ||
29 | this.inReplyToCommentId = hash.inReplyToCommentId | ||
30 | this.videoId = hash.videoId | ||
31 | this.createdAt = new Date(hash.createdAt.toString()) | ||
32 | this.updatedAt = new Date(hash.updatedAt.toString()) | ||
33 | this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null | ||
34 | this.isDeleted = hash.isDeleted | ||
35 | this.account = hash.account | ||
36 | this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor | ||
37 | this.totalReplies = hash.totalReplies | ||
38 | |||
39 | if (this.account) { | ||
40 | this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) | ||
41 | this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) | ||
42 | |||
43 | const absoluteAPIUrl = getAbsoluteAPIUrl() | ||
44 | const thisHost = new URL(absoluteAPIUrl).host | ||
45 | this.isLocal = this.account.host.trim() === thisHost | ||
46 | } | ||
47 | } | ||
48 | } | ||
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts 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 @@ | |||
1 | import { Observable } from 'rxjs' | ||
2 | import { catchError, map } from 'rxjs/operators' | ||
3 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' | ||
6 | import { objectLineFeedToHtml } from '@app/helpers' | ||
7 | import { | ||
8 | FeedFormat, | ||
9 | ResultList, | ||
10 | VideoComment as VideoCommentServerModel, | ||
11 | VideoCommentCreate, | ||
12 | VideoCommentThreadTree as VideoCommentThreadTreeServerModel | ||
13 | } from '@shared/models' | ||
14 | import { environment } from '../../../../environments/environment' | ||
15 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | ||
16 | import { VideoComment } from './video-comment.model' | ||
17 | |||
18 | @Injectable() | ||
19 | export class VideoCommentService { | ||
20 | private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | ||
21 | private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.' | ||
22 | |||
23 | constructor ( | ||
24 | private authHttp: HttpClient, | ||
25 | private restExtractor: RestExtractor, | ||
26 | private restService: RestService | ||
27 | ) {} | ||
28 | |||
29 | addCommentThread (videoId: number | string, comment: VideoCommentCreate) { | ||
30 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | ||
31 | const normalizedComment = objectLineFeedToHtml(comment, 'text') | ||
32 | |||
33 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) | ||
34 | .pipe( | ||
35 | map(data => this.extractVideoComment(data.comment)), | ||
36 | catchError(err => this.restExtractor.handleError(err)) | ||
37 | ) | ||
38 | } | ||
39 | |||
40 | addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) { | ||
41 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId | ||
42 | const normalizedComment = objectLineFeedToHtml(comment, 'text') | ||
43 | |||
44 | return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) | ||
45 | .pipe( | ||
46 | map(data => this.extractVideoComment(data.comment)), | ||
47 | catchError(err => this.restExtractor.handleError(err)) | ||
48 | ) | ||
49 | } | ||
50 | |||
51 | getVideoCommentThreads (parameters: { | ||
52 | videoId: number | string, | ||
53 | componentPagination: ComponentPaginationLight, | ||
54 | sort: string | ||
55 | }): Observable<ResultList<VideoComment>> { | ||
56 | const { videoId, componentPagination, sort } = parameters | ||
57 | |||
58 | const pagination = this.restService.componentPaginationToRestPagination(componentPagination) | ||
59 | |||
60 | let params = new HttpParams() | ||
61 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
62 | |||
63 | const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' | ||
64 | return this.authHttp.get<ResultList<VideoComment>>(url, { params }) | ||
65 | .pipe( | ||
66 | map(result => this.extractVideoComments(result)), | ||
67 | catchError(err => this.restExtractor.handleError(err)) | ||
68 | ) | ||
69 | } | ||
70 | |||
71 | getVideoThreadComments (parameters: { | ||
72 | videoId: number | string, | ||
73 | threadId: number | ||
74 | }): Observable<VideoCommentThreadTree> { | ||
75 | const { videoId, threadId } = parameters | ||
76 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` | ||
77 | |||
78 | return this.authHttp | ||
79 | .get<VideoCommentThreadTreeServerModel>(url) | ||
80 | .pipe( | ||
81 | map(tree => this.extractVideoCommentTree(tree)), | ||
82 | catchError(err => this.restExtractor.handleError(err)) | ||
83 | ) | ||
84 | } | ||
85 | |||
86 | deleteVideoComment (videoId: number | string, commentId: number) { | ||
87 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` | ||
88 | |||
89 | return this.authHttp | ||
90 | .delete(url) | ||
91 | .pipe( | ||
92 | map(this.restExtractor.extractDataBool), | ||
93 | catchError(err => this.restExtractor.handleError(err)) | ||
94 | ) | ||
95 | } | ||
96 | |||
97 | getVideoCommentsFeeds (videoUUID?: string) { | ||
98 | const feeds = [ | ||
99 | { | ||
100 | format: FeedFormat.RSS, | ||
101 | label: 'rss 2.0', | ||
102 | url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() | ||
103 | }, | ||
104 | { | ||
105 | format: FeedFormat.ATOM, | ||
106 | label: 'atom 1.0', | ||
107 | url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() | ||
108 | }, | ||
109 | { | ||
110 | format: FeedFormat.JSON, | ||
111 | label: 'json 1.0', | ||
112 | url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() | ||
113 | } | ||
114 | ] | ||
115 | |||
116 | if (videoUUID !== undefined) { | ||
117 | for (const feed of feeds) { | ||
118 | feed.url += '?videoId=' + videoUUID | ||
119 | } | ||
120 | } | ||
121 | |||
122 | return feeds | ||
123 | } | ||
124 | |||
125 | private extractVideoComment (videoComment: VideoCommentServerModel) { | ||
126 | return new VideoComment(videoComment) | ||
127 | } | ||
128 | |||
129 | private extractVideoComments (result: ResultList<VideoCommentServerModel>) { | ||
130 | const videoCommentsJson = result.data | ||
131 | const totalComments = result.total | ||
132 | const comments: VideoComment[] = [] | ||
133 | |||
134 | for (const videoCommentJson of videoCommentsJson) { | ||
135 | comments.push(new VideoComment(videoCommentJson)) | ||
136 | } | ||
137 | |||
138 | return { data: comments, total: totalComments } | ||
139 | } | ||
140 | |||
141 | private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { | ||
142 | if (!tree) return tree as VideoCommentThreadTree | ||
143 | |||
144 | tree.comment = new VideoComment(tree.comment) | ||
145 | tree.children.forEach(c => this.extractVideoCommentTree(c)) | ||
146 | |||
147 | return tree as VideoCommentThreadTree | ||
148 | } | ||
149 | } | ||
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/comment/video-comments.component.html 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 @@ | |||
1 | import { Subject, Subscription } from 'rxjs' | ||
2 | import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' | ||
3 | import { ActivatedRoute } from '@angular/router' | ||
4 | import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' | ||
5 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
6 | import { Syndication, VideoDetails } from '@app/shared/shared-main' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { VideoCommentThreadTree } from './video-comment-thread-tree.model' | ||
9 | import { VideoComment } from './video-comment.model' | ||
10 | import { VideoCommentService } from './video-comment.service' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-comments', | ||
14 | templateUrl: './video-comments.component.html', | ||
15 | styleUrls: ['./video-comments.component.scss'] | ||
16 | }) | ||
17 | export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | ||
18 | @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef | ||
19 | @Input() video: VideoDetails | ||
20 | @Input() user: User | ||
21 | |||
22 | @Output() timestampClicked = new EventEmitter<number>() | ||
23 | |||
24 | comments: VideoComment[] = [] | ||
25 | highlightedThread: VideoComment | ||
26 | sort = '-createdAt' | ||
27 | componentPagination: ComponentPagination = { | ||
28 | currentPage: 1, | ||
29 | itemsPerPage: 10, | ||
30 | totalItems: null | ||
31 | } | ||
32 | inReplyToCommentId: number | ||
33 | threadComments: { [ id: number ]: VideoCommentThreadTree } = {} | ||
34 | threadLoading: { [ id: number ]: boolean } = {} | ||
35 | |||
36 | syndicationItems: Syndication[] = [] | ||
37 | |||
38 | onDataSubject = new Subject<any[]>() | ||
39 | |||
40 | private sub: Subscription | ||
41 | |||
42 | constructor ( | ||
43 | private authService: AuthService, | ||
44 | private notifier: Notifier, | ||
45 | private confirmService: ConfirmService, | ||
46 | private videoCommentService: VideoCommentService, | ||
47 | private activatedRoute: ActivatedRoute, | ||
48 | private i18n: I18n, | ||
49 | private hooks: HooksService | ||
50 | ) {} | ||
51 | |||
52 | ngOnInit () { | ||
53 | // Find highlighted comment in params | ||
54 | this.sub = this.activatedRoute.params.subscribe( | ||
55 | params => { | ||
56 | if (params['threadId']) { | ||
57 | const highlightedThreadId = +params['threadId'] | ||
58 | this.processHighlightedThread(highlightedThreadId) | ||
59 | } | ||
60 | } | ||
61 | ) | ||
62 | } | ||
63 | |||
64 | ngOnChanges (changes: SimpleChanges) { | ||
65 | if (changes['video']) { | ||
66 | this.resetVideo() | ||
67 | } | ||
68 | } | ||
69 | |||
70 | ngOnDestroy () { | ||
71 | if (this.sub) this.sub.unsubscribe() | ||
72 | } | ||
73 | |||
74 | viewReplies (commentId: number, highlightThread = false) { | ||
75 | this.threadLoading[commentId] = true | ||
76 | |||
77 | const params = { | ||
78 | videoId: this.video.id, | ||
79 | threadId: commentId | ||
80 | } | ||
81 | |||
82 | const obs = this.hooks.wrapObsFun( | ||
83 | this.videoCommentService.getVideoThreadComments.bind(this.videoCommentService), | ||
84 | params, | ||
85 | 'video-watch', | ||
86 | 'filter:api.video-watch.video-thread-replies.list.params', | ||
87 | 'filter:api.video-watch.video-thread-replies.list.result' | ||
88 | ) | ||
89 | |||
90 | obs.subscribe( | ||
91 | res => { | ||
92 | this.threadComments[commentId] = res | ||
93 | this.threadLoading[commentId] = false | ||
94 | this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res }) | ||
95 | |||
96 | if (highlightThread) { | ||
97 | this.highlightedThread = new VideoComment(res.comment) | ||
98 | |||
99 | // Scroll to the highlighted thread | ||
100 | setTimeout(() => this.commentHighlightBlock.nativeElement.scrollIntoView(), 0) | ||
101 | } | ||
102 | }, | ||
103 | |||
104 | err => this.notifier.error(err.message) | ||
105 | ) | ||
106 | } | ||
107 | |||
108 | loadMoreThreads () { | ||
109 | const params = { | ||
110 | videoId: this.video.id, | ||
111 | componentPagination: this.componentPagination, | ||
112 | sort: this.sort | ||
113 | } | ||
114 | |||
115 | const obs = this.hooks.wrapObsFun( | ||
116 | this.videoCommentService.getVideoCommentThreads.bind(this.videoCommentService), | ||
117 | params, | ||
118 | 'video-watch', | ||
119 | 'filter:api.video-watch.video-threads.list.params', | ||
120 | 'filter:api.video-watch.video-threads.list.result' | ||
121 | ) | ||
122 | |||
123 | obs.subscribe( | ||
124 | res => { | ||
125 | this.comments = this.comments.concat(res.data) | ||
126 | this.componentPagination.totalItems = res.total | ||
127 | |||
128 | this.onDataSubject.next(res.data) | ||
129 | this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination }) | ||
130 | }, | ||
131 | |||
132 | err => this.notifier.error(err.message) | ||
133 | ) | ||
134 | } | ||
135 | |||
136 | onCommentThreadCreated (comment: VideoComment) { | ||
137 | this.comments.unshift(comment) | ||
138 | } | ||
139 | |||
140 | onWantedToReply (comment: VideoComment) { | ||
141 | this.inReplyToCommentId = comment.id | ||
142 | } | ||
143 | |||
144 | onResetReply () { | ||
145 | this.inReplyToCommentId = undefined | ||
146 | } | ||
147 | |||
148 | onThreadCreated (commentTree: VideoCommentThreadTree) { | ||
149 | this.viewReplies(commentTree.comment.id) | ||
150 | } | ||
151 | |||
152 | handleSortChange (sort: string) { | ||
153 | if (this.sort === sort) return | ||
154 | |||
155 | this.sort = sort | ||
156 | this.resetVideo() | ||
157 | } | ||
158 | |||
159 | handleTimestampClicked (timestamp: number) { | ||
160 | this.timestampClicked.emit(timestamp) | ||
161 | } | ||
162 | |||
163 | async onWantedToDelete (commentToDelete: VideoComment) { | ||
164 | let message = 'Do you really want to delete this comment?' | ||
165 | |||
166 | if (commentToDelete.isLocal || this.video.isLocal) { | ||
167 | message += this.i18n(' The deletion will be sent to remote instances so they can reflect the change.') | ||
168 | } else { | ||
169 | message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.') | ||
170 | } | ||
171 | |||
172 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) | ||
173 | if (res === false) return | ||
174 | |||
175 | this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id) | ||
176 | .subscribe( | ||
177 | () => { | ||
178 | if (this.highlightedThread?.id === commentToDelete.id) { | ||
179 | commentToDelete = this.comments.find(c => c.id === commentToDelete.id) | ||
180 | |||
181 | this.highlightedThread = undefined | ||
182 | } | ||
183 | |||
184 | // Mark the comment as deleted | ||
185 | this.softDeleteComment(commentToDelete) | ||
186 | }, | ||
187 | |||
188 | err => this.notifier.error(err.message) | ||
189 | ) | ||
190 | } | ||
191 | |||
192 | isUserLoggedIn () { | ||
193 | return this.authService.isLoggedIn() | ||
194 | } | ||
195 | |||
196 | onNearOfBottom () { | ||
197 | if (hasMoreItems(this.componentPagination)) { | ||
198 | this.componentPagination.currentPage++ | ||
199 | this.loadMoreThreads() | ||
200 | } | ||
201 | } | ||
202 | |||
203 | private softDeleteComment (comment: VideoComment) { | ||
204 | comment.isDeleted = true | ||
205 | comment.deletedAt = new Date() | ||
206 | comment.text = '' | ||
207 | comment.account = null | ||
208 | } | ||
209 | |||
210 | private resetVideo () { | ||
211 | if (this.video.commentsEnabled === true) { | ||
212 | // Reset all our fields | ||
213 | this.highlightedThread = null | ||
214 | this.comments = [] | ||
215 | this.threadComments = {} | ||
216 | this.threadLoading = {} | ||
217 | this.inReplyToCommentId = undefined | ||
218 | this.componentPagination.currentPage = 1 | ||
219 | this.componentPagination.totalItems = null | ||
220 | |||
221 | this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid) | ||
222 | this.loadMoreThreads() | ||
223 | } | ||
224 | } | ||
225 | |||
226 | private processHighlightedThread (highlightedThreadId: number) { | ||
227 | this.highlightedThread = this.comments.find(c => c.id === highlightedThreadId) | ||
228 | |||
229 | const highlightThread = true | ||
230 | this.viewReplies(highlightedThreadId, highlightThread) | ||
231 | } | ||
232 | } | ||
diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.html b/client/src/app/+videos/+video-watch/modal/video-share.component.html 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 | |||
4 | my-input-readonly-copy { | ||
5 | width: 100%; | ||
6 | } | ||
7 | |||
8 | .title-page.title-page-single { | ||
9 | margin-top: 0; | ||
10 | } | ||
11 | |||
12 | .playlist { | ||
13 | margin-bottom: 50px; | ||
14 | } | ||
15 | |||
16 | .peertube-select-container { | ||
17 | @include peertube-select-container(200px); | ||
18 | } | ||
19 | |||
20 | .qr-code-group { | ||
21 | text-align: center; | ||
22 | } | ||
23 | |||
24 | .nav-content { | ||
25 | margin-top: 30px; | ||
26 | display: flex; | ||
27 | justify-content: center; | ||
28 | align-items: center; | ||
29 | flex-direction: column; | ||
30 | } | ||
31 | |||
32 | .alert { | ||
33 | margin-top: 20px; | ||
34 | } | ||
35 | |||
36 | .filters { | ||
37 | margin-top: 30px; | ||
38 | |||
39 | .advanced-filters-button { | ||
40 | display: flex; | ||
41 | justify-content: center; | ||
42 | align-items: center; | ||
43 | margin-top: 20px; | ||
44 | font-size: 16px; | ||
45 | font-weight: $font-semibold; | ||
46 | cursor: pointer; | ||
47 | |||
48 | .glyphicon { | ||
49 | margin-right: 5px; | ||
50 | } | ||
51 | } | ||
52 | |||
53 | .form-group { | ||
54 | margin-bottom: 0; | ||
55 | height: 34px; | ||
56 | display: flex; | ||
57 | align-items: center; | ||
58 | } | ||
59 | |||
60 | .video-caption-block { | ||
61 | display: flex; | ||
62 | align-items: center; | ||
63 | |||
64 | .peertube-select-container { | ||
65 | margin-left: 10px; | ||
66 | } | ||
67 | } | ||
68 | |||
69 | .start-at, | ||
70 | .stop-at { | ||
71 | width: 300px; | ||
72 | display: flex; | ||
73 | align-items: center; | ||
74 | |||
75 | my-timestamp-input { | ||
76 | margin-left: 10px; | ||
77 | } | ||
78 | } | ||
79 | } | ||
diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.ts b/client/src/app/+videos/+video-watch/modal/video-share.component.ts 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 @@ | |||
1 | import { Component, ElementRef, Input, ViewChild } from '@angular/core' | ||
2 | import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' | ||
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
4 | import { VideoCaption } from '@shared/models' | ||
5 | import { VideoDetails } from '@app/shared/shared-main' | ||
6 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' | ||
7 | |||
8 | type Customizations = { | ||
9 | startAtCheckbox: boolean | ||
10 | startAt: number | ||
11 | |||
12 | stopAtCheckbox: boolean | ||
13 | stopAt: number | ||
14 | |||
15 | subtitleCheckbox: boolean | ||
16 | subtitle: string | ||
17 | |||
18 | loop: boolean | ||
19 | autoplay: boolean | ||
20 | muted: boolean | ||
21 | title: boolean | ||
22 | warningTitle: boolean | ||
23 | controls: boolean | ||
24 | } | ||
25 | |||
26 | @Component({ | ||
27 | selector: 'my-video-share', | ||
28 | templateUrl: './video-share.component.html', | ||
29 | styleUrls: [ './video-share.component.scss' ] | ||
30 | }) | ||
31 | export class VideoShareComponent { | ||
32 | @ViewChild('modal', { static: true }) modal: ElementRef | ||
33 | |||
34 | @Input() video: VideoDetails = null | ||
35 | @Input() videoCaptions: VideoCaption[] = [] | ||
36 | @Input() playlist: VideoPlaylist = null | ||
37 | |||
38 | activeId: 'url' | 'qrcode' | 'embed' = 'url' | ||
39 | customizations: Customizations | ||
40 | isAdvancedCustomizationCollapsed = true | ||
41 | includeVideoInPlaylist = false | ||
42 | |||
43 | constructor (private modalService: NgbModal) { } | ||
44 | |||
45 | show (currentVideoTimestamp?: number) { | ||
46 | let subtitle: string | ||
47 | if (this.videoCaptions.length !== 0) { | ||
48 | subtitle = this.videoCaptions[0].language.id | ||
49 | } | ||
50 | |||
51 | this.customizations = { | ||
52 | startAtCheckbox: false, | ||
53 | startAt: currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0, | ||
54 | |||
55 | stopAtCheckbox: false, | ||
56 | stopAt: this.video.duration, | ||
57 | |||
58 | subtitleCheckbox: false, | ||
59 | subtitle, | ||
60 | |||
61 | loop: false, | ||
62 | autoplay: false, | ||
63 | muted: false, | ||
64 | |||
65 | // Embed options | ||
66 | title: true, | ||
67 | warningTitle: true, | ||
68 | controls: true | ||
69 | } | ||
70 | |||
71 | this.modalService.open(this.modal, { centered: true }) | ||
72 | } | ||
73 | |||
74 | getVideoIframeCode () { | ||
75 | const options = this.getOptions(this.video.embedUrl) | ||
76 | |||
77 | const embedUrl = buildVideoLink(options) | ||
78 | return buildVideoEmbed(embedUrl) | ||
79 | } | ||
80 | |||
81 | getVideoUrl () { | ||
82 | const baseUrl = window.location.origin + '/videos/watch/' + this.video.uuid | ||
83 | const options = this.getOptions(baseUrl) | ||
84 | |||
85 | return buildVideoLink(options) | ||
86 | } | ||
87 | |||
88 | getPlaylistUrl () { | ||
89 | const base = window.location.origin + '/videos/watch/playlist/' + this.playlist.uuid | ||
90 | |||
91 | if (!this.includeVideoInPlaylist) return base | ||
92 | |||
93 | return base + '?videoId=' + this.video.uuid | ||
94 | } | ||
95 | |||
96 | notSecure () { | ||
97 | return window.location.protocol === 'http:' | ||
98 | } | ||
99 | |||
100 | isInEmbedTab () { | ||
101 | return this.activeId === 'embed' | ||
102 | } | ||
103 | |||
104 | hasPlaylist () { | ||
105 | return !!this.playlist | ||
106 | } | ||
107 | |||
108 | private getOptions (baseUrl?: string) { | ||
109 | return { | ||
110 | baseUrl, | ||
111 | |||
112 | startTime: this.customizations.startAtCheckbox ? this.customizations.startAt : undefined, | ||
113 | stopTime: this.customizations.stopAtCheckbox ? this.customizations.stopAt : undefined, | ||
114 | |||
115 | subtitle: this.customizations.subtitleCheckbox ? this.customizations.subtitle : undefined, | ||
116 | |||
117 | loop: this.customizations.loop, | ||
118 | autoplay: this.customizations.autoplay, | ||
119 | muted: this.customizations.muted, | ||
120 | |||
121 | title: this.customizations.title, | ||
122 | warningTitle: this.customizations.warningTitle, | ||
123 | controls: this.customizations.controls | ||
124 | } | ||
125 | } | ||
126 | } | ||
diff --git a/client/src/app/+videos/+video-watch/modal/video-support.component.html b/client/src/app/+videos/+video-watch/modal/video-support.component.html 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 @@ | |||
1 | import { Component, Input, ViewChild } from '@angular/core' | ||
2 | import { MarkdownService } from '@app/core' | ||
3 | import { VideoDetails } from '@app/shared/shared-main' | ||
4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-video-support', | ||
8 | templateUrl: './video-support.component.html', | ||
9 | styleUrls: [ './video-support.component.scss' ] | ||
10 | }) | ||
11 | export class VideoSupportComponent { | ||
12 | @Input() video: VideoDetails = null | ||
13 | |||
14 | @ViewChild('modal', { static: true }) modal: NgbModal | ||
15 | |||
16 | videoHTMLSupport = '' | ||
17 | |||
18 | constructor ( | ||
19 | private markdownService: MarkdownService, | ||
20 | private modalService: NgbModal | ||
21 | ) { } | ||
22 | |||
23 | show () { | ||
24 | this.modalService.open(this.modal, { centered: true }) | ||
25 | |||
26 | this.markdownService.enhancedMarkdownToHTML(this.video.support) | ||
27 | .then(r => this.videoHTMLSupport = r) | ||
28 | } | ||
29 | } | ||
diff --git a/client/src/app/+videos/+video-watch/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 @@ | |||
1 | import { Observable, of } from 'rxjs' | ||
2 | import { map, switchMap } from 'rxjs/operators' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { ServerService, UserService } from '@app/core' | ||
5 | import { Video, VideoService } from '@app/shared/shared-main' | ||
6 | import { AdvancedSearch, SearchService } from '@app/shared/shared-search' | ||
7 | import { ServerConfig } from '@shared/models' | ||
8 | import { RecommendationInfo } from './recommendation-info.model' | ||
9 | import { RecommendationService } from './recommendations.service' | ||
10 | |||
11 | /** | ||
12 | * Provides "recommendations" by providing the most recently uploaded videos. | ||
13 | */ | ||
14 | @Injectable() | ||
15 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import { InputSwitchModule } from 'primeng/inputswitch' | ||
2 | import { CommonModule } from '@angular/common' | ||
3 | import { NgModule } from '@angular/core' | ||
4 | import { SharedMainModule } from '@app/shared/shared-main' | ||
5 | import { SharedSearchModule } from '@app/shared/shared-search' | ||
6 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | ||
7 | import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' | ||
8 | import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' | ||
9 | import { RecommendedVideosComponent } from './recommended-videos.component' | ||
10 | import { 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 | }) | ||
33 | export 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 @@ | |||
1 | import { Observable } from 'rxjs' | ||
2 | import { Video } from '@app/shared/shared-main' | ||
3 | import { RecommendationInfo } from './recommendation-info.model' | ||
4 | |||
5 | export 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 | |||
29 | hr { | ||
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 @@ | |||
1 | import { Observable } from 'rxjs' | ||
2 | import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' | ||
3 | import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core' | ||
4 | import { Video } from '@app/shared/shared-main' | ||
5 | import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' | ||
6 | import { VideoPlaylist } from '@app/shared/shared-video-playlist' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { RecommendationInfo } from './recommendation-info.model' | ||
9 | import { 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 | }) | ||
16 | export 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 @@ | |||
1 | import { Observable, ReplaySubject } from 'rxjs' | ||
2 | import { map, shareReplay, switchMap, take } from 'rxjs/operators' | ||
3 | import { Inject, Injectable } from '@angular/core' | ||
4 | import { Video } from '@app/shared/shared-main' | ||
5 | import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' | ||
6 | import { RecommendationInfo } from './recommendation-info.model' | ||
7 | import { RecommendationService } from './recommendations.service' | ||
8 | |||
9 | /** | ||
10 | * This store is intended to provide data for the RecommendedVideosComponent. | ||
11 | */ | ||
12 | @Injectable() | ||
13 | export 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 @@ | |||
1 | import { Directive, EventEmitter, HostListener, Output } from '@angular/core' | ||
2 | |||
3 | @Directive({ | ||
4 | selector: '[timestampRouteTransformer]' | ||
5 | }) | ||
6 | export class TimestampRouteTransformerDirective { | ||
7 | @Output() timestampClicked = new EventEmitter<number>() | ||
8 | |||
9 | @HostListener('click', ['$event']) | ||
10 | public onClick ($event: Event) { | ||
11 | const target = $event.target as HTMLLinkElement | ||
12 | |||
13 | if (target.hasAttribute('href') !== true) return | ||
14 | |||
15 | const ngxLink = document.createElement('a') | ||
16 | ngxLink.href = target.getAttribute('href') | ||
17 | |||
18 | // we only care about reflective links | ||
19 | if (ngxLink.host !== window.location.host) return | ||
20 | |||
21 | const ngxLinkParams = new URLSearchParams(ngxLink.search) | ||
22 | if (ngxLinkParams.has('start') !== true) return | ||
23 | |||
24 | const separators = ['h', 'm', 's'] | ||
25 | const start = ngxLinkParams | ||
26 | .get('start') | ||
27 | .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator | ||
28 | .map(t => { | ||
29 | if (t.includes('h')) return parseInt(t, 10) * 3600 | ||
30 | if (t.includes('m')) return parseInt(t, 10) * 60 | ||
31 | return parseInt(t, 10) | ||
32 | }) | ||
33 | .reduce((acc, t) => acc + t) | ||
34 | |||
35 | this.timestampClicked.emit(start) | ||
36 | |||
37 | $event.preventDefault() | ||
38 | } | ||
39 | } | ||
diff --git a/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts b/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts 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 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
3 | |||
4 | @Pipe({ | ||
5 | name: 'myVideoDurationFormatter' | ||
6 | }) | ||
7 | export class VideoDurationPipe implements PipeTransform { | ||
8 | |||
9 | constructor (private i18n: I18n) { | ||
10 | |||
11 | } | ||
12 | |||
13 | transform (value: number): string { | ||
14 | const hours = Math.floor(value / 3600) | ||
15 | const minutes = Math.floor((value % 3600) / 60) | ||
16 | const seconds = value % 60 | ||
17 | |||
18 | if (hours > 0) { | ||
19 | return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds }) | ||
20 | } | ||
21 | |||
22 | if (minutes > 0) { | ||
23 | return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds }) | ||
24 | } | ||
25 | |||
26 | return this.i18n('{{seconds}} sec', { seconds }) | ||
27 | } | ||
28 | } | ||
diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.html b/client/src/app/+videos/+video-watch/video-watch-playlist.component.html 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core' | ||
4 | import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage' | ||
5 | import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-video-watch-playlist', | ||
11 | templateUrl: './video-watch-playlist.component.html', | ||
12 | styleUrls: [ './video-watch-playlist.component.scss' ] | ||
13 | }) | ||
14 | export class VideoWatchPlaylistComponent { | ||
15 | static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist' | ||
16 | static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist' | ||
17 | |||
18 | @Input() video: VideoDetails | ||
19 | @Input() playlist: VideoPlaylist | ||
20 | |||
21 | playlistElements: VideoPlaylistElement[] = [] | ||
22 | playlistPagination: ComponentPagination = { | ||
23 | currentPage: 1, | ||
24 | itemsPerPage: 30, | ||
25 | totalItems: null | ||
26 | } | ||
27 | |||
28 | autoPlayNextVideoPlaylist: boolean | ||
29 | autoPlayNextVideoPlaylistSwitchText = '' | ||
30 | loopPlaylist: boolean | ||
31 | loopPlaylistSwitchText = '' | ||
32 | noPlaylistVideos = false | ||
33 | currentPlaylistPosition = 1 | ||
34 | |||
35 | constructor ( | ||
36 | private userService: UserService, | ||
37 | private auth: AuthService, | ||
38 | private notifier: Notifier, | ||
39 | private i18n: I18n, | ||
40 | private videoPlaylist: VideoPlaylistService, | ||
41 | private localStorageService: LocalStorageService, | ||
42 | private sessionStorageService: SessionStorageService, | ||
43 | private router: Router | ||
44 | ) { | ||
45 | // defaults to true | ||
46 | this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() | ||
47 | ? this.auth.getUser().autoPlayNextVideoPlaylist | ||
48 | : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' | ||
49 | this.setAutoPlayNextVideoPlaylistSwitchText() | ||
50 | |||
51 | // defaults to false | ||
52 | this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' | ||
53 | this.setLoopPlaylistSwitchText() | ||
54 | } | ||
55 | |||
56 | onPlaylistVideosNearOfBottom () { | ||
57 | // Last page | ||
58 | if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return | ||
59 | |||
60 | this.playlistPagination.currentPage += 1 | ||
61 | this.loadPlaylistElements(this.playlist,false) | ||
62 | } | ||
63 | |||
64 | onElementRemoved (playlistElement: VideoPlaylistElement) { | ||
65 | this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id) | ||
66 | |||
67 | this.playlistPagination.totalItems-- | ||
68 | } | ||
69 | |||
70 | isPlaylistOwned () { | ||
71 | return this.playlist.isLocal === true && | ||
72 | this.auth.isLoggedIn() && | ||
73 | this.playlist.ownerAccount.name === this.auth.getUser().username | ||
74 | } | ||
75 | |||
76 | isUnlistedPlaylist () { | ||
77 | return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED | ||
78 | } | ||
79 | |||
80 | isPrivatePlaylist () { | ||
81 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE | ||
82 | } | ||
83 | |||
84 | isPublicPlaylist () { | ||
85 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC | ||
86 | } | ||
87 | |||
88 | loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) { | ||
89 | this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination) | ||
90 | .subscribe(({ total, data }) => { | ||
91 | this.playlistElements = this.playlistElements.concat(data) | ||
92 | this.playlistPagination.totalItems = total | ||
93 | |||
94 | const firstAvailableVideos = this.playlistElements.find(e => !!e.video) | ||
95 | if (!firstAvailableVideos) { | ||
96 | this.noPlaylistVideos = true | ||
97 | return | ||
98 | } | ||
99 | |||
100 | this.updatePlaylistIndex(this.video) | ||
101 | |||
102 | if (redirectToFirst) { | ||
103 | const extras = { | ||
104 | queryParams: { | ||
105 | start: firstAvailableVideos.startTimestamp, | ||
106 | stop: firstAvailableVideos.stopTimestamp, | ||
107 | videoId: firstAvailableVideos.video.uuid | ||
108 | }, | ||
109 | replaceUrl: true | ||
110 | } | ||
111 | this.router.navigate([], extras) | ||
112 | } | ||
113 | }) | ||
114 | } | ||
115 | |||
116 | updatePlaylistIndex (video: VideoDetails) { | ||
117 | if (this.playlistElements.length === 0 || !video) return | ||
118 | |||
119 | for (const playlistElement of this.playlistElements) { | ||
120 | if (playlistElement.video && playlistElement.video.id === video.id) { | ||
121 | this.currentPlaylistPosition = playlistElement.position | ||
122 | return | ||
123 | } | ||
124 | } | ||
125 | |||
126 | // Load more videos to find our video | ||
127 | this.onPlaylistVideosNearOfBottom() | ||
128 | } | ||
129 | |||
130 | findNextPlaylistVideo (position = this.currentPlaylistPosition): VideoPlaylistElement { | ||
131 | if (this.currentPlaylistPosition >= this.playlistPagination.totalItems) { | ||
132 | // we have reached the end of the playlist: either loop or stop | ||
133 | if (this.loopPlaylist) { | ||
134 | this.currentPlaylistPosition = position = 0 | ||
135 | } else { | ||
136 | return | ||
137 | } | ||
138 | } | ||
139 | |||
140 | const next = this.playlistElements.find(e => e.position === position) | ||
141 | |||
142 | if (!next || !next.video) { | ||
143 | return this.findNextPlaylistVideo(position + 1) | ||
144 | } | ||
145 | |||
146 | return next | ||
147 | } | ||
148 | |||
149 | navigateToNextPlaylistVideo () { | ||
150 | const next = this.findNextPlaylistVideo(this.currentPlaylistPosition + 1) | ||
151 | if (!next) return | ||
152 | const start = next.startTimestamp | ||
153 | const stop = next.stopTimestamp | ||
154 | this.router.navigate([],{ queryParams: { videoId: next.video.uuid, start, stop } }) | ||
155 | } | ||
156 | |||
157 | switchAutoPlayNextVideoPlaylist () { | ||
158 | this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist | ||
159 | this.setAutoPlayNextVideoPlaylistSwitchText() | ||
160 | |||
161 | peertubeLocalStorage.setItem( | ||
162 | VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, | ||
163 | this.autoPlayNextVideoPlaylist.toString() | ||
164 | ) | ||
165 | |||
166 | if (this.auth.isLoggedIn()) { | ||
167 | const details = { | ||
168 | autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist | ||
169 | } | ||
170 | |||
171 | this.userService.updateMyProfile(details).subscribe( | ||
172 | () => { | ||
173 | this.auth.refreshUserInformation() | ||
174 | }, | ||
175 | err => this.notifier.error(err.message) | ||
176 | ) | ||
177 | } | ||
178 | } | ||
179 | |||
180 | switchLoopPlaylist () { | ||
181 | this.loopPlaylist = !this.loopPlaylist | ||
182 | this.setLoopPlaylistSwitchText() | ||
183 | |||
184 | peertubeSessionStorage.setItem( | ||
185 | VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, | ||
186 | this.loopPlaylist.toString() | ||
187 | ) | ||
188 | } | ||
189 | |||
190 | private setAutoPlayNextVideoPlaylistSwitchText () { | ||
191 | this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist | ||
192 | ? this.i18n('Stop autoplaying next video') | ||
193 | : this.i18n('Autoplay next video') | ||
194 | } | ||
195 | |||
196 | private setLoopPlaylistSwitchText () { | ||
197 | this.loopPlaylistSwitchText = this.loopPlaylist | ||
198 | ? this.i18n('Stop looping playlist videos') | ||
199 | : this.i18n('Loop playlist videos') | ||
200 | } | ||
201 | } | ||
diff --git a/client/src/app/+videos/+video-watch/video-watch-routing.module.ts b/client/src/app/+videos/+video-watch/video-watch-routing.module.ts 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 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { MetaGuard } from '@ngx-meta/core' | ||
4 | import { VideoWatchComponent } from './video-watch.component' | ||
5 | |||
6 | const videoWatchRoutes: Routes = [ | ||
7 | { | ||
8 | path: 'playlist/:playlistId', | ||
9 | component: VideoWatchComponent, | ||
10 | canActivate: [ MetaGuard ] | ||
11 | }, | ||
12 | { | ||
13 | path: ':videoId/comments/:commentId', | ||
14 | redirectTo: ':videoId' | ||
15 | }, | ||
16 | { | ||
17 | path: ':videoId', | ||
18 | component: VideoWatchComponent, | ||
19 | canActivate: [ MetaGuard ] | ||
20 | } | ||
21 | ] | ||
22 | |||
23 | @NgModule({ | ||
24 | imports: [ RouterModule.forChild(videoWatchRoutes) ], | ||
25 | exports: [ RouterModule ] | ||
26 | }) | ||
27 | export class VideoWatchRoutingModule {} | ||
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html 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 | |||
437 | my-video-comments { | ||
438 | display: inline-block; | ||
439 | width: 100%; | ||
440 | margin-bottom: 20px; | ||
441 | } | ||
442 | |||
443 | // If the view is not expanded, take into account the menu | ||
444 | .privacy-concerns { | ||
445 | z-index: z(dropdown) + 1; | ||
446 | width: calc(100% - #{$menu-width}); | ||
447 | } | ||
448 | |||
449 | @media screen and (max-width: $small-view) { | ||
450 | .privacy-concerns { | ||
451 | margin-left: $menu-width - 15px; // Menu is absolute | ||
452 | } | ||
453 | } | ||
454 | |||
455 | :host-context(.expanded) { | ||
456 | .privacy-concerns { | ||
457 | width: 100%; | ||
458 | margin-left: -15px; | ||
459 | } | ||
460 | } | ||
461 | |||
462 | .privacy-concerns { | ||
463 | position: fixed; | ||
464 | bottom: 0; | ||
465 | z-index: z(privacymsg); | ||
466 | |||
467 | padding: 5px 15px; | ||
468 | |||
469 | display: flex; | ||
470 | flex-wrap: nowrap; | ||
471 | align-items: center; | ||
472 | justify-content: space-between; | ||
473 | background-color: rgba(0, 0, 0, 0.9); | ||
474 | color: #fff; | ||
475 | |||
476 | .privacy-concerns-text { | ||
477 | margin: 0 5px; | ||
478 | } | ||
479 | |||
480 | a { | ||
481 | @include disable-default-a-behaviour; | ||
482 | |||
483 | color: pvar(--mainColor); | ||
484 | transition: color 0.3s; | ||
485 | |||
486 | &:hover { | ||
487 | color: #fff; | ||
488 | } | ||
489 | } | ||
490 | |||
491 | .privacy-concerns-button { | ||
492 | padding: 5px 8px 5px 7px; | ||
493 | margin-left: auto; | ||
494 | border-radius: 3px; | ||
495 | white-space: nowrap; | ||
496 | cursor: pointer; | ||
497 | transition: background-color 0.3s; | ||
498 | font-weight: $font-semibold; | ||
499 | |||
500 | &:hover { | ||
501 | background-color: #000; | ||
502 | } | ||
503 | } | ||
504 | |||
505 | .privacy-concerns-okay { | ||
506 | background-color: pvar(--mainColor); | ||
507 | margin-left: 10px; | ||
508 | } | ||
509 | } | ||
510 | |||
511 | @media screen and (max-width: 1600px) { | ||
512 | .video-bottom .video-info .video-attributes .video-attribute { | ||
513 | margin-bottom: 5px; | ||
514 | } | ||
515 | } | ||
516 | |||
517 | @media screen and (max-width: 1300px) { | ||
518 | .privacy-concerns { | ||
519 | font-size: 12px; | ||
520 | padding: 2px 5px; | ||
521 | |||
522 | .privacy-concerns-text { | ||
523 | margin: 0; | ||
524 | } | ||
525 | } | ||
526 | } | ||
527 | |||
528 | @media screen and (max-width: 1100px) { | ||
529 | #video-wrapper { | ||
530 | flex-direction: column; | ||
531 | justify-content: center; | ||
532 | |||
533 | my-video-watch-playlist ::ng-deep .playlist { | ||
534 | @include playlist-below-player; | ||
535 | } | ||
536 | } | ||
537 | |||
538 | .video-bottom { | ||
539 | flex-direction: column; | ||
540 | |||
541 | ::ng-deep .other-videos { | ||
542 | padding-left: 0 !important; | ||
543 | |||
544 | ::ng-deep .video-miniature { | ||
545 | flex-direction: row; | ||
546 | width: auto; | ||
547 | } | ||
548 | } | ||
549 | } | ||
550 | } | ||
551 | |||
552 | @media screen and (max-width: 600px) { | ||
553 | .video-bottom { | ||
554 | margin-top: 20px !important; | ||
555 | padding-bottom: 20px !important; | ||
556 | |||
557 | .video-info { | ||
558 | padding: 0; | ||
559 | |||
560 | .video-info-first-row { | ||
561 | |||
562 | .video-info-name { | ||
563 | font-size: 20px; | ||
564 | height: auto; | ||
565 | } | ||
566 | } | ||
567 | } | ||
568 | } | ||
569 | |||
570 | ::ng-deep .other-videos .video-miniature { | ||
571 | flex-direction: column; | ||
572 | } | ||
573 | |||
574 | .privacy-concerns { | ||
575 | width: 100%; | ||
576 | |||
577 | strong { | ||
578 | display: none; | ||
579 | } | ||
580 | } | ||
581 | } | ||
582 | |||
583 | @media screen and (max-width: 450px) { | ||
584 | .video-bottom { | ||
585 | .action-button .icon-text { | ||
586 | display: none !important; | ||
587 | } | ||
588 | |||
589 | .video-info .video-info-first-row { | ||
590 | .video-info-name { | ||
591 | font-size: 18px; | ||
592 | } | ||
593 | |||
594 | .video-info-date-views { | ||
595 | font-size: 14px; | ||
596 | } | ||
597 | |||
598 | .video-actions-rates { | ||
599 | margin-top: 10px; | ||
600 | } | ||
601 | } | ||
602 | |||
603 | .video-info-description { | ||
604 | font-size: 14px !important; | ||
605 | } | ||
606 | } | ||
607 | } | ||
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts new file mode 100644 index 000000000..5b0b34c80 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts | |||
@@ -0,0 +1,782 @@ | |||
1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | ||
2 | import { forkJoin, Observable, Subscription } from 'rxjs' | ||
3 | import { catchError } from 'rxjs/operators' | ||
4 | import { PlatformLocation } from '@angular/common' | ||
5 | import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' | ||
6 | import { ActivatedRoute, Router } from '@angular/router' | ||
7 | import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core' | ||
8 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
9 | import { RedirectService } from '@app/core/routing/redirect.service' | ||
10 | import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers' | ||
11 | import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' | ||
12 | import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' | ||
13 | import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' | ||
14 | import { MetaService } from '@ngx-meta/core' | ||
15 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
16 | import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models' | ||
17 | import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage' | ||
18 | import { | ||
19 | CustomizationOptions, | ||
20 | P2PMediaLoaderOptions, | ||
21 | PeertubePlayerManager, | ||
22 | PeertubePlayerManagerOptions, | ||
23 | PlayerMode, | ||
24 | videojs | ||
25 | } from '../../../assets/player/peertube-player-manager' | ||
26 | import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils' | ||
27 | import { environment } from '../../../environments/environment' | ||
28 | import { VideoShareComponent } from './modal/video-share.component' | ||
29 | import { VideoSupportComponent } from './modal/video-support.component' | ||
30 | import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' | ||
31 | |||
32 | @Component({ | ||
33 | selector: 'my-video-watch', | ||
34 | templateUrl: './video-watch.component.html', | ||
35 | styleUrls: [ './video-watch.component.scss' ] | ||
36 | }) | ||
37 | export class VideoWatchComponent implements OnInit, OnDestroy { | ||
38 | private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' | ||
39 | |||
40 | @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent | ||
41 | @ViewChild('videoShareModal') videoShareModal: VideoShareComponent | ||
42 | @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent | ||
43 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent | ||
44 | |||
45 | player: any | ||
46 | playerElement: HTMLVideoElement | ||
47 | theaterEnabled = false | ||
48 | userRating: UserVideoRateType = null | ||
49 | descriptionLoading = false | ||
50 | |||
51 | video: VideoDetails = null | ||
52 | videoCaptions: VideoCaption[] = [] | ||
53 | |||
54 | playlist: VideoPlaylist = null | ||
55 | |||
56 | completeDescriptionShown = false | ||
57 | completeVideoDescription: string | ||
58 | shortVideoDescription: string | ||
59 | videoHTMLDescription = '' | ||
60 | likesBarTooltipText = '' | ||
61 | hasAlreadyAcceptedPrivacyConcern = false | ||
62 | remoteServerDown = false | ||
63 | hotkeys: Hotkey[] = [] | ||
64 | |||
65 | tooltipLike = '' | ||
66 | tooltipDislike = '' | ||
67 | tooltipSupport = '' | ||
68 | tooltipSaveToPlaylist = '' | ||
69 | |||
70 | private nextVideoUuid = '' | ||
71 | private nextVideoTitle = '' | ||
72 | private currentTime: number | ||
73 | private paramsSub: Subscription | ||
74 | private queryParamsSub: Subscription | ||
75 | private configSub: Subscription | ||
76 | |||
77 | private serverConfig: ServerConfig | ||
78 | |||
79 | constructor ( | ||
80 | private elementRef: ElementRef, | ||
81 | private changeDetector: ChangeDetectorRef, | ||
82 | private route: ActivatedRoute, | ||
83 | private router: Router, | ||
84 | private videoService: VideoService, | ||
85 | private playlistService: VideoPlaylistService, | ||
86 | private confirmService: ConfirmService, | ||
87 | private metaService: MetaService, | ||
88 | private authService: AuthService, | ||
89 | private userService: UserService, | ||
90 | private serverService: ServerService, | ||
91 | private restExtractor: RestExtractor, | ||
92 | private notifier: Notifier, | ||
93 | private markdownService: MarkdownService, | ||
94 | private zone: NgZone, | ||
95 | private redirectService: RedirectService, | ||
96 | private videoCaptionService: VideoCaptionService, | ||
97 | private i18n: I18n, | ||
98 | private hotkeysService: HotkeysService, | ||
99 | private hooks: HooksService, | ||
100 | private location: PlatformLocation, | ||
101 | @Inject(LOCALE_ID) private localeId: string | ||
102 | ) { | ||
103 | this.tooltipLike = this.i18n('Like this video') | ||
104 | this.tooltipDislike = this.i18n('Dislike this video') | ||
105 | this.tooltipSupport = this.i18n('Support options for this video') | ||
106 | this.tooltipSaveToPlaylist = this.i18n('Save to playlist') | ||
107 | } | ||
108 | |||
109 | get user () { | ||
110 | return this.authService.getUser() | ||
111 | } | ||
112 | |||
113 | get anonymousUser () { | ||
114 | return this.userService.getAnonymousUser() | ||
115 | } | ||
116 | |||
117 | async ngOnInit () { | ||
118 | this.serverConfig = this.serverService.getTmpConfig() | ||
119 | |||
120 | this.configSub = this.serverService.getConfig() | ||
121 | .subscribe(config => { | ||
122 | this.serverConfig = config | ||
123 | |||
124 | if ( | ||
125 | isWebRTCDisabled() || | ||
126 | this.serverConfig.tracker.enabled === false || | ||
127 | getStoredP2PEnabled() === false || | ||
128 | peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true' | ||
129 | ) { | ||
130 | this.hasAlreadyAcceptedPrivacyConcern = true | ||
131 | } | ||
132 | }) | ||
133 | |||
134 | this.paramsSub = this.route.params.subscribe(routeParams => { | ||
135 | const videoId = routeParams[ 'videoId' ] | ||
136 | if (videoId) this.loadVideo(videoId) | ||
137 | |||
138 | const playlistId = routeParams[ 'playlistId' ] | ||
139 | if (playlistId) this.loadPlaylist(playlistId) | ||
140 | }) | ||
141 | |||
142 | this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => { | ||
143 | const videoId = queryParams[ 'videoId' ] | ||
144 | if (videoId) this.loadVideo(videoId) | ||
145 | |||
146 | const start = queryParams[ 'start' ] | ||
147 | if (this.player && start) this.player.currentTime(parseInt(start, 10)) | ||
148 | }) | ||
149 | |||
150 | this.initHotkeys() | ||
151 | |||
152 | this.theaterEnabled = getStoredTheater() | ||
153 | |||
154 | this.hooks.runAction('action:video-watch.init', 'video-watch') | ||
155 | } | ||
156 | |||
157 | ngOnDestroy () { | ||
158 | this.flushPlayer() | ||
159 | |||
160 | // Unsubscribe subscriptions | ||
161 | if (this.paramsSub) this.paramsSub.unsubscribe() | ||
162 | if (this.queryParamsSub) this.queryParamsSub.unsubscribe() | ||
163 | |||
164 | // Unbind hotkeys | ||
165 | this.hotkeysService.remove(this.hotkeys) | ||
166 | } | ||
167 | |||
168 | setLike () { | ||
169 | if (this.isUserLoggedIn() === false) return | ||
170 | |||
171 | // Already liked this video | ||
172 | if (this.userRating === 'like') this.setRating('none') | ||
173 | else this.setRating('like') | ||
174 | } | ||
175 | |||
176 | setDislike () { | ||
177 | if (this.isUserLoggedIn() === false) return | ||
178 | |||
179 | // Already disliked this video | ||
180 | if (this.userRating === 'dislike') this.setRating('none') | ||
181 | else this.setRating('dislike') | ||
182 | } | ||
183 | |||
184 | getRatePopoverText () { | ||
185 | if (this.isUserLoggedIn()) return undefined | ||
186 | |||
187 | return this.i18n('You need to be connected to rate this content.') | ||
188 | } | ||
189 | |||
190 | showMoreDescription () { | ||
191 | if (this.completeVideoDescription === undefined) { | ||
192 | return this.loadCompleteDescription() | ||
193 | } | ||
194 | |||
195 | this.updateVideoDescription(this.completeVideoDescription) | ||
196 | this.completeDescriptionShown = true | ||
197 | } | ||
198 | |||
199 | showLessDescription () { | ||
200 | this.updateVideoDescription(this.shortVideoDescription) | ||
201 | this.completeDescriptionShown = false | ||
202 | } | ||
203 | |||
204 | loadCompleteDescription () { | ||
205 | this.descriptionLoading = true | ||
206 | |||
207 | this.videoService.loadCompleteDescription(this.video.descriptionPath) | ||
208 | .subscribe( | ||
209 | description => { | ||
210 | this.completeDescriptionShown = true | ||
211 | this.descriptionLoading = false | ||
212 | |||
213 | this.shortVideoDescription = this.video.description | ||
214 | this.completeVideoDescription = description | ||
215 | |||
216 | this.updateVideoDescription(this.completeVideoDescription) | ||
217 | }, | ||
218 | |||
219 | error => { | ||
220 | this.descriptionLoading = false | ||
221 | this.notifier.error(error.message) | ||
222 | } | ||
223 | ) | ||
224 | } | ||
225 | |||
226 | showSupportModal () { | ||
227 | this.pausePlayer() | ||
228 | |||
229 | this.videoSupportModal.show() | ||
230 | } | ||
231 | |||
232 | showShareModal () { | ||
233 | this.pausePlayer() | ||
234 | |||
235 | this.videoShareModal.show(this.currentTime) | ||
236 | } | ||
237 | |||
238 | isUserLoggedIn () { | ||
239 | return this.authService.isLoggedIn() | ||
240 | } | ||
241 | |||
242 | getVideoTags () { | ||
243 | if (!this.video || Array.isArray(this.video.tags) === false) return [] | ||
244 | |||
245 | return this.video.tags | ||
246 | } | ||
247 | |||
248 | onRecommendations (videos: Video[]) { | ||
249 | if (videos.length > 0) { | ||
250 | // The recommended videos's first element should be the next video | ||
251 | const video = videos[0] | ||
252 | this.nextVideoUuid = video.uuid | ||
253 | this.nextVideoTitle = video.name | ||
254 | } | ||
255 | } | ||
256 | |||
257 | onModalOpened () { | ||
258 | this.pausePlayer() | ||
259 | } | ||
260 | |||
261 | onVideoRemoved () { | ||
262 | this.redirectService.redirectToHomepage() | ||
263 | } | ||
264 | |||
265 | declinedPrivacyConcern () { | ||
266 | peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false') | ||
267 | this.hasAlreadyAcceptedPrivacyConcern = false | ||
268 | } | ||
269 | |||
270 | acceptedPrivacyConcern () { | ||
271 | peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true') | ||
272 | this.hasAlreadyAcceptedPrivacyConcern = true | ||
273 | } | ||
274 | |||
275 | isVideoToTranscode () { | ||
276 | return this.video && this.video.state.id === VideoState.TO_TRANSCODE | ||
277 | } | ||
278 | |||
279 | isVideoToImport () { | ||
280 | return this.video && this.video.state.id === VideoState.TO_IMPORT | ||
281 | } | ||
282 | |||
283 | hasVideoScheduledPublication () { | ||
284 | return this.video && this.video.scheduledUpdate !== undefined | ||
285 | } | ||
286 | |||
287 | isVideoBlur (video: Video) { | ||
288 | return video.isVideoNSFWForUser(this.user, this.serverConfig) | ||
289 | } | ||
290 | |||
291 | isAutoPlayEnabled () { | ||
292 | return ( | ||
293 | (this.user && this.user.autoPlayNextVideo) || | ||
294 | this.anonymousUser.autoPlayNextVideo | ||
295 | ) | ||
296 | } | ||
297 | |||
298 | handleTimestampClicked (timestamp: number) { | ||
299 | if (this.player) this.player.currentTime(timestamp) | ||
300 | scrollToTop() | ||
301 | } | ||
302 | |||
303 | isPlaylistAutoPlayEnabled () { | ||
304 | return ( | ||
305 | (this.user && this.user.autoPlayNextVideoPlaylist) || | ||
306 | this.anonymousUser.autoPlayNextVideoPlaylist | ||
307 | ) | ||
308 | } | ||
309 | |||
310 | private loadVideo (videoId: string) { | ||
311 | // Video did not change | ||
312 | if (this.video && this.video.uuid === videoId) return | ||
313 | |||
314 | if (this.player) this.player.pause() | ||
315 | |||
316 | const videoObs = this.hooks.wrapObsFun( | ||
317 | this.videoService.getVideo.bind(this.videoService), | ||
318 | { videoId }, | ||
319 | 'video-watch', | ||
320 | 'filter:api.video-watch.video.get.params', | ||
321 | 'filter:api.video-watch.video.get.result' | ||
322 | ) | ||
323 | |||
324 | // Video did change | ||
325 | forkJoin([ | ||
326 | videoObs, | ||
327 | this.videoCaptionService.listCaptions(videoId) | ||
328 | ]) | ||
329 | .pipe( | ||
330 | // If 401, the video is private or blocked so redirect to 404 | ||
331 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) | ||
332 | ) | ||
333 | .subscribe(([ video, captionsResult ]) => { | ||
334 | const queryParams = this.route.snapshot.queryParams | ||
335 | |||
336 | const urlOptions = { | ||
337 | startTime: queryParams.start, | ||
338 | stopTime: queryParams.stop, | ||
339 | |||
340 | muted: queryParams.muted, | ||
341 | loop: queryParams.loop, | ||
342 | subtitle: queryParams.subtitle, | ||
343 | |||
344 | playerMode: queryParams.mode, | ||
345 | peertubeLink: false | ||
346 | } | ||
347 | |||
348 | this.onVideoFetched(video, captionsResult.data, urlOptions) | ||
349 | .catch(err => this.handleError(err)) | ||
350 | }) | ||
351 | } | ||
352 | |||
353 | private loadPlaylist (playlistId: string) { | ||
354 | // Playlist did not change | ||
355 | if (this.playlist && this.playlist.uuid === playlistId) return | ||
356 | |||
357 | this.playlistService.getVideoPlaylist(playlistId) | ||
358 | .pipe( | ||
359 | // If 401, the video is private or blocked so redirect to 404 | ||
360 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) | ||
361 | ) | ||
362 | .subscribe(playlist => { | ||
363 | this.playlist = playlist | ||
364 | |||
365 | const videoId = this.route.snapshot.queryParams['videoId'] | ||
366 | this.videoWatchPlaylist.loadPlaylistElements(playlist, !videoId) | ||
367 | }) | ||
368 | } | ||
369 | |||
370 | private updateVideoDescription (description: string) { | ||
371 | this.video.description = description | ||
372 | this.setVideoDescriptionHTML() | ||
373 | .catch(err => console.error(err)) | ||
374 | } | ||
375 | |||
376 | private async setVideoDescriptionHTML () { | ||
377 | const html = await this.markdownService.textMarkdownToHTML(this.video.description) | ||
378 | this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html) | ||
379 | } | ||
380 | |||
381 | private setVideoLikesBarTooltipText () { | ||
382 | this.likesBarTooltipText = this.i18n('{{likesNumber}} likes / {{dislikesNumber}} dislikes', { | ||
383 | likesNumber: this.video.likes, | ||
384 | dislikesNumber: this.video.dislikes | ||
385 | }) | ||
386 | } | ||
387 | |||
388 | private handleError (err: any) { | ||
389 | const errorMessage: string = typeof err === 'string' ? err : err.message | ||
390 | if (!errorMessage) return | ||
391 | |||
392 | // Display a message in the video player instead of a notification | ||
393 | if (errorMessage.indexOf('from xs param') !== -1) { | ||
394 | this.flushPlayer() | ||
395 | this.remoteServerDown = true | ||
396 | this.changeDetector.detectChanges() | ||
397 | |||
398 | return | ||
399 | } | ||
400 | |||
401 | this.notifier.error(errorMessage) | ||
402 | } | ||
403 | |||
404 | private checkUserRating () { | ||
405 | // Unlogged users do not have ratings | ||
406 | if (this.isUserLoggedIn() === false) return | ||
407 | |||
408 | this.videoService.getUserVideoRating(this.video.id) | ||
409 | .subscribe( | ||
410 | ratingObject => { | ||
411 | if (ratingObject) { | ||
412 | this.userRating = ratingObject.rating | ||
413 | } | ||
414 | }, | ||
415 | |||
416 | err => this.notifier.error(err.message) | ||
417 | ) | ||
418 | } | ||
419 | |||
420 | private async onVideoFetched ( | ||
421 | video: VideoDetails, | ||
422 | videoCaptions: VideoCaption[], | ||
423 | urlOptions: CustomizationOptions & { playerMode: PlayerMode } | ||
424 | ) { | ||
425 | this.video = video | ||
426 | this.videoCaptions = videoCaptions | ||
427 | |||
428 | // Re init attributes | ||
429 | this.descriptionLoading = false | ||
430 | this.completeDescriptionShown = false | ||
431 | this.remoteServerDown = false | ||
432 | this.currentTime = undefined | ||
433 | |||
434 | this.videoWatchPlaylist.updatePlaylistIndex(video) | ||
435 | |||
436 | if (this.isVideoBlur(this.video)) { | ||
437 | const res = await this.confirmService.confirm( | ||
438 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), | ||
439 | this.i18n('Mature or explicit content') | ||
440 | ) | ||
441 | if (res === false) return this.location.back() | ||
442 | } | ||
443 | |||
444 | // Flush old player if needed | ||
445 | this.flushPlayer() | ||
446 | |||
447 | // Build video element, because videojs removes it on dispose | ||
448 | const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper') | ||
449 | this.playerElement = document.createElement('video') | ||
450 | this.playerElement.className = 'video-js vjs-peertube-skin' | ||
451 | this.playerElement.setAttribute('playsinline', 'true') | ||
452 | playerElementWrapper.appendChild(this.playerElement) | ||
453 | |||
454 | const params = { | ||
455 | video: this.video, | ||
456 | videoCaptions, | ||
457 | urlOptions, | ||
458 | user: this.user | ||
459 | } | ||
460 | const { playerMode, playerOptions } = await this.hooks.wrapFun( | ||
461 | this.buildPlayerManagerOptions.bind(this), | ||
462 | params, | ||
463 | 'video-watch', | ||
464 | 'filter:internal.video-watch.player.build-options.params', | ||
465 | 'filter:internal.video-watch.player.build-options.result' | ||
466 | ) | ||
467 | |||
468 | this.zone.runOutsideAngular(async () => { | ||
469 | this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) | ||
470 | this.player.focus() | ||
471 | |||
472 | this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) | ||
473 | |||
474 | this.player.on('timeupdate', () => { | ||
475 | this.currentTime = Math.floor(this.player.currentTime()) | ||
476 | }) | ||
477 | |||
478 | /** | ||
479 | * replaces this.player.one('ended') | ||
480 | * 'condition()': true to make the upnext functionality trigger, | ||
481 | * false to disable the upnext functionality | ||
482 | * go to the next video in 'condition()' if you don't want of the timer. | ||
483 | * 'next': function triggered at the end of the timer. | ||
484 | * 'suspended': function used at each clic of the timer checking if we need | ||
485 | * to reset progress and wait until 'suspended' becomes truthy again. | ||
486 | */ | ||
487 | this.player.upnext({ | ||
488 | timeout: 10000, // 10s | ||
489 | headText: this.i18n('Up Next'), | ||
490 | cancelText: this.i18n('Cancel'), | ||
491 | suspendedText: this.i18n('Autoplay is suspended'), | ||
492 | getTitle: () => this.nextVideoTitle, | ||
493 | next: () => this.zone.run(() => this.autoplayNext()), | ||
494 | condition: () => { | ||
495 | if (this.playlist) { | ||
496 | if (this.isPlaylistAutoPlayEnabled()) { | ||
497 | // upnext will not trigger, and instead the next video will play immediately | ||
498 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
499 | } | ||
500 | } else if (this.isAutoPlayEnabled()) { | ||
501 | return true // upnext will trigger | ||
502 | } | ||
503 | return false // upnext will not trigger, and instead leave the video stopping | ||
504 | }, | ||
505 | suspended: () => { | ||
506 | return ( | ||
507 | !isXPercentInViewport(this.player.el(), 80) || | ||
508 | !document.getElementById('content').contains(document.activeElement) | ||
509 | ) | ||
510 | } | ||
511 | }) | ||
512 | |||
513 | this.player.one('stopped', () => { | ||
514 | if (this.playlist) { | ||
515 | if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
516 | } | ||
517 | }) | ||
518 | |||
519 | this.player.on('theaterChange', (_: any, enabled: boolean) => { | ||
520 | this.zone.run(() => this.theaterEnabled = enabled) | ||
521 | }) | ||
522 | |||
523 | this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { player: this.player }) | ||
524 | }) | ||
525 | |||
526 | this.setVideoDescriptionHTML() | ||
527 | this.setVideoLikesBarTooltipText() | ||
528 | |||
529 | this.setOpenGraphTags() | ||
530 | this.checkUserRating() | ||
531 | |||
532 | this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', { videojs }) | ||
533 | } | ||
534 | |||
535 | private autoplayNext () { | ||
536 | if (this.playlist) { | ||
537 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
538 | } else if (this.nextVideoUuid) { | ||
539 | this.router.navigate([ '/videos/watch', this.nextVideoUuid ]) | ||
540 | } | ||
541 | } | ||
542 | |||
543 | private setRating (nextRating: UserVideoRateType) { | ||
544 | const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable<any> } = { | ||
545 | like: this.videoService.setVideoLike, | ||
546 | dislike: this.videoService.setVideoDislike, | ||
547 | none: this.videoService.unsetVideoLike | ||
548 | } | ||
549 | |||
550 | ratingMethods[nextRating].call(this.videoService, this.video.id) | ||
551 | .subscribe( | ||
552 | () => { | ||
553 | // Update the video like attribute | ||
554 | this.updateVideoRating(this.userRating, nextRating) | ||
555 | this.userRating = nextRating | ||
556 | }, | ||
557 | |||
558 | (err: { message: string }) => this.notifier.error(err.message) | ||
559 | ) | ||
560 | } | ||
561 | |||
562 | private updateVideoRating (oldRating: UserVideoRateType, newRating: UserVideoRateType) { | ||
563 | let likesToIncrement = 0 | ||
564 | let dislikesToIncrement = 0 | ||
565 | |||
566 | if (oldRating) { | ||
567 | if (oldRating === 'like') likesToIncrement-- | ||
568 | if (oldRating === 'dislike') dislikesToIncrement-- | ||
569 | } | ||
570 | |||
571 | if (newRating === 'like') likesToIncrement++ | ||
572 | if (newRating === 'dislike') dislikesToIncrement++ | ||
573 | |||
574 | this.video.likes += likesToIncrement | ||
575 | this.video.dislikes += dislikesToIncrement | ||
576 | |||
577 | this.video.buildLikeAndDislikePercents() | ||
578 | this.setVideoLikesBarTooltipText() | ||
579 | } | ||
580 | |||
581 | private setOpenGraphTags () { | ||
582 | this.metaService.setTitle(this.video.name) | ||
583 | |||
584 | this.metaService.setTag('og:type', 'video') | ||
585 | |||
586 | this.metaService.setTag('og:title', this.video.name) | ||
587 | this.metaService.setTag('name', this.video.name) | ||
588 | |||
589 | this.metaService.setTag('og:description', this.video.description) | ||
590 | this.metaService.setTag('description', this.video.description) | ||
591 | |||
592 | this.metaService.setTag('og:image', this.video.previewPath) | ||
593 | |||
594 | this.metaService.setTag('og:duration', this.video.duration.toString()) | ||
595 | |||
596 | this.metaService.setTag('og:site_name', 'PeerTube') | ||
597 | |||
598 | this.metaService.setTag('og:url', window.location.href) | ||
599 | this.metaService.setTag('url', window.location.href) | ||
600 | } | ||
601 | |||
602 | private isAutoplay () { | ||
603 | // We'll jump to the thread id, so do not play the video | ||
604 | if (this.route.snapshot.params['threadId']) return false | ||
605 | |||
606 | // Otherwise true by default | ||
607 | if (!this.user) return true | ||
608 | |||
609 | // Be sure the autoPlay is set to false | ||
610 | return this.user.autoPlayVideo !== false | ||
611 | } | ||
612 | |||
613 | private flushPlayer () { | ||
614 | // Remove player if it exists | ||
615 | if (this.player) { | ||
616 | try { | ||
617 | this.player.dispose() | ||
618 | this.player = undefined | ||
619 | } catch (err) { | ||
620 | console.error('Cannot dispose player.', err) | ||
621 | } | ||
622 | } | ||
623 | } | ||
624 | |||
625 | private buildPlayerManagerOptions (params: { | ||
626 | video: VideoDetails, | ||
627 | videoCaptions: VideoCaption[], | ||
628 | urlOptions: CustomizationOptions & { playerMode: PlayerMode }, | ||
629 | user?: AuthUser | ||
630 | }) { | ||
631 | const { video, videoCaptions, urlOptions, user } = params | ||
632 | const getStartTime = () => { | ||
633 | const byUrl = urlOptions.startTime !== undefined | ||
634 | const byHistory = video.userHistory && (!this.playlist || urlOptions.resume !== undefined) | ||
635 | |||
636 | if (byUrl) { | ||
637 | return timeToInt(urlOptions.startTime) | ||
638 | } else if (byHistory) { | ||
639 | return video.userHistory.currentTime | ||
640 | } else { | ||
641 | return 0 | ||
642 | } | ||
643 | } | ||
644 | |||
645 | let startTime = getStartTime() | ||
646 | // If we are at the end of the video, reset the timer | ||
647 | if (video.duration - startTime <= 1) startTime = 0 | ||
648 | |||
649 | const playerCaptions = videoCaptions.map(c => ({ | ||
650 | label: c.language.label, | ||
651 | language: c.language.id, | ||
652 | src: environment.apiUrl + c.captionPath | ||
653 | })) | ||
654 | |||
655 | const options: PeertubePlayerManagerOptions = { | ||
656 | common: { | ||
657 | autoplay: this.isAutoplay(), | ||
658 | nextVideo: () => this.zone.run(() => this.autoplayNext()), | ||
659 | |||
660 | playerElement: this.playerElement, | ||
661 | onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element, | ||
662 | |||
663 | videoDuration: video.duration, | ||
664 | enableHotkeys: true, | ||
665 | inactivityTimeout: 2500, | ||
666 | poster: video.previewUrl, | ||
667 | |||
668 | startTime, | ||
669 | stopTime: urlOptions.stopTime, | ||
670 | controls: urlOptions.controls, | ||
671 | muted: urlOptions.muted, | ||
672 | loop: urlOptions.loop, | ||
673 | subtitle: urlOptions.subtitle, | ||
674 | |||
675 | peertubeLink: urlOptions.peertubeLink, | ||
676 | |||
677 | theaterButton: true, | ||
678 | captions: videoCaptions.length !== 0, | ||
679 | |||
680 | videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE | ||
681 | ? this.videoService.getVideoViewUrl(video.uuid) | ||
682 | : null, | ||
683 | embedUrl: video.embedUrl, | ||
684 | |||
685 | language: this.localeId, | ||
686 | |||
687 | userWatching: user && user.videosHistoryEnabled === true ? { | ||
688 | url: this.videoService.getUserWatchingVideoUrl(video.uuid), | ||
689 | authorizationHeader: this.authService.getRequestHeaderValue() | ||
690 | } : undefined, | ||
691 | |||
692 | serverUrl: environment.apiUrl, | ||
693 | |||
694 | videoCaptions: playerCaptions | ||
695 | }, | ||
696 | |||
697 | webtorrent: { | ||
698 | videoFiles: video.files | ||
699 | } | ||
700 | } | ||
701 | |||
702 | let mode: PlayerMode | ||
703 | |||
704 | if (urlOptions.playerMode) { | ||
705 | if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' | ||
706 | else mode = 'webtorrent' | ||
707 | } else { | ||
708 | if (video.hasHlsPlaylist()) mode = 'p2p-media-loader' | ||
709 | else mode = 'webtorrent' | ||
710 | } | ||
711 | |||
712 | // p2p-media-loader needs TextEncoder, try to fallback on WebTorrent | ||
713 | if (typeof TextEncoder === 'undefined') { | ||
714 | mode = 'webtorrent' | ||
715 | } | ||
716 | |||
717 | if (mode === 'p2p-media-loader') { | ||
718 | const hlsPlaylist = video.getHlsPlaylist() | ||
719 | |||
720 | const p2pMediaLoader = { | ||
721 | playlistUrl: hlsPlaylist.playlistUrl, | ||
722 | segmentsSha256Url: hlsPlaylist.segmentsSha256Url, | ||
723 | redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), | ||
724 | trackerAnnounce: video.trackerUrls, | ||
725 | videoFiles: hlsPlaylist.files | ||
726 | } as P2PMediaLoaderOptions | ||
727 | |||
728 | Object.assign(options, { p2pMediaLoader }) | ||
729 | } | ||
730 | |||
731 | return { playerMode: mode, playerOptions: options } | ||
732 | } | ||
733 | |||
734 | private pausePlayer () { | ||
735 | if (!this.player) return | ||
736 | |||
737 | this.player.pause() | ||
738 | } | ||
739 | |||
740 | private initHotkeys () { | ||
741 | this.hotkeys = [ | ||
742 | // These hotkeys are managed by the player | ||
743 | new Hotkey('f', e => e, undefined, this.i18n('Enter/exit fullscreen (requires player focus)')), | ||
744 | new Hotkey('space', e => e, undefined, this.i18n('Play/Pause the video (requires player focus)')), | ||
745 | new Hotkey('m', e => e, undefined, this.i18n('Mute/unmute the video (requires player focus)')), | ||
746 | |||
747 | new Hotkey('0-9', e => e, undefined, this.i18n('Skip to a percentage of the video: 0 is 0% and 9 is 90% (requires player focus)')), | ||
748 | |||
749 | new Hotkey('up', e => e, undefined, this.i18n('Increase the volume (requires player focus)')), | ||
750 | new Hotkey('down', e => e, undefined, this.i18n('Decrease the volume (requires player focus)')), | ||
751 | |||
752 | new Hotkey('right', e => e, undefined, this.i18n('Seek the video forward (requires player focus)')), | ||
753 | new Hotkey('left', e => e, undefined, this.i18n('Seek the video backward (requires player focus)')), | ||
754 | |||
755 | new Hotkey('>', e => e, undefined, this.i18n('Increase playback rate (requires player focus)')), | ||
756 | new Hotkey('<', e => e, undefined, this.i18n('Decrease playback rate (requires player focus)')), | ||
757 | |||
758 | new Hotkey('.', e => e, undefined, this.i18n('Navigate in the video frame by frame (requires player focus)')) | ||
759 | ] | ||
760 | |||
761 | if (this.isUserLoggedIn()) { | ||
762 | this.hotkeys = this.hotkeys.concat([ | ||
763 | new Hotkey('shift+l', () => { | ||
764 | this.setLike() | ||
765 | return false | ||
766 | }, undefined, this.i18n('Like the video')), | ||
767 | |||
768 | new Hotkey('shift+d', () => { | ||
769 | this.setDislike() | ||
770 | return false | ||
771 | }, undefined, this.i18n('Dislike the video')), | ||
772 | |||
773 | new Hotkey('shift+s', () => { | ||
774 | this.subscribeButton.subscribed ? this.subscribeButton.unsubscribe() : this.subscribeButton.subscribe() | ||
775 | return false | ||
776 | }, undefined, this.i18n('Subscribe to the account')) | ||
777 | ]) | ||
778 | } | ||
779 | |||
780 | this.hotkeysService.add(this.hotkeys) | ||
781 | } | ||
782 | } | ||
diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts new file mode 100644 index 000000000..421170d81 --- /dev/null +++ b/client/src/app/+videos/+video-watch/video-watch.module.ts | |||
@@ -0,0 +1,65 @@ | |||
1 | import { QRCodeModule } from 'angularx-qrcode' | ||
2 | import { NgModule } from '@angular/core' | ||
3 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
4 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | ||
5 | import { SharedMainModule } from '@app/shared/shared-main' | ||
6 | import { SharedModerationModule } from '@app/shared/shared-moderation' | ||
7 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' | ||
8 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | ||
9 | import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' | ||
10 | import { RecommendationsModule } from './recommendations/recommendations.module' | ||
11 | import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | ||
12 | import { VideoCommentAddComponent } from './comment/video-comment-add.component' | ||
13 | import { VideoCommentComponent } from './comment/video-comment.component' | ||
14 | import { VideoCommentService } from './comment/video-comment.service' | ||
15 | import { VideoCommentsComponent } from './comment/video-comments.component' | ||
16 | import { VideoShareComponent } from './modal/video-share.component' | ||
17 | import { VideoSupportComponent } from './modal/video-support.component' | ||
18 | import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' | ||
19 | import { VideoDurationPipe } from './video-duration-formatter.pipe' | ||
20 | import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' | ||
21 | import { VideoWatchRoutingModule } from './video-watch-routing.module' | ||
22 | import { VideoWatchComponent } from './video-watch.component' | ||
23 | |||
24 | @NgModule({ | ||
25 | imports: [ | ||
26 | VideoWatchRoutingModule, | ||
27 | NgbTooltipModule, | ||
28 | QRCodeModule, | ||
29 | RecommendationsModule, | ||
30 | |||
31 | SharedMainModule, | ||
32 | SharedFormModule, | ||
33 | SharedVideoMiniatureModule, | ||
34 | SharedVideoPlaylistModule, | ||
35 | SharedUserSubscriptionModule, | ||
36 | SharedModerationModule, | ||
37 | SharedGlobalIconModule | ||
38 | ], | ||
39 | |||
40 | declarations: [ | ||
41 | VideoWatchComponent, | ||
42 | VideoWatchPlaylistComponent, | ||
43 | |||
44 | VideoShareComponent, | ||
45 | VideoSupportComponent, | ||
46 | VideoCommentsComponent, | ||
47 | VideoCommentAddComponent, | ||
48 | VideoCommentComponent, | ||
49 | |||
50 | TimestampRouteTransformerDirective, | ||
51 | VideoDurationPipe, | ||
52 | TimestampRouteTransformerDirective | ||
53 | ], | ||
54 | |||
55 | exports: [ | ||
56 | VideoWatchComponent, | ||
57 | |||
58 | TimestampRouteTransformerDirective | ||
59 | ], | ||
60 | |||
61 | providers: [ | ||
62 | VideoCommentService | ||
63 | ] | ||
64 | }) | ||
65 | export class VideoWatchModule { } | ||
diff --git a/client/src/app/+videos/index.ts b/client/src/app/+videos/index.ts new file mode 100644 index 000000000..028a5854b --- /dev/null +++ b/client/src/app/+videos/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './videos.module' | |||
diff --git a/client/src/app/+videos/video-list/index.ts b/client/src/app/+videos/video-list/index.ts new file mode 100644 index 000000000..af1bd58b7 --- /dev/null +++ b/client/src/app/+videos/video-list/index.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export * from './overview' | ||
2 | export * from './video-local.component' | ||
3 | export * from './video-recently-added.component' | ||
4 | export * from './video-trending.component' | ||
5 | export * from './video-most-liked.component' | ||
diff --git a/client/src/app/+videos/video-list/overview/index.ts b/client/src/app/+videos/video-list/overview/index.ts new file mode 100644 index 000000000..e6cfa4802 --- /dev/null +++ b/client/src/app/+videos/video-list/overview/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './overview.service' | ||
2 | export * from './video-overview.component' | ||
3 | export * from './videos-overview.model' | ||
diff --git a/client/src/app/+videos/video-list/overview/overview.service.ts b/client/src/app/+videos/video-list/overview/overview.service.ts new file mode 100644 index 000000000..4458454d5 --- /dev/null +++ b/client/src/app/+videos/video-list/overview/overview.service.ts | |||
@@ -0,0 +1,78 @@ | |||
1 | import { forkJoin, Observable, of } from 'rxjs' | ||
2 | import { catchError, map, switchMap, tap } from 'rxjs/operators' | ||
3 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { RestExtractor, ServerService } from '@app/core' | ||
6 | import { immutableAssign } from '@app/helpers' | ||
7 | import { VideoService } from '@app/shared/shared-main' | ||
8 | import { peertubeTranslate, VideosOverview as VideosOverviewServer } from '@shared/models' | ||
9 | import { environment } from '../../../../environments/environment' | ||
10 | import { VideosOverview } from './videos-overview.model' | ||
11 | |||
12 | @Injectable() | ||
13 | export class OverviewService { | ||
14 | static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/' | ||
15 | |||
16 | constructor ( | ||
17 | private authHttp: HttpClient, | ||
18 | private restExtractor: RestExtractor, | ||
19 | private videosService: VideoService, | ||
20 | private serverService: ServerService | ||
21 | ) {} | ||
22 | |||
23 | getVideosOverview (page: number): Observable<VideosOverview> { | ||
24 | let params = new HttpParams() | ||
25 | params = params.append('page', page + '') | ||
26 | |||
27 | return this.authHttp | ||
28 | .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params }) | ||
29 | .pipe( | ||
30 | switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), | ||
31 | catchError(err => this.restExtractor.handleError(err)) | ||
32 | ) | ||
33 | } | ||
34 | |||
35 | private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> { | ||
36 | const observables: Observable<any>[] = [] | ||
37 | const videosOverviewResult: VideosOverview = { | ||
38 | tags: [], | ||
39 | categories: [], | ||
40 | channels: [] | ||
41 | } | ||
42 | |||
43 | // Build videos objects | ||
44 | for (const key of Object.keys(serverVideosOverview)) { | ||
45 | for (const object of serverVideosOverview[ key ]) { | ||
46 | observables.push( | ||
47 | of(object.videos) | ||
48 | .pipe( | ||
49 | switchMap(videos => this.videosService.extractVideos({ total: 0, data: videos })), | ||
50 | map(result => result.data), | ||
51 | tap(videos => { | ||
52 | videosOverviewResult[key].push(immutableAssign(object, { videos })) | ||
53 | }) | ||
54 | ) | ||
55 | ) | ||
56 | } | ||
57 | } | ||
58 | |||
59 | if (observables.length === 0) return of(videosOverviewResult) | ||
60 | |||
61 | return forkJoin(observables) | ||
62 | .pipe( | ||
63 | // Translate categories | ||
64 | switchMap(() => { | ||
65 | return this.serverService.getServerLocale() | ||
66 | .pipe( | ||
67 | tap(translations => { | ||
68 | for (const c of videosOverviewResult.categories) { | ||
69 | c.category.label = peertubeTranslate(c.category.label, translations) | ||
70 | } | ||
71 | }) | ||
72 | ) | ||
73 | }), | ||
74 | map(() => videosOverviewResult) | ||
75 | ) | ||
76 | } | ||
77 | |||
78 | } | ||
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.html b/client/src/app/+videos/video-list/overview/video-overview.component.html new file mode 100644 index 000000000..ca986c634 --- /dev/null +++ b/client/src/app/+videos/video-list/overview/video-overview.component.html | |||
@@ -0,0 +1,52 @@ | |||
1 | <h1 class="sr-only" i18n>Discover</h1> | ||
2 | <div class="margin-content"> | ||
3 | |||
4 | <div class="no-results" i18n *ngIf="notResults">No results.</div> | ||
5 | |||
6 | <div | ||
7 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" | ||
8 | > | ||
9 | <ng-container *ngFor="let overview of overviews"> | ||
10 | |||
11 | <div class="section videos" *ngFor="let object of overview.categories"> | ||
12 | <h1 class="section-title"> | ||
13 | <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> | ||
14 | </h1> | ||
15 | |||
16 | <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)"> | ||
17 | <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true"> | ||
18 | </my-video-miniature> | ||
19 | </div> | ||
20 | </div> | ||
21 | |||
22 | <div class="section videos" *ngFor="let object of overview.tags"> | ||
23 | <h2 class="section-title"> | ||
24 | <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> | ||
25 | </h2> | ||
26 | |||
27 | <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)"> | ||
28 | <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true"> | ||
29 | </my-video-miniature> | ||
30 | </div> | ||
31 | </div> | ||
32 | |||
33 | <div class="section channel videos" *ngFor="let object of overview.channels"> | ||
34 | <div class="section-title"> | ||
35 | <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]"> | ||
36 | <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" /> | ||
37 | |||
38 | <h2 class="section-title">{{ object.channel.displayName }}</h2> | ||
39 | </a> | ||
40 | </div> | ||
41 | |||
42 | <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)"> | ||
43 | <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true"> | ||
44 | </my-video-miniature> | ||
45 | </div> | ||
46 | </div> | ||
47 | |||
48 | </ng-container> | ||
49 | |||
50 | </div> | ||
51 | |||
52 | </div> | ||
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.scss b/client/src/app/+videos/video-list/overview/video-overview.component.scss new file mode 100644 index 000000000..c1d10188a --- /dev/null +++ b/client/src/app/+videos/video-list/overview/video-overview.component.scss | |||
@@ -0,0 +1,16 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | @import '_miniature'; | ||
4 | |||
5 | .section-title { | ||
6 | // make the element span a full grid row within .videos grid | ||
7 | grid-column: 1 / -1; | ||
8 | } | ||
9 | |||
10 | .margin-content { | ||
11 | @include fluid-videos-miniature-layout; | ||
12 | } | ||
13 | |||
14 | .section { | ||
15 | @include miniature-rows; | ||
16 | } | ||
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.ts b/client/src/app/+videos/video-list/overview/video-overview.component.ts new file mode 100644 index 000000000..b3be1d7b5 --- /dev/null +++ b/client/src/app/+videos/video-list/overview/video-overview.component.ts | |||
@@ -0,0 +1,94 @@ | |||
1 | import { Subject } from 'rxjs' | ||
2 | import { Component, OnInit } from '@angular/core' | ||
3 | import { Notifier, ScreenService, User, UserService } from '@app/core' | ||
4 | import { Video } from '@app/shared/shared-main' | ||
5 | import { OverviewService } from './overview.service' | ||
6 | import { VideosOverview } from './videos-overview.model' | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-video-overview', | ||
10 | templateUrl: './video-overview.component.html', | ||
11 | styleUrls: [ './video-overview.component.scss' ] | ||
12 | }) | ||
13 | export class VideoOverviewComponent implements OnInit { | ||
14 | onDataSubject = new Subject<any>() | ||
15 | |||
16 | overviews: VideosOverview[] = [] | ||
17 | notResults = false | ||
18 | |||
19 | userMiniature: User | ||
20 | |||
21 | private loaded = false | ||
22 | private currentPage = 1 | ||
23 | private maxPage = 20 | ||
24 | private lastWasEmpty = false | ||
25 | private isLoading = false | ||
26 | |||
27 | constructor ( | ||
28 | private notifier: Notifier, | ||
29 | private userService: UserService, | ||
30 | private overviewService: OverviewService, | ||
31 | private screenService: ScreenService | ||
32 | ) { } | ||
33 | |||
34 | ngOnInit () { | ||
35 | this.loadMoreResults() | ||
36 | |||
37 | this.userService.getAnonymousOrLoggedUser() | ||
38 | .subscribe(user => this.userMiniature = user) | ||
39 | |||
40 | this.userService.listenAnonymousUpdate() | ||
41 | .subscribe(user => this.userMiniature = user) | ||
42 | } | ||
43 | |||
44 | buildVideoChannelBy (object: { videos: Video[] }) { | ||
45 | return object.videos[0].byVideoChannel | ||
46 | } | ||
47 | |||
48 | buildVideoChannelAvatarUrl (object: { videos: Video[] }) { | ||
49 | return object.videos[0].videoChannelAvatarUrl | ||
50 | } | ||
51 | |||
52 | buildVideos (videos: Video[]) { | ||
53 | const numberOfVideos = this.screenService.getNumberOfAvailableMiniatures() | ||
54 | |||
55 | return videos.slice(0, numberOfVideos * 2) | ||
56 | } | ||
57 | |||
58 | onNearOfBottom () { | ||
59 | if (this.currentPage >= this.maxPage) return | ||
60 | if (this.lastWasEmpty) return | ||
61 | if (this.isLoading) return | ||
62 | |||
63 | this.currentPage++ | ||
64 | this.loadMoreResults() | ||
65 | } | ||
66 | |||
67 | private loadMoreResults () { | ||
68 | this.isLoading = true | ||
69 | |||
70 | this.overviewService.getVideosOverview(this.currentPage) | ||
71 | .subscribe( | ||
72 | overview => { | ||
73 | this.isLoading = false | ||
74 | |||
75 | if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) { | ||
76 | this.lastWasEmpty = true | ||
77 | if (this.loaded === false) this.notResults = true | ||
78 | |||
79 | return | ||
80 | } | ||
81 | |||
82 | this.loaded = true | ||
83 | this.onDataSubject.next(overview) | ||
84 | |||
85 | this.overviews.push(overview) | ||
86 | }, | ||
87 | |||
88 | err => { | ||
89 | this.notifier.error(err.message) | ||
90 | this.isLoading = false | ||
91 | } | ||
92 | ) | ||
93 | } | ||
94 | } | ||
diff --git a/client/src/app/+videos/video-list/overview/videos-overview.model.ts b/client/src/app/+videos/video-list/overview/videos-overview.model.ts new file mode 100644 index 000000000..6765ad9b7 --- /dev/null +++ b/client/src/app/+videos/video-list/overview/videos-overview.model.ts | |||
@@ -0,0 +1,20 @@ | |||
1 | import { Video } from '@app/shared/shared-main' | ||
2 | import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '@shared/models' | ||
3 | |||
4 | export class VideosOverview implements VideosOverviewServer { | ||
5 | channels: { | ||
6 | channel: VideoChannelSummary | ||
7 | videos: Video[] | ||
8 | }[] | ||
9 | |||
10 | categories: { | ||
11 | category: VideoConstant<number> | ||
12 | videos: Video[] | ||
13 | }[] | ||
14 | |||
15 | tags: { | ||
16 | tag: string | ||
17 | videos: Video[] | ||
18 | }[] | ||
19 | [key: string]: any | ||
20 | } | ||
diff --git a/client/src/app/+videos/video-list/video-local.component.ts b/client/src/app/+videos/video-list/video-local.component.ts new file mode 100644 index 000000000..b4c71ac49 --- /dev/null +++ b/client/src/app/+videos/video-list/video-local.component.ts | |||
@@ -0,0 +1,86 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
9 | import { UserRight, VideoFilter, VideoSortField } from '@shared/models' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-videos-local', | ||
13 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
14 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
15 | }) | ||
16 | export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
17 | titlePage: string | ||
18 | sort = '-publishedAt' as VideoSortField | ||
19 | filter: VideoFilter = 'local' | ||
20 | |||
21 | useUserVideoPreferences = true | ||
22 | |||
23 | constructor ( | ||
24 | protected i18n: I18n, | ||
25 | protected router: Router, | ||
26 | protected serverService: ServerService, | ||
27 | protected route: ActivatedRoute, | ||
28 | protected notifier: Notifier, | ||
29 | protected authService: AuthService, | ||
30 | protected userService: UserService, | ||
31 | protected screenService: ScreenService, | ||
32 | protected storageService: LocalStorageService, | ||
33 | private videoService: VideoService, | ||
34 | private hooks: HooksService | ||
35 | ) { | ||
36 | super() | ||
37 | |||
38 | this.titlePage = i18n('Local videos') | ||
39 | } | ||
40 | |||
41 | ngOnInit () { | ||
42 | super.ngOnInit() | ||
43 | |||
44 | if (this.authService.isLoggedIn()) { | ||
45 | const user = this.authService.getUser() | ||
46 | this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) | ||
47 | } | ||
48 | |||
49 | this.generateSyndicationList() | ||
50 | } | ||
51 | |||
52 | ngOnDestroy () { | ||
53 | super.ngOnDestroy() | ||
54 | } | ||
55 | |||
56 | getVideosObservable (page: number) { | ||
57 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
58 | const params = { | ||
59 | videoPagination: newPagination, | ||
60 | sort: this.sort, | ||
61 | filter: this.filter, | ||
62 | categoryOneOf: this.categoryOneOf, | ||
63 | languageOneOf: this.languageOneOf, | ||
64 | nsfwPolicy: this.nsfwPolicy, | ||
65 | skipCount: true | ||
66 | } | ||
67 | |||
68 | return this.hooks.wrapObsFun( | ||
69 | this.videoService.getVideos.bind(this.videoService), | ||
70 | params, | ||
71 | 'common', | ||
72 | 'filter:api.local-videos.videos.list.params', | ||
73 | 'filter:api.local-videos.videos.list.result' | ||
74 | ) | ||
75 | } | ||
76 | |||
77 | generateSyndicationList () { | ||
78 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf) | ||
79 | } | ||
80 | |||
81 | toggleModerationDisplay () { | ||
82 | this.filter = this.filter === 'local' ? 'all-local' as 'all-local' : 'local' as 'local' | ||
83 | |||
84 | this.reloadVideos() | ||
85 | } | ||
86 | } | ||
diff --git a/client/src/app/+videos/video-list/video-most-liked.component.ts b/client/src/app/+videos/video-list/video-most-liked.component.ts new file mode 100644 index 000000000..ca14851bb --- /dev/null +++ b/client/src/app/+videos/video-list/video-most-liked.component.ts | |||
@@ -0,0 +1,70 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
9 | import { VideoSortField } from '@shared/models' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-videos-most-liked', | ||
13 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
14 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
15 | }) | ||
16 | export class VideoMostLikedComponent extends AbstractVideoList implements OnInit { | ||
17 | titlePage: string | ||
18 | defaultSort: VideoSortField = '-likes' | ||
19 | |||
20 | useUserVideoPreferences = true | ||
21 | |||
22 | constructor ( | ||
23 | protected i18n: I18n, | ||
24 | protected router: Router, | ||
25 | protected serverService: ServerService, | ||
26 | protected route: ActivatedRoute, | ||
27 | protected notifier: Notifier, | ||
28 | protected authService: AuthService, | ||
29 | protected userService: UserService, | ||
30 | protected screenService: ScreenService, | ||
31 | protected storageService: LocalStorageService, | ||
32 | private videoService: VideoService, | ||
33 | private hooks: HooksService | ||
34 | ) { | ||
35 | super() | ||
36 | } | ||
37 | |||
38 | ngOnInit () { | ||
39 | super.ngOnInit() | ||
40 | |||
41 | this.generateSyndicationList() | ||
42 | |||
43 | this.titlePage = this.i18n('Most liked videos') | ||
44 | this.titleTooltip = this.i18n('Videos that have the higher number of likes.') | ||
45 | } | ||
46 | |||
47 | getVideosObservable (page: number) { | ||
48 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
49 | const params = { | ||
50 | videoPagination: newPagination, | ||
51 | sort: this.sort, | ||
52 | categoryOneOf: this.categoryOneOf, | ||
53 | languageOneOf: this.languageOneOf, | ||
54 | nsfwPolicy: this.nsfwPolicy, | ||
55 | skipCount: true | ||
56 | } | ||
57 | |||
58 | return this.hooks.wrapObsFun( | ||
59 | this.videoService.getVideos.bind(this.videoService), | ||
60 | params, | ||
61 | 'common', | ||
62 | 'filter:api.most-liked-videos.videos.list.params', | ||
63 | 'filter:api.most-liked-videos.videos.list.result' | ||
64 | ) | ||
65 | } | ||
66 | |||
67 | generateSyndicationList () { | ||
68 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) | ||
69 | } | ||
70 | } | ||
diff --git a/client/src/app/+videos/video-list/video-recently-added.component.ts b/client/src/app/+videos/video-list/video-recently-added.component.ts new file mode 100644 index 000000000..c9395133f --- /dev/null +++ b/client/src/app/+videos/video-list/video-recently-added.component.ts | |||
@@ -0,0 +1,74 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
9 | import { VideoSortField } from '@shared/models' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-videos-recently-added', | ||
13 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
14 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
15 | }) | ||
16 | export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
17 | titlePage: string | ||
18 | sort: VideoSortField = '-publishedAt' | ||
19 | groupByDate = true | ||
20 | |||
21 | useUserVideoPreferences = true | ||
22 | |||
23 | constructor ( | ||
24 | protected i18n: I18n, | ||
25 | protected route: ActivatedRoute, | ||
26 | protected serverService: ServerService, | ||
27 | protected router: Router, | ||
28 | protected notifier: Notifier, | ||
29 | protected authService: AuthService, | ||
30 | protected userService: UserService, | ||
31 | protected screenService: ScreenService, | ||
32 | protected storageService: LocalStorageService, | ||
33 | private videoService: VideoService, | ||
34 | private hooks: HooksService | ||
35 | ) { | ||
36 | super() | ||
37 | |||
38 | this.titlePage = i18n('Recently added') | ||
39 | } | ||
40 | |||
41 | ngOnInit () { | ||
42 | super.ngOnInit() | ||
43 | |||
44 | this.generateSyndicationList() | ||
45 | } | ||
46 | |||
47 | ngOnDestroy () { | ||
48 | super.ngOnDestroy() | ||
49 | } | ||
50 | |||
51 | getVideosObservable (page: number) { | ||
52 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
53 | const params = { | ||
54 | videoPagination: newPagination, | ||
55 | sort: this.sort, | ||
56 | categoryOneOf: this.categoryOneOf, | ||
57 | languageOneOf: this.languageOneOf, | ||
58 | nsfwPolicy: this.nsfwPolicy, | ||
59 | skipCount: true | ||
60 | } | ||
61 | |||
62 | return this.hooks.wrapObsFun( | ||
63 | this.videoService.getVideos.bind(this.videoService), | ||
64 | params, | ||
65 | 'common', | ||
66 | 'filter:api.recently-added-videos.videos.list.params', | ||
67 | 'filter:api.recently-added-videos.videos.list.result' | ||
68 | ) | ||
69 | } | ||
70 | |||
71 | generateSyndicationList () { | ||
72 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) | ||
73 | } | ||
74 | } | ||
diff --git a/client/src/app/+videos/video-list/video-trending.component.ts b/client/src/app/+videos/video-list/video-trending.component.ts new file mode 100644 index 000000000..10eab18de --- /dev/null +++ b/client/src/app/+videos/video-list/video-trending.component.ts | |||
@@ -0,0 +1,87 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
9 | import { VideoSortField } from '@shared/models' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-videos-trending', | ||
13 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
14 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
15 | }) | ||
16 | export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
17 | titlePage: string | ||
18 | defaultSort: VideoSortField = '-trending' | ||
19 | |||
20 | useUserVideoPreferences = true | ||
21 | |||
22 | constructor ( | ||
23 | protected i18n: I18n, | ||
24 | protected router: Router, | ||
25 | protected serverService: ServerService, | ||
26 | protected route: ActivatedRoute, | ||
27 | protected notifier: Notifier, | ||
28 | protected authService: AuthService, | ||
29 | protected userService: UserService, | ||
30 | protected screenService: ScreenService, | ||
31 | protected storageService: LocalStorageService, | ||
32 | private videoService: VideoService, | ||
33 | private hooks: HooksService | ||
34 | ) { | ||
35 | super() | ||
36 | } | ||
37 | |||
38 | ngOnInit () { | ||
39 | super.ngOnInit() | ||
40 | |||
41 | this.generateSyndicationList() | ||
42 | |||
43 | this.serverService.getConfig().subscribe( | ||
44 | config => { | ||
45 | const trendingDays = config.trending.videos.intervalDays | ||
46 | |||
47 | if (trendingDays === 1) { | ||
48 | this.titlePage = this.i18n('Trending for the last 24 hours') | ||
49 | this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours') | ||
50 | } else { | ||
51 | this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays }) | ||
52 | this.titleTooltip = this.i18n( | ||
53 | 'Trending videos are those totalizing the greatest number of views during the last {{days}} days', | ||
54 | { days: trendingDays } | ||
55 | ) | ||
56 | } | ||
57 | }) | ||
58 | } | ||
59 | |||
60 | ngOnDestroy () { | ||
61 | super.ngOnDestroy() | ||
62 | } | ||
63 | |||
64 | getVideosObservable (page: number) { | ||
65 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
66 | const params = { | ||
67 | videoPagination: newPagination, | ||
68 | sort: this.sort, | ||
69 | categoryOneOf: this.categoryOneOf, | ||
70 | languageOneOf: this.languageOneOf, | ||
71 | nsfwPolicy: this.nsfwPolicy, | ||
72 | skipCount: true | ||
73 | } | ||
74 | |||
75 | return this.hooks.wrapObsFun( | ||
76 | this.videoService.getVideos.bind(this.videoService), | ||
77 | params, | ||
78 | 'common', | ||
79 | 'filter:api.trending-videos.videos.list.params', | ||
80 | 'filter:api.trending-videos.videos.list.result' | ||
81 | ) | ||
82 | } | ||
83 | |||
84 | generateSyndicationList () { | ||
85 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) | ||
86 | } | ||
87 | } | ||
diff --git a/client/src/app/+videos/video-list/video-user-subscriptions.component.ts b/client/src/app/+videos/video-list/video-user-subscriptions.component.ts new file mode 100644 index 000000000..41ad9b277 --- /dev/null +++ b/client/src/app/+videos/video-list/video-user-subscriptions.component.ts | |||
@@ -0,0 +1,75 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { UserSubscriptionService } from '@app/shared/shared-user-subscription' | ||
8 | import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature' | ||
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
10 | import { VideoSortField } from '@shared/models' | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-videos-user-subscriptions', | ||
14 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
15 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
16 | }) | ||
17 | export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
18 | titlePage: string | ||
19 | sort = '-publishedAt' as VideoSortField | ||
20 | ownerDisplayType: OwnerDisplayType = 'auto' | ||
21 | groupByDate = true | ||
22 | |||
23 | constructor ( | ||
24 | protected i18n: I18n, | ||
25 | protected router: Router, | ||
26 | protected serverService: ServerService, | ||
27 | protected route: ActivatedRoute, | ||
28 | protected notifier: Notifier, | ||
29 | protected authService: AuthService, | ||
30 | protected userService: UserService, | ||
31 | protected screenService: ScreenService, | ||
32 | protected storageService: LocalStorageService, | ||
33 | private userSubscription: UserSubscriptionService, | ||
34 | private videoService: VideoService, | ||
35 | private hooks: HooksService | ||
36 | ) { | ||
37 | super() | ||
38 | |||
39 | this.titlePage = i18n('Videos from your subscriptions') | ||
40 | this.actions.push({ | ||
41 | routerLink: '/my-account/subscriptions', | ||
42 | label: i18n('Subscriptions'), | ||
43 | iconName: 'cog' | ||
44 | }) | ||
45 | } | ||
46 | |||
47 | ngOnInit () { | ||
48 | super.ngOnInit() | ||
49 | } | ||
50 | |||
51 | ngOnDestroy () { | ||
52 | super.ngOnDestroy() | ||
53 | } | ||
54 | |||
55 | getVideosObservable (page: number) { | ||
56 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
57 | const params = { | ||
58 | videoPagination: newPagination, | ||
59 | sort: this.sort, | ||
60 | skipCount: true | ||
61 | } | ||
62 | |||
63 | return this.hooks.wrapObsFun( | ||
64 | this.userSubscription.getUserSubscriptionVideos.bind(this.userSubscription), | ||
65 | params, | ||
66 | 'common', | ||
67 | 'filter:api.user-subscriptions-videos.videos.list.params', | ||
68 | 'filter:api.user-subscriptions-videos.videos.list.result' | ||
69 | ) | ||
70 | } | ||
71 | |||
72 | generateSyndicationList () { | ||
73 | // not implemented yet | ||
74 | } | ||
75 | } | ||
diff --git a/client/src/app/+videos/videos-routing.module.ts b/client/src/app/+videos/videos-routing.module.ts new file mode 100644 index 000000000..e0e877fc6 --- /dev/null +++ b/client/src/app/+videos/videos-routing.module.ts | |||
@@ -0,0 +1,125 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { MetaGuard } from '@ngx-meta/core' | ||
4 | import { VideoOverviewComponent } from './video-list/overview/video-overview.component' | ||
5 | import { VideoLocalComponent } from './video-list/video-local.component' | ||
6 | import { VideoMostLikedComponent } from './video-list/video-most-liked.component' | ||
7 | import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' | ||
8 | import { VideoTrendingComponent } from './video-list/video-trending.component' | ||
9 | import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' | ||
10 | import { VideosComponent } from './videos.component' | ||
11 | |||
12 | const videosRoutes: Routes = [ | ||
13 | { | ||
14 | path: '', | ||
15 | component: VideosComponent, | ||
16 | canActivateChild: [ MetaGuard ], | ||
17 | children: [ | ||
18 | { | ||
19 | path: 'overview', | ||
20 | component: VideoOverviewComponent, | ||
21 | data: { | ||
22 | meta: { | ||
23 | title: 'Discover videos' | ||
24 | } | ||
25 | } | ||
26 | }, | ||
27 | { | ||
28 | path: 'trending', | ||
29 | component: VideoTrendingComponent, | ||
30 | data: { | ||
31 | meta: { | ||
32 | title: 'Trending videos' | ||
33 | }, | ||
34 | reuse: { | ||
35 | enabled: true, | ||
36 | key: 'trending-videos-list' | ||
37 | } | ||
38 | } | ||
39 | }, | ||
40 | { | ||
41 | path: 'most-liked', | ||
42 | component: VideoMostLikedComponent, | ||
43 | data: { | ||
44 | meta: { | ||
45 | title: 'Most liked videos' | ||
46 | }, | ||
47 | reuse: { | ||
48 | enabled: true, | ||
49 | key: 'most-liked-videos-list' | ||
50 | } | ||
51 | } | ||
52 | }, | ||
53 | { | ||
54 | path: 'recently-added', | ||
55 | component: VideoRecentlyAddedComponent, | ||
56 | data: { | ||
57 | meta: { | ||
58 | title: 'Recently added videos' | ||
59 | }, | ||
60 | reuse: { | ||
61 | enabled: true, | ||
62 | key: 'recently-added-videos-list' | ||
63 | } | ||
64 | } | ||
65 | }, | ||
66 | { | ||
67 | path: 'subscriptions', | ||
68 | component: VideoUserSubscriptionsComponent, | ||
69 | data: { | ||
70 | meta: { | ||
71 | title: 'Subscriptions' | ||
72 | }, | ||
73 | reuse: { | ||
74 | enabled: true, | ||
75 | key: 'subscription-videos-list' | ||
76 | } | ||
77 | } | ||
78 | }, | ||
79 | { | ||
80 | path: 'local', | ||
81 | component: VideoLocalComponent, | ||
82 | data: { | ||
83 | meta: { | ||
84 | title: 'Local videos' | ||
85 | }, | ||
86 | reuse: { | ||
87 | enabled: true, | ||
88 | key: 'local-videos-list' | ||
89 | } | ||
90 | } | ||
91 | }, | ||
92 | { | ||
93 | path: 'upload', | ||
94 | loadChildren: () => import('@app/+videos/+video-edit/video-add.module').then(m => m.VideoAddModule), | ||
95 | data: { | ||
96 | meta: { | ||
97 | title: 'Upload a video' | ||
98 | } | ||
99 | } | ||
100 | }, | ||
101 | { | ||
102 | path: 'update/:uuid', | ||
103 | loadChildren: () => import('@app/+videos/+video-edit/video-update.module').then(m => m.VideoUpdateModule), | ||
104 | data: { | ||
105 | meta: { | ||
106 | title: 'Edit a video' | ||
107 | } | ||
108 | } | ||
109 | }, | ||
110 | { | ||
111 | path: 'watch', | ||
112 | loadChildren: () => import('@app/+videos/+video-watch/video-watch.module').then(m => m.VideoWatchModule), | ||
113 | data: { | ||
114 | preload: 3000 | ||
115 | } | ||
116 | } | ||
117 | ] | ||
118 | } | ||
119 | ] | ||
120 | |||
121 | @NgModule({ | ||
122 | imports: [ RouterModule.forChild(videosRoutes) ], | ||
123 | exports: [ RouterModule ] | ||
124 | }) | ||
125 | export class VideosRoutingModule {} | ||
diff --git a/client/src/app/+videos/videos.component.ts b/client/src/app/+videos/videos.component.ts new file mode 100644 index 000000000..585a3ad9a --- /dev/null +++ b/client/src/app/+videos/videos.component.ts | |||
@@ -0,0 +1,6 @@ | |||
1 | import { Component } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | template: '<router-outlet></router-outlet>' | ||
5 | }) | ||
6 | export class VideosComponent {} | ||
diff --git a/client/src/app/+videos/videos.module.ts b/client/src/app/+videos/videos.module.ts new file mode 100644 index 000000000..1cf68bf83 --- /dev/null +++ b/client/src/app/+videos/videos.module.ts | |||
@@ -0,0 +1,47 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
3 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | ||
4 | import { SharedMainModule } from '@app/shared/shared-main' | ||
5 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' | ||
6 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | ||
7 | import { OverviewService } from './video-list' | ||
8 | import { VideoOverviewComponent } from './video-list/overview/video-overview.component' | ||
9 | import { VideoLocalComponent } from './video-list/video-local.component' | ||
10 | import { VideoMostLikedComponent } from './video-list/video-most-liked.component' | ||
11 | import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' | ||
12 | import { VideoTrendingComponent } from './video-list/video-trending.component' | ||
13 | import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' | ||
14 | import { VideosRoutingModule } from './videos-routing.module' | ||
15 | import { VideosComponent } from './videos.component' | ||
16 | |||
17 | @NgModule({ | ||
18 | imports: [ | ||
19 | VideosRoutingModule, | ||
20 | |||
21 | SharedMainModule, | ||
22 | SharedFormModule, | ||
23 | SharedVideoMiniatureModule, | ||
24 | SharedUserSubscriptionModule, | ||
25 | SharedGlobalIconModule | ||
26 | ], | ||
27 | |||
28 | declarations: [ | ||
29 | VideosComponent, | ||
30 | |||
31 | VideoTrendingComponent, | ||
32 | VideoMostLikedComponent, | ||
33 | VideoRecentlyAddedComponent, | ||
34 | VideoLocalComponent, | ||
35 | VideoUserSubscriptionsComponent, | ||
36 | VideoOverviewComponent | ||
37 | ], | ||
38 | |||
39 | exports: [ | ||
40 | VideosComponent | ||
41 | ], | ||
42 | |||
43 | providers: [ | ||
44 | OverviewService | ||
45 | ] | ||
46 | }) | ||
47 | export class VideosModule { } | ||