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