From 1942f11d5ee6926ad93dc1b79fae18325ba5de18 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 23 Jun 2020 14:49:20 +0200 Subject: Lazy load all routes --- .../shared/i18n-primeng-calendar.service.ts | 94 +++ .../shared/video-caption-add-modal.component.html | 47 ++ .../shared/video-caption-add-modal.component.scss | 20 + .../shared/video-caption-add-modal.component.ts | 85 +++ .../+video-edit/shared/video-edit.component.html | 280 ++++++++ .../+video-edit/shared/video-edit.component.scss | 197 ++++++ .../+video-edit/shared/video-edit.component.ts | 274 ++++++++ .../+video-edit/shared/video-edit.module.ts | 38 + .../video-add-components/drag-drop.directive.ts | 30 + .../video-import-torrent.component.html | 76 ++ .../video-import-torrent.component.scss | 18 + .../video-import-torrent.component.ts | 147 ++++ .../video-import-url.component.html | 72 ++ .../video-import-url.component.ts | 178 +++++ .../video-add-components/video-send.scss | 46 ++ .../+video-edit/video-add-components/video-send.ts | 71 ++ .../video-upload.component.html | 90 +++ .../video-upload.component.scss | 49 ++ .../video-add-components/video-upload.component.ts | 306 ++++++++ .../+video-edit/video-add-routing.module.ts | 20 + .../+videos/+video-edit/video-add.component.html | 46 ++ .../+videos/+video-edit/video-add.component.scss | 89 +++ .../app/+videos/+video-edit/video-add.component.ts | 77 ++ .../app/+videos/+video-edit/video-add.module.ts | 32 + .../+video-edit/video-update-routing.module.ts | 24 + .../+video-edit/video-update.component.html | 22 + .../+videos/+video-edit/video-update.component.ts | 155 ++++ .../app/+videos/+video-edit/video-update.module.ts | 26 + .../+videos/+video-edit/video-update.resolver.ts | 44 ++ .../comment/video-comment-add.component.html | 56 ++ .../comment/video-comment-add.component.scss | 82 +++ .../comment/video-comment-add.component.ts | 149 ++++ .../comment/video-comment-thread-tree.model.ts | 7 + .../comment/video-comment.component.html | 95 +++ .../comment/video-comment.component.scss | 189 +++++ .../comment/video-comment.component.ts | 131 ++++ .../+video-watch/comment/video-comment.model.ts | 48 ++ .../+video-watch/comment/video-comment.service.ts | 149 ++++ .../comment/video-comments.component.html | 98 +++ .../comment/video-comments.component.scss | 53 ++ .../comment/video-comments.component.ts | 232 ++++++ .../+video-watch/modal/video-share.component.html | 187 +++++ .../+video-watch/modal/video-share.component.scss | 79 +++ .../+video-watch/modal/video-share.component.ts | 126 ++++ .../modal/video-support.component.html | 15 + .../modal/video-support.component.scss | 3 + .../+video-watch/modal/video-support.component.ts | 29 + .../recent-videos-recommendation.service.ts | 81 +++ .../recommendations/recommendation-info.model.ts | 4 + .../recommendations/recommendations.module.ts | 34 + .../recommendations/recommendations.service.ts | 7 + .../recommended-videos.component.html | 24 + .../recommended-videos.component.scss | 31 + .../recommended-videos.component.ts | 91 +++ .../recommendations/recommended-videos.store.ts | 37 + .../timestamp-route-transformer.directive.ts | 39 + .../+video-watch/video-duration-formatter.pipe.ts | 28 + .../video-watch-playlist.component.html | 46 ++ .../video-watch-playlist.component.scss | 83 +++ .../+video-watch/video-watch-playlist.component.ts | 201 ++++++ .../+video-watch/video-watch-routing.module.ts | 27 + .../+video-watch/video-watch.component.html | 277 ++++++++ .../+video-watch/video-watch.component.scss | 607 ++++++++++++++++ .../+videos/+video-watch/video-watch.component.ts | 782 +++++++++++++++++++++ .../app/+videos/+video-watch/video-watch.module.ts | 65 ++ client/src/app/+videos/index.ts | 1 + client/src/app/+videos/video-list/index.ts | 5 + .../src/app/+videos/video-list/overview/index.ts | 3 + .../video-list/overview/overview.service.ts | 78 ++ .../overview/video-overview.component.html | 52 ++ .../overview/video-overview.component.scss | 16 + .../overview/video-overview.component.ts | 94 +++ .../video-list/overview/videos-overview.model.ts | 20 + .../+videos/video-list/video-local.component.ts | 86 +++ .../video-list/video-most-liked.component.ts | 70 ++ .../video-list/video-recently-added.component.ts | 74 ++ .../+videos/video-list/video-trending.component.ts | 87 +++ .../video-user-subscriptions.component.ts | 75 ++ client/src/app/+videos/videos-routing.module.ts | 125 ++++ client/src/app/+videos/videos.component.ts | 6 + client/src/app/+videos/videos.module.ts | 47 ++ 81 files changed, 7684 insertions(+) create mode 100644 client/src/app/+videos/+video-edit/shared/i18n-primeng-calendar.service.ts create mode 100644 client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html create mode 100644 client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss create mode 100644 client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts create mode 100644 client/src/app/+videos/+video-edit/shared/video-edit.component.html create mode 100644 client/src/app/+videos/+video-edit/shared/video-edit.component.scss create mode 100644 client/src/app/+videos/+video-edit/shared/video-edit.component.ts create mode 100644 client/src/app/+videos/+video-edit/shared/video-edit.module.ts create mode 100644 client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.scss create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-send.scss create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-send.ts create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss create mode 100644 client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts create mode 100644 client/src/app/+videos/+video-edit/video-add-routing.module.ts create mode 100644 client/src/app/+videos/+video-edit/video-add.component.html create mode 100644 client/src/app/+videos/+video-edit/video-add.component.scss create mode 100644 client/src/app/+videos/+video-edit/video-add.component.ts create mode 100644 client/src/app/+videos/+video-edit/video-add.module.ts create mode 100644 client/src/app/+videos/+video-edit/video-update-routing.module.ts create mode 100644 client/src/app/+videos/+video-edit/video-update.component.html create mode 100644 client/src/app/+videos/+video-edit/video-update.component.ts create mode 100644 client/src/app/+videos/+video-edit/video-update.module.ts create mode 100644 client/src/app/+videos/+video-edit/video-update.resolver.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.html create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.html create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.scss create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.model.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.service.ts create mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.html create mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.scss create mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.ts create mode 100644 client/src/app/+videos/+video-watch/modal/video-share.component.html create mode 100644 client/src/app/+videos/+video-watch/modal/video-share.component.scss create mode 100644 client/src/app/+videos/+video-watch/modal/video-share.component.ts create mode 100644 client/src/app/+videos/+video-watch/modal/video-support.component.html create mode 100644 client/src/app/+videos/+video-watch/modal/video-support.component.scss create mode 100644 client/src/app/+videos/+video-watch/modal/video-support.component.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts create mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts create mode 100644 client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts create mode 100644 client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.html create mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.scss create mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch-routing.module.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch.component.html create mode 100644 client/src/app/+videos/+video-watch/video-watch.component.scss create mode 100644 client/src/app/+videos/+video-watch/video-watch.component.ts create mode 100644 client/src/app/+videos/+video-watch/video-watch.module.ts create mode 100644 client/src/app/+videos/index.ts create mode 100644 client/src/app/+videos/video-list/index.ts create mode 100644 client/src/app/+videos/video-list/overview/index.ts create mode 100644 client/src/app/+videos/video-list/overview/overview.service.ts create mode 100644 client/src/app/+videos/video-list/overview/video-overview.component.html create mode 100644 client/src/app/+videos/video-list/overview/video-overview.component.scss create mode 100644 client/src/app/+videos/video-list/overview/video-overview.component.ts create mode 100644 client/src/app/+videos/video-list/overview/videos-overview.model.ts create mode 100644 client/src/app/+videos/video-list/video-local.component.ts create mode 100644 client/src/app/+videos/video-list/video-most-liked.component.ts create mode 100644 client/src/app/+videos/video-list/video-recently-added.component.ts create mode 100644 client/src/app/+videos/video-list/video-trending.component.ts create mode 100644 client/src/app/+videos/video-list/video-user-subscriptions.component.ts create mode 100644 client/src/app/+videos/videos-routing.module.ts create mode 100644 client/src/app/+videos/videos.component.ts create mode 100644 client/src/app/+videos/videos.module.ts (limited to 'client/src/app/+videos') 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 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Injectable } from '@angular/core' + +@Injectable() +export class I18nPrimengCalendarService { + private readonly calendarLocale: any = {} + + constructor (private i18n: I18n) { + this.calendarLocale = { + firstDayOfWeek: 0, + dayNames: [ + this.i18n('Sunday'), + this.i18n('Monday'), + this.i18n('Tuesday'), + this.i18n('Wednesday'), + this.i18n('Thursday'), + this.i18n('Friday'), + this.i18n('Saturday') + ], + + dayNamesShort: [ + this.i18n({ value: 'Sun', description: 'Day name short' }), + this.i18n({ value: 'Mon', description: 'Day name short' }), + this.i18n({ value: 'Tue', description: 'Day name short' }), + this.i18n({ value: 'Wed', description: 'Day name short' }), + this.i18n({ value: 'Thu', description: 'Day name short' }), + this.i18n({ value: 'Fri', description: 'Day name short' }), + this.i18n({ value: 'Sat', description: 'Day name short' }) + ], + + dayNamesMin: [ + this.i18n({ value: 'Su', description: 'Day name min' }), + this.i18n({ value: 'Mo', description: 'Day name min' }), + this.i18n({ value: 'Tu', description: 'Day name min' }), + this.i18n({ value: 'We', description: 'Day name min' }), + this.i18n({ value: 'Th', description: 'Day name min' }), + this.i18n({ value: 'Fr', description: 'Day name min' }), + this.i18n({ value: 'Sa', description: 'Day name min' }) + ], + + monthNames: [ + this.i18n('January'), + this.i18n('February'), + this.i18n('March'), + this.i18n('April'), + this.i18n('May'), + this.i18n('June'), + this.i18n('July'), + this.i18n('August'), + this.i18n('September'), + this.i18n('October'), + this.i18n('November'), + this.i18n('December') + ], + + monthNamesShort: [ + this.i18n({ value: 'Jan', description: 'Month name short' }), + this.i18n({ value: 'Feb', description: 'Month name short' }), + this.i18n({ value: 'Mar', description: 'Month name short' }), + this.i18n({ value: 'Apr', description: 'Month name short' }), + this.i18n({ value: 'May', description: 'Month name short' }), + this.i18n({ value: 'Jun', description: 'Month name short' }), + this.i18n({ value: 'Jul', description: 'Month name short' }), + this.i18n({ value: 'Aug', description: 'Month name short' }), + this.i18n({ value: 'Sep', description: 'Month name short' }), + this.i18n({ value: 'Oct', description: 'Month name short' }), + this.i18n({ value: 'Nov', description: 'Month name short' }), + this.i18n({ value: 'Dec', description: 'Month name short' }) + ], + + today: this.i18n('Today'), + + clear: this.i18n('Clear') + } + } + + getCalendarLocale () { + return this.calendarLocale + } + + getTimezone () { + const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1] + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + + return `${timezone} - ${gmt}` + } + + getDateFormat () { + return this.i18n({ + value: 'yy-mm-dd ', + description: 'Date format in this locale.' + }) + } +} 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 @@ + + + + + + + + + + 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 @@ +@import '_variables'; +@import '_mixins'; + +.peertube-select-container { + @include peertube-select-container(auto); +} + +.caption-file { + margin-top: 20px; + width: max-content; + + ::ng-deep .root { + width: max-content; + } +} + +.warning-replace-caption { + color: red; + margin-top: 10px; +} \ 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 @@ +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { ServerService } from '@app/core' +import { FormReactive, FormValidatorService, VideoCaptionsValidatorsService } from '@app/shared/shared-forms' +import { VideoCaptionEdit } from '@app/shared/shared-main' +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' +import { ServerConfig, VideoConstant } from '@shared/models' + +@Component({ + selector: 'my-video-caption-add-modal', + styleUrls: [ './video-caption-add-modal.component.scss' ], + templateUrl: './video-caption-add-modal.component.html' +}) + +export class VideoCaptionAddModalComponent extends FormReactive implements OnInit { + @Input() existingCaptions: string[] + @Input() serverConfig: ServerConfig + + @Output() captionAdded = new EventEmitter() + + @ViewChild('modal', { static: true }) modal: ElementRef + + videoCaptionLanguages: VideoConstant[] = [] + + private openedModal: NgbModalRef + private closingModal = false + + constructor ( + protected formValidatorService: FormValidatorService, + private modalService: NgbModal, + private serverService: ServerService, + private videoCaptionsValidatorsService: VideoCaptionsValidatorsService + ) { + super() + } + + get videoCaptionExtensions () { + return this.serverConfig.videoCaption.file.extensions + } + + get videoCaptionMaxSize () { + return this.serverConfig.videoCaption.file.size.max + } + + ngOnInit () { + this.serverService.getVideoLanguages() + .subscribe(languages => this.videoCaptionLanguages = languages) + + this.buildForm({ + language: this.videoCaptionsValidatorsService.VIDEO_CAPTION_LANGUAGE, + captionfile: this.videoCaptionsValidatorsService.VIDEO_CAPTION_FILE + }) + } + + show () { + this.closingModal = false + + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) + } + + hide () { + this.closingModal = true + this.openedModal.close() + this.form.reset() + } + + isReplacingExistingCaption () { + if (this.closingModal === true) return false + + const languageId = this.form.value[ 'language' ] + + return languageId && this.existingCaptions.indexOf(languageId) !== -1 + } + + async addCaption () { + const languageId = this.form.value[ 'language' ] + const languageObject = this.videoCaptionLanguages.find(l => l.id === languageId) + + this.captionAdded.emit({ + language: languageObject, + captionfile: this.form.value[ 'captionfile' ] + }) + + this.hide() + } +} 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 @@ +
+ + +
+
+ + 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 @@ +// Bootstrap grid utilities require functions, variables and mixins +@import 'node_modules/bootstrap/scss/functions'; +@import 'node_modules/bootstrap/scss/variables'; +@import 'node_modules/bootstrap/scss/mixins'; +@import 'node_modules/bootstrap/scss/grid'; + +@import 'variables'; +@import 'mixins'; + +label { + font-weight: $font-regular; + font-size: 100%; +} + +.peertube-select-container { + @include peertube-select-container(auto); +} + +.title-page a { + color: pvar(--mainForegroundColor); + + &:hover { + text-decoration: none; + opacity: .8; + } +} + +my-peertube-checkbox { + display: block; + margin-bottom: 1rem; +} + +.nav-tabs { + margin-bottom: 15px; +} + +.video-edit { + height: 100%; + min-height: 300px; + + .form-group { + margin-bottom: 25px; + } + + input { + @include peertube-input-text(100%); + display: block; + } + + .label-tags + span { + font-size: 15px; + } + + .advanced-settings .form-group { + margin-bottom: 20px; + } +} + +.captions { + + .captions-header { + text-align: right; + margin-bottom: 1rem; + + .create-caption { + @include create-button; + } + } + + .caption-entry { + display: flex; + height: 40px; + align-items: center; + + a.caption-entry-label { + @include disable-default-a-behaviour; + + flex-grow: 1; + color: #000; + + &:hover { + opacity: 0.8; + } + } + + .caption-entry-label { + font-size: 15px; + font-weight: bold; + + margin-right: 20px; + width: 150px; + } + + .caption-entry-state { + width: 200px; + + &.caption-entry-state-create { + color: #39CC0B; + } + + &.caption-entry-state-delete { + color: #FF0000; + } + } + + .caption-entry-delete { + @include peertube-button; + @include grey-button; + } + } + + .no-caption { + text-align: center; + font-size: 15px; + } +} + +.submit-container { + text-align: right; + + .message-submit { + display: inline-block; + margin-right: 25px; + + color: pvar(--greyForegroundColor); + font-size: 15px; + } + + .submit-button { + @include peertube-button; + @include orange-button; + @include button-with-icon(20px, 1px); + + display: inline-block; + + input { + cursor: inherit; + background-color: inherit; + border: none; + padding: 0; + outline: 0; + color: inherit; + font-weight: $font-semibold; + } + } +} + +p-calendar { + display: block; + + ::ng-deep { + input, + .ui-calendar { + width: 100%; + } + + input { + @include peertube-input-text(100%); + color: #000; + } + } +} + +@include ng2-tags; + +// columns for the video +.col-video-edit { + @include make-col-ready(); + + @include media-breakpoint-up(md) { + @include make-col(7); + + & + .col-video-edit { + @include make-col(5); + } + } + + @include media-breakpoint-up(xl) { + @include make-col(8); + + & + .col-video-edit { + @include make-col(4); + } + } +} + +:host-context(.expanded) { + .col-video-edit { + @include media-breakpoint-up(md) { + @include make-col(8); + + & + .col-video-edit { + @include make-col(4); + } + } + } +} 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 @@ +import { map } from 'rxjs/operators' +import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' +import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms' +import { ServerService } from '@app/core' +import { removeElementFromArray } from '@app/helpers' +import { FormReactiveValidationMessages, FormValidatorService, VideoValidatorsService } from '@app/shared/shared-forms' +import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main' +import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models' +import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' +import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' + +@Component({ + selector: 'my-video-edit', + styleUrls: [ './video-edit.component.scss' ], + templateUrl: './video-edit.component.html' +}) +export class VideoEditComponent implements OnInit, OnDestroy { + @Input() form: FormGroup + @Input() formErrors: { [ id: string ]: string } = {} + @Input() validationMessages: FormReactiveValidationMessages = {} + @Input() userVideoChannels: { id: number, label: string, support: string }[] = [] + @Input() schedulePublicationPossible = true + @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = [] + @Input() waitTranscodingEnabled = true + + @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent + + // So that it can be accessed in the template + readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY + + videoPrivacies: VideoConstant[] = [] + videoCategories: VideoConstant[] = [] + videoLicences: VideoConstant[] = [] + videoLanguages: VideoConstant[] = [] + + tagValidators: ValidatorFn[] + tagValidatorsMessages: { [ name: string ]: string } + + schedulePublicationEnabled = false + + calendarLocale: any = {} + minScheduledDate = new Date() + myYearRange = '1880:' + (new Date()).getFullYear() + + calendarTimezone: string + calendarDateFormat: string + + serverConfig: ServerConfig + + private schedulerInterval: any + private firstPatchDone = false + private initialVideoCaptions: string[] = [] + + constructor ( + private formValidatorService: FormValidatorService, + private videoValidatorsService: VideoValidatorsService, + private videoService: VideoService, + private serverService: ServerService, + private i18nPrimengCalendarService: I18nPrimengCalendarService, + private ngZone: NgZone + ) { + this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS + this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES + + this.calendarLocale = this.i18nPrimengCalendarService.getCalendarLocale() + this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone() + this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat() + } + + get existingCaptions () { + return this.videoCaptions + .filter(c => c.action !== 'REMOVE') + .map(c => c.language.id) + } + + updateForm () { + const defaultValues: any = { + nsfw: 'false', + commentsEnabled: 'true', + downloadEnabled: 'true', + waitTranscoding: 'true', + tags: [] + } + const obj: any = { + name: this.videoValidatorsService.VIDEO_NAME, + privacy: this.videoValidatorsService.VIDEO_PRIVACY, + channelId: this.videoValidatorsService.VIDEO_CHANNEL, + nsfw: null, + commentsEnabled: null, + downloadEnabled: null, + waitTranscoding: null, + category: this.videoValidatorsService.VIDEO_CATEGORY, + licence: this.videoValidatorsService.VIDEO_LICENCE, + language: this.videoValidatorsService.VIDEO_LANGUAGE, + description: this.videoValidatorsService.VIDEO_DESCRIPTION, + tags: null, + previewfile: null, + support: this.videoValidatorsService.VIDEO_SUPPORT, + schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT, + originallyPublishedAt: this.videoValidatorsService.VIDEO_ORIGINALLY_PUBLISHED_AT + } + + this.formValidatorService.updateForm( + this.form, + this.formErrors, + this.validationMessages, + obj, + defaultValues + ) + + this.form.addControl('captions', new FormArray([ + new FormGroup({ + language: new FormControl(), + captionfile: new FormControl() + }) + ])) + + this.trackChannelChange() + this.trackPrivacyChange() + } + + ngOnInit () { + this.updateForm() + + this.serverService.getVideoCategories() + .subscribe(res => this.videoCategories = res) + this.serverService.getVideoLicences() + .subscribe(res => this.videoLicences = res) + this.serverService.getVideoLanguages() + .subscribe(res => this.videoLanguages = res) + + this.serverService.getVideoPrivacies() + .subscribe(privacies => this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies)) + + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id) + + this.ngZone.runOutsideAngular(() => { + this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute + }) + } + + ngOnDestroy () { + if (this.schedulerInterval) clearInterval(this.schedulerInterval) + } + + onCaptionAdded (caption: VideoCaptionEdit) { + const existingCaption = this.videoCaptions.find(c => c.language.id === caption.language.id) + + // Replace existing caption? + if (existingCaption) { + Object.assign(existingCaption, caption, { action: 'CREATE' as 'CREATE' }) + } else { + this.videoCaptions.push( + Object.assign(caption, { action: 'CREATE' as 'CREATE' }) + ) + } + + this.sortVideoCaptions() + } + + async deleteCaption (caption: VideoCaptionEdit) { + // Caption recovers his former state + if (caption.action && this.initialVideoCaptions.indexOf(caption.language.id) !== -1) { + caption.action = undefined + return + } + + // This caption is not on the server, just remove it from our array + if (caption.action === 'CREATE') { + removeElementFromArray(this.videoCaptions, caption) + return + } + + caption.action = 'REMOVE' as 'REMOVE' + } + + openAddCaptionModal () { + this.videoCaptionAddModal.show() + } + + private sortVideoCaptions () { + this.videoCaptions.sort((v1, v2) => { + if (v1.language.label < v2.language.label) return -1 + if (v1.language.label === v2.language.label) return 0 + + return 1 + }) + } + + private trackPrivacyChange () { + // We will update the schedule input and the wait transcoding checkbox validators + this.form.controls[ 'privacy' ] + .valueChanges + .pipe(map(res => parseInt(res.toString(), 10))) + .subscribe( + newPrivacyId => { + + this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY + + // Value changed + const scheduleControl = this.form.get('schedulePublicationAt') + const waitTranscodingControl = this.form.get('waitTranscoding') + + if (this.schedulePublicationEnabled) { + scheduleControl.setValidators([ Validators.required ]) + + waitTranscodingControl.disable() + waitTranscodingControl.setValue(false) + } else { + scheduleControl.clearValidators() + + waitTranscodingControl.enable() + + // Do not update the control value on first patch (values come from the server) + if (this.firstPatchDone === true) { + waitTranscodingControl.setValue(true) + } + } + + scheduleControl.updateValueAndValidity() + waitTranscodingControl.updateValueAndValidity() + + this.firstPatchDone = true + + } + ) + } + + private trackChannelChange () { + // We will update the "support" field depending on the channel + this.form.controls[ 'channelId' ] + .valueChanges + .pipe(map(res => parseInt(res.toString(), 10))) + .subscribe( + newChannelId => { + const oldChannelId = parseInt(this.form.value[ 'channelId' ], 10) + + // Not initialized yet + if (isNaN(newChannelId)) return + const newChannel = this.userVideoChannels.find(c => c.id === newChannelId) + if (!newChannel) return + + // Wait support field update + setTimeout(() => { + const currentSupport = this.form.value[ 'support' ] + + // First time we set the channel? + if (isNaN(oldChannelId) && !currentSupport) return this.updateSupportField(newChannel.support) + + const oldChannel = this.userVideoChannels.find(c => c.id === oldChannelId) + if (!newChannel || !oldChannel) { + console.error('Cannot find new or old channel.') + return + } + + // If the current support text is not the same than the old channel, the user updated it. + // We don't want the user to lose his text, so stop here + if (currentSupport && currentSupport !== oldChannel.support) return + + // Update the support text with our new channel + this.updateSupportField(newChannel.support) + }) + } + ) + } + + private updateSupportField (support: string) { + return this.form.patchValue({ support: support || '' }) + } +} 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 @@ +import { TagInputModule } from 'ngx-chips' +import { CalendarModule } from 'primeng/calendar' +import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' +import { SharedGlobalIconModule } from '@app/shared/shared-icons' +import { SharedMainModule } from '@app/shared/shared-main' +import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' +import { VideoEditComponent } from './video-edit.component' + +@NgModule({ + imports: [ + TagInputModule, + CalendarModule, + + SharedMainModule, + SharedFormModule, + SharedGlobalIconModule + ], + + declarations: [ + VideoEditComponent, + VideoCaptionAddModalComponent + ], + + exports: [ + TagInputModule, + CalendarModule, + + SharedMainModule, + SharedFormModule, + SharedGlobalIconModule, + + VideoEditComponent + ], + + providers: [] +}) +export class VideoEditModule { } diff --git a/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts b/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts new file mode 100644 index 000000000..7b1a38c62 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts @@ -0,0 +1,30 @@ +import { Directive, Output, EventEmitter, HostBinding, HostListener } from '@angular/core' + +@Directive({ + selector: '[dragDrop]' +}) +export class DragDropDirective { + @Output() fileDropped = new EventEmitter() + + @HostBinding('class.dragover') dragover = false + + @HostListener('dragover', ['$event']) onDragOver (e: Event) { + e.preventDefault() + e.stopPropagation() + this.dragover = true + } + + @HostListener('dragleave', ['$event']) public onDragLeave (e: Event) { + e.preventDefault() + e.stopPropagation() + this.dragover = false + } + + @HostListener('drop', ['$event']) public ondrop (e: DragEvent) { + e.preventDefault() + e.stopPropagation() + this.dragover = false + const files = e.dataTransfer.files + if (files.length > 0) this.fileDropped.emit(files) + } +} 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 @@ +
+
+ + +
+ Select the torrent to import + +
+ +
+ +
+ + + + + You can import any torrent file that points to a mp4 file. + 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. + + + + + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+
Sorry, but something went wrong
+ {{ error }} +
+ +
+ Congratulations, the video will be imported with BitTorrent! You can already add information about this video. +
+ + +
+ + +
+
+ + +
+
+
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 @@ +@import 'variables'; +@import 'mixins'; + +.first-step-block { + .torrent-or-magnet { + @include divider($color: pvar(--inputPlaceholderColor), $background: pvar(--submenuColor)); + + &[data-content] { + margin: 1.5rem 0; + } + } + + .form-group-magnet-uri { + margin-bottom: 40px; + } +} + + 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 @@ +import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' +import { scrollToTop } from '@app/helpers' +import { FormValidatorService } from '@app/shared/shared-forms' +import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' +import { VideoSend } from './video-send' +import { LoadingBarService } from '@ngx-loading-bar/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoPrivacy, VideoUpdate } from '@shared/models' + +@Component({ + selector: 'my-video-import-torrent', + templateUrl: './video-import-torrent.component.html', + styleUrls: [ + '../shared/video-edit.component.scss', + './video-import-torrent.component.scss', + './video-send.scss' + ] +}) +export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate { + @Output() firstStepDone = new EventEmitter() + @Output() firstStepError = new EventEmitter() + @ViewChild('torrentfileInput') torrentfileInput: ElementRef + + magnetUri = '' + + isImportingVideo = false + hasImportedVideo = false + isUpdatingVideo = false + + video: VideoEdit + error: string + + protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC + + constructor ( + protected formValidatorService: FormValidatorService, + protected loadingBar: LoadingBarService, + protected notifier: Notifier, + protected authService: AuthService, + protected serverService: ServerService, + protected videoService: VideoService, + protected videoCaptionService: VideoCaptionService, + private router: Router, + private videoImportService: VideoImportService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + super.ngOnInit() + } + + canDeactivate () { + return { canDeactivate: true } + } + + isMagnetUrlValid () { + return !!this.magnetUri + } + + fileChange () { + const torrentfile = this.torrentfileInput.nativeElement.files[0] + if (!torrentfile) return + + this.importVideo(torrentfile) + } + + setTorrentFile (files: FileList) { + this.torrentfileInput.nativeElement.files = files + this.fileChange() + } + + importVideo (torrentfile?: Blob) { + this.isImportingVideo = true + + const videoUpdate: VideoUpdate = { + privacy: this.firstStepPrivacyId, + waitTranscoding: false, + commentsEnabled: true, + downloadEnabled: true, + channelId: this.firstStepChannelId + } + + this.loadingBar.start() + + this.videoImportService.importVideoTorrent(torrentfile || this.magnetUri, videoUpdate).subscribe( + res => { + this.loadingBar.complete() + this.firstStepDone.emit(res.video.name) + this.isImportingVideo = false + this.hasImportedVideo = true + + this.video = new VideoEdit(Object.assign(res.video, { + commentsEnabled: videoUpdate.commentsEnabled, + downloadEnabled: videoUpdate.downloadEnabled, + support: null, + thumbnailUrl: null, + previewUrl: null + })) + + this.hydrateFormFromVideo() + }, + + err => { + this.loadingBar.complete() + this.isImportingVideo = false + this.firstStepError.emit() + this.notifier.error(err.message) + } + ) + } + + updateSecondStep () { + if (this.checkForm() === false) { + return + } + + this.video.patch(this.form.value) + + this.isUpdatingVideo = true + + // Update the video + this.updateVideoAndCaptions(this.video) + .subscribe( + () => { + this.isUpdatingVideo = false + this.notifier.success(this.i18n('Video to import updated.')) + + this.router.navigate([ '/my-account', 'video-imports' ]) + }, + + err => { + this.error = err.message + scrollToTop() + console.error(err) + } + ) + + } + + private hydrateFormFromVideo () { + this.form.patchValue(this.video.toFormPatch()) + } +} 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 @@ +
+
+ + +
+ + + + + + You can import any URL supported by youtube-dl + or URL that points to a raw MP4 file. + 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. + + + + + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
Sorry, but something went wrong
+ {{ error }} +
+ +
+ Congratulations, the video behind {{ targetUrl }} will be imported! You can already add information about this video. +
+ + +
+ + +
+
+ + +
+
+
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 @@ +import { map, switchMap } from 'rxjs/operators' +import { Component, EventEmitter, OnInit, Output } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' +import { getAbsoluteAPIUrl, scrollToTop } from '@app/helpers' +import { FormValidatorService } from '@app/shared/shared-forms' +import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' +import { VideoSend } from './video-send' +import { LoadingBarService } from '@ngx-loading-bar/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoPrivacy, VideoUpdate } from '@shared/models' + +@Component({ + selector: 'my-video-import-url', + templateUrl: './video-import-url.component.html', + styleUrls: [ + '../shared/video-edit.component.scss', + './video-send.scss' + ] +}) +export class VideoImportUrlComponent extends VideoSend implements OnInit, CanComponentDeactivate { + @Output() firstStepDone = new EventEmitter() + @Output() firstStepError = new EventEmitter() + + targetUrl = '' + + isImportingVideo = false + hasImportedVideo = false + isUpdatingVideo = false + + video: VideoEdit + error: string + + protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC + + constructor ( + protected formValidatorService: FormValidatorService, + protected loadingBar: LoadingBarService, + protected notifier: Notifier, + protected authService: AuthService, + protected serverService: ServerService, + protected videoService: VideoService, + protected videoCaptionService: VideoCaptionService, + private router: Router, + private videoImportService: VideoImportService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + super.ngOnInit() + } + + canDeactivate () { + return { canDeactivate: true } + } + + isTargetUrlValid () { + return this.targetUrl && this.targetUrl.match(/https?:\/\//) + } + + importVideo () { + this.isImportingVideo = true + + const videoUpdate: VideoUpdate = { + privacy: this.firstStepPrivacyId, + waitTranscoding: false, + commentsEnabled: true, + downloadEnabled: true, + channelId: this.firstStepChannelId + } + + this.loadingBar.start() + + this.videoImportService + .importVideoUrl(this.targetUrl, videoUpdate) + .pipe( + switchMap(res => { + return this.videoCaptionService + .listCaptions(res.video.id) + .pipe( + map(result => ({ video: res.video, videoCaptions: result.data })) + ) + }) + ) + .subscribe( + ({ video, videoCaptions }) => { + this.loadingBar.complete() + this.firstStepDone.emit(video.name) + this.isImportingVideo = false + this.hasImportedVideo = true + + const absoluteAPIUrl = getAbsoluteAPIUrl() + + const thumbnailUrl = video.thumbnailPath + ? absoluteAPIUrl + video.thumbnailPath + : null + + const previewUrl = video.previewPath + ? absoluteAPIUrl + video.previewPath + : null + + this.video = new VideoEdit(Object.assign(video, { + commentsEnabled: videoUpdate.commentsEnabled, + downloadEnabled: videoUpdate.downloadEnabled, + support: null, + thumbnailUrl, + previewUrl + })) + + this.videoCaptions = videoCaptions + + this.hydrateFormFromVideo() + }, + + err => { + this.loadingBar.complete() + this.isImportingVideo = false + this.firstStepError.emit() + this.notifier.error(err.message) + } + ) + } + + updateSecondStep () { + if (this.checkForm() === false) { + return + } + + this.video.patch(this.form.value) + + this.isUpdatingVideo = true + + // Update the video + this.updateVideoAndCaptions(this.video) + .subscribe( + () => { + this.isUpdatingVideo = false + this.notifier.success(this.i18n('Video to import updated.')) + + this.router.navigate([ '/my-account', 'video-imports' ]) + }, + + err => { + this.error = err.message + scrollToTop() + console.error(err) + } + ) + + } + + private hydrateFormFromVideo () { + this.form.patchValue(this.video.toFormPatch()) + + const objects = [ + { + url: 'thumbnailUrl', + name: 'thumbnailfile' + }, + { + url: 'previewUrl', + name: 'previewfile' + } + ] + + for (const obj of objects) { + fetch(this.video[obj.url]) + .then(response => response.blob()) + .then(data => { + this.form.patchValue({ + [ obj.name ]: data + }) + }) + } + } +} 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 @@ +@import 'variables'; +@import 'mixins'; + +$width-size: 190px; + +.alert.alert-danger { + text-align: center; + + & > div { + font-weight: $font-semibold; + } +} + +.first-step-block { + display: flex; + flex-direction: column; + align-items: center; + + .upload-icon { + width: 90px; + margin-bottom: 25px; + + @include apply-svg-color(#C6C6C6); + } + + .peertube-select-container { + @include peertube-select-container($width-size); + } + + input[type=text] { + @include peertube-input-text($width-size); + display: block; + } + + input[type=button] { + @include peertube-button; + @include orange-button; + + width: $width-size; + margin-top: 30px; + } + + .button-file { + @include peertube-button-file(max-content); + } +} 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 @@ +import { catchError, switchMap, tap } from 'rxjs/operators' +import { EventEmitter, OnInit } from '@angular/core' +import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core' +import { populateAsyncUserVideoChannels } from '@app/helpers' +import { FormReactive } from '@app/shared/shared-forms' +import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { LoadingBarService } from '@ngx-loading-bar/core' +import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models' + +export abstract class VideoSend extends FormReactive implements OnInit { + userVideoChannels: { id: number, label: string, support: string }[] = [] + videoPrivacies: VideoConstant[] = [] + videoCaptions: VideoCaptionEdit[] = [] + + firstStepPrivacyId = 0 + firstStepChannelId = 0 + + abstract firstStepDone: EventEmitter + abstract firstStepError: EventEmitter + protected abstract readonly DEFAULT_VIDEO_PRIVACY: VideoPrivacy + + protected loadingBar: LoadingBarService + protected notifier: Notifier + protected authService: AuthService + protected serverService: ServerService + protected videoService: VideoService + protected videoCaptionService: VideoCaptionService + protected serverConfig: ServerConfig + + abstract canDeactivate (): CanComponentDeactivateResult + + ngOnInit () { + this.buildForm({}) + + populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) + .then(() => this.firstStepChannelId = this.userVideoChannels[ 0 ].id) + + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.serverService.getVideoPrivacies() + .subscribe( + privacies => { + this.videoPrivacies = privacies + + this.firstStepPrivacyId = this.DEFAULT_VIDEO_PRIVACY + }) + } + + checkForm () { + this.forceCheck() + + return this.form.valid + } + + protected updateVideoAndCaptions (video: VideoEdit) { + this.loadingBar.start() + + return this.videoService.updateVideo(video) + .pipe( + // Then update captions + switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions)), + tap(() => this.loadingBar.complete()), + catchError(err => { + this.loadingBar.complete() + throw err + }) + ) + } +} 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 @@ +
+
+ + +
+ Select the file to upload + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+ + +
+ Image that will be merged with your audio file. +
+ The chosen image will be definitive and cannot be modified. +
+ + +
+ +
+ +
+
+
+
+ +
+
+
+ Processing… + {{ videoUploadPercents }}% +
+
+ +
+ +
+
Sorry, but something went wrong
+ {{ error }} +
+ +
+ Congratulations! Your video is now available in your private library. +
+ + +
+ + +
+
Publish will be available when upload is finished
+ +
+ + +
+
+
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 @@ +@import 'variables'; +@import 'mixins'; + +.first-step-block { + .form-group-channel { + margin-bottom: 20px; + margin-top: 35px; + } + + .audio-image-info { + margin-bottom: 10px; + } + + .audio-preview { + margin: 30px 0; + } +} + +.upload-progress-cancel { + display: flex; + margin-top: 25px; + margin-bottom: 40px; + + .progress { + @include progressbar; + flex-grow: 1; + height: 30px; + font-size: 15px; + background-color: rgba(11, 204, 41, 0.16); + + .progress-bar { + background-color: $green; + line-height: 30px; + text-align: left; + font-weight: $font-bold; + + span { + margin-left: 18px; + } + } + } + + input { + @include peertube-button; + @include grey-button; + + margin-left: 10px; + } +} 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 @@ +import { BytesPipe } from 'ngx-pipes' +import { Subscription } from 'rxjs' +import { HttpEventType, HttpResponse } from '@angular/common/http' +import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, CanComponentDeactivate, Notifier, ServerService, UserService } from '@app/core' +import { scrollToTop } from '@app/helpers' +import { FormValidatorService } from '@app/shared/shared-forms' +import { VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' +import { LoadingBarService } from '@ngx-loading-bar/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoPrivacy } from '@shared/models' +import { VideoSend } from './video-send' + +@Component({ + selector: 'my-video-upload', + templateUrl: './video-upload.component.html', + styleUrls: [ + '../shared/video-edit.component.scss', + './video-upload.component.scss', + './video-send.scss' + ] +}) +export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate { + @Output() firstStepDone = new EventEmitter() + @Output() firstStepError = new EventEmitter() + @ViewChild('videofileInput') videofileInput: ElementRef + + // So that it can be accessed in the template + readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY + + userVideoQuotaUsed = 0 + userVideoQuotaUsedDaily = 0 + + isUploadingAudioFile = false + isUploadingVideo = false + isUpdatingVideo = false + + videoUploaded = false + videoUploadObservable: Subscription = null + videoUploadPercents = 0 + videoUploadedIds = { + id: 0, + uuid: '' + } + + waitTranscodingEnabled = true + previewfileUpload: File + + error: string + + protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC + + constructor ( + protected formValidatorService: FormValidatorService, + protected loadingBar: LoadingBarService, + protected notifier: Notifier, + protected authService: AuthService, + protected serverService: ServerService, + protected videoService: VideoService, + protected videoCaptionService: VideoCaptionService, + private userService: UserService, + private router: Router, + private i18n: I18n + ) { + super() + } + + get videoExtensions () { + return this.serverConfig.video.file.extensions.join(', ') + } + + ngOnInit () { + super.ngOnInit() + + this.userService.getMyVideoQuotaUsed() + .subscribe(data => { + this.userVideoQuotaUsed = data.videoQuotaUsed + this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily + }) + } + + ngOnDestroy () { + if (this.videoUploadObservable) this.videoUploadObservable.unsubscribe() + } + + canDeactivate () { + let text = '' + + if (this.videoUploaded === true) { + // FIXME: cannot concatenate strings inside i18n service :/ + text = this.i18n('Your video was uploaded to your account and is private.') + ' ' + + this.i18n('But associated data (tags, description...) will be lost, are you sure you want to leave this page?') + } else { + text = this.i18n('Your video is not uploaded yet, are you sure you want to leave this page?') + } + + return { + canDeactivate: !this.isUploadingVideo, + text + } + } + + getVideoFile () { + return this.videofileInput.nativeElement.files[0] + } + + setVideoFile (files: FileList) { + this.videofileInput.nativeElement.files = files + this.fileChange() + } + + getAudioUploadLabel () { + const videofile = this.getVideoFile() + if (!videofile) return this.i18n('Upload') + + return this.i18n('Upload {{videofileName}}', { videofileName: videofile.name }) + } + + fileChange () { + this.uploadFirstStep() + } + + cancelUpload () { + if (this.videoUploadObservable !== null) { + this.videoUploadObservable.unsubscribe() + + this.isUploadingVideo = false + this.videoUploadPercents = 0 + this.videoUploadObservable = null + + this.firstStepError.emit() + + this.notifier.info(this.i18n('Upload cancelled')) + } + } + + uploadFirstStep (clickedOnButton = false) { + const videofile = this.getVideoFile() + if (!videofile) return + + if (!this.checkGlobalUserQuota(videofile)) return + if (!this.checkDailyUserQuota(videofile)) return + + if (clickedOnButton === false && this.isAudioFile(videofile.name)) { + this.isUploadingAudioFile = true + return + } + + // Build name field + const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '') + let name: string + + // If the name of the file is very small, keep the extension + if (nameWithoutExtension.length < 3) name = videofile.name + else name = nameWithoutExtension + + // Force user to wait transcoding for unsupported video types in web browsers + if (!videofile.name.endsWith('.mp4') && !videofile.name.endsWith('.webm') && !videofile.name.endsWith('.ogv')) { + this.waitTranscodingEnabled = false + } + + const privacy = this.firstStepPrivacyId.toString() + const nsfw = this.serverConfig.instance.isNSFW + const waitTranscoding = true + const commentsEnabled = true + const downloadEnabled = true + const channelId = this.firstStepChannelId.toString() + + const formData = new FormData() + formData.append('name', name) + // Put the video "private" -> we are waiting the user validation of the second step + formData.append('privacy', VideoPrivacy.PRIVATE.toString()) + formData.append('nsfw', '' + nsfw) + formData.append('commentsEnabled', '' + commentsEnabled) + formData.append('downloadEnabled', '' + downloadEnabled) + formData.append('waitTranscoding', '' + waitTranscoding) + formData.append('channelId', '' + channelId) + formData.append('videofile', videofile) + + if (this.previewfileUpload) { + formData.append('previewfile', this.previewfileUpload) + formData.append('thumbnailfile', this.previewfileUpload) + } + + this.isUploadingVideo = true + this.firstStepDone.emit(name) + + this.form.patchValue({ + name, + privacy, + nsfw, + channelId, + previewfile: this.previewfileUpload + }) + + this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe( + event => { + if (event.type === HttpEventType.UploadProgress) { + this.videoUploadPercents = Math.round(100 * event.loaded / event.total) + } else if (event instanceof HttpResponse) { + this.videoUploaded = true + + this.videoUploadedIds = event.body.video + + this.videoUploadObservable = null + } + }, + + err => { + // Reset progress + this.isUploadingVideo = false + this.videoUploadPercents = 0 + this.videoUploadObservable = null + this.firstStepError.emit() + this.notifier.error(err.message) + } + ) + } + + isPublishingButtonDisabled () { + return !this.form.valid || + this.isUpdatingVideo === true || + this.videoUploaded !== true + } + + updateSecondStep () { + if (this.checkForm() === false) { + return + } + + const video = new VideoEdit() + video.patch(this.form.value) + video.id = this.videoUploadedIds.id + video.uuid = this.videoUploadedIds.uuid + + this.isUpdatingVideo = true + + this.updateVideoAndCaptions(video) + .subscribe( + () => { + this.isUpdatingVideo = false + this.isUploadingVideo = false + + this.notifier.success(this.i18n('Video published.')) + this.router.navigate([ '/videos/watch', video.uuid ]) + }, + + err => { + this.error = err.message + scrollToTop() + console.error(err) + } + ) + } + + private checkGlobalUserQuota (videofile: File) { + const bytePipes = new BytesPipe() + + // Check global user quota + const videoQuota = this.authService.getUser().videoQuota + if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { + const msg = this.i18n( + 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})', + { + videoSize: bytePipes.transform(videofile.size, 0), + videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0), + videoQuota: bytePipes.transform(videoQuota, 0) + } + ) + this.notifier.error(msg) + + return false + } + + return true + } + + private checkDailyUserQuota (videofile: File) { + const bytePipes = new BytesPipe() + + // Check daily user quota + const videoQuotaDaily = this.authService.getUser().videoQuotaDaily + if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) { + const msg = this.i18n( + 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})', + { + videoSize: bytePipes.transform(videofile.size, 0), + quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0), + quotaDaily: bytePipes.transform(videoQuotaDaily, 0) + } + ) + this.notifier.error(msg) + + return false + } + + return true + } + + private isAudioFile (filename: string) { + const extensions = [ '.mp3', '.flac', '.ogg', '.wma', '.wav' ] + + return extensions.some(e => filename.endsWith(e)) + } +} 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 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { CanDeactivateGuard, LoginGuard } from '@app/core' +import { MetaGuard } from '@ngx-meta/core' +import { VideoAddComponent } from './video-add.component' + +const videoAddRoutes: Routes = [ + { + path: '', + component: VideoAddComponent, + canActivate: [ MetaGuard, LoginGuard ], + canDeactivate: [ CanDeactivateGuard ] + } +] + +@NgModule({ + imports: [ RouterModule.forChild(videoAddRoutes) ], + exports: [ RouterModule ] +}) +export class VideoAddRoutingModule {} diff --git a/client/src/app/+videos/+video-edit/video-add.component.html b/client/src/app/+videos/+video-edit/video-add.component.html new file mode 100644 index 000000000..79bfc6e5c --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add.component.html @@ -0,0 +1,46 @@ +
+
+ We recommend you to not use the root user to publish your videos, since it's the super-admin account of your instance. +
+ Instead, create a dedicated account to upload your videos. +
+ +
+ Import {{ videoName }} + Upload {{ videoName }} +
+ + + +
+
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 @@ +@import '_variables'; +@import '_mixins'; + +$border-width: 3px; +$border-type: solid; +$border-color: #EAEAEA; +$nav-link-height: 40px; + +.margin-content { + padding-top: 50px; +} + +.alert { + font-size: 15px; +} + +::ng-deep .video-add-nav { + border-bottom: $border-width $border-type $border-color; + margin: 50px 0 0 0 !important; + + &.hide-nav { + display: none !important; + } + + a.nav-link { + @include disable-default-a-behaviour; + + margin-bottom: -$border-width; + height: $nav-link-height !important; + padding: 0 30px !important; + font-size: 15px; + + &.active { + border: $border-width $border-type $border-color; + border-bottom: none; + background-color: pvar(--submenuColor) !important; + + span { + border-bottom: 2px solid pvar(--mainColor); + font-weight: $font-bold; + } + } + } +} + +::ng-deep .upload-video-container { + border: $border-width $border-type $border-color; + border-top: transparent; + + background-color: pvar(--submenuColor); + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + width: 100%; + min-height: 440px; + padding-bottom: 20px; + display: flex; + justify-content: center; + align-items: center; + + &.dragover { + border: 3px dashed pvar(--mainColor); + } +} + +@mixin nav-scroll { + ::ng-deep .video-add-nav { + height: #{$nav-link-height + $border-width * 2}; + overflow-x: auto; + white-space: nowrap; + flex-wrap: unset; + + /* Hide active tab style to not have a moving tab effect */ + a.nav-link.active { + border: none; + background-color: pvar(--mainBackgroundColor) !important; + } + } +} + +/* Make .video-add-nav tabs scrollable on small devices */ +@media screen and (max-width: $small-view) { + @include nav-scroll(); +} + +@media screen and (max-width: #{$small-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include nav-scroll(); + } +} 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 @@ +import { Component, HostListener, OnInit, ViewChild } from '@angular/core' +import { AuthService, CanComponentDeactivate, ServerService } from '@app/core' +import { ServerConfig } from '@shared/models' +import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' +import { VideoImportUrlComponent } from './video-add-components/video-import-url.component' +import { VideoUploadComponent } from './video-add-components/video-upload.component' + +@Component({ + selector: 'my-videos-add', + templateUrl: './video-add.component.html', + styleUrls: [ './video-add.component.scss' ] +}) +export class VideoAddComponent implements OnInit, CanComponentDeactivate { + @ViewChild('videoUpload') videoUpload: VideoUploadComponent + @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent + @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent + + secondStepType: 'upload' | 'import-url' | 'import-torrent' + videoName: string + serverConfig: ServerConfig + + constructor ( + private auth: AuthService, + private serverService: ServerService + ) {} + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + } + + onFirstStepDone (type: 'upload' | 'import-url' | 'import-torrent', videoName: string) { + this.secondStepType = type + this.videoName = videoName + } + + onError () { + this.videoName = undefined + this.secondStepType = undefined + } + + @HostListener('window:beforeunload', [ '$event' ]) + onUnload (event: any) { + const { text, canDeactivate } = this.canDeactivate() + + if (canDeactivate) return + + event.returnValue = text + return text + } + + canDeactivate (): { canDeactivate: boolean, text?: string} { + if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate() + if (this.secondStepType === 'import-url') return this.videoImportUrl.canDeactivate() + if (this.secondStepType === 'import-torrent') return this.videoImportTorrent.canDeactivate() + + return { canDeactivate: true } + } + + isVideoImportHttpEnabled () { + return this.serverConfig.import.videos.http.enabled + } + + isVideoImportTorrentEnabled () { + return this.serverConfig.import.videos.torrent.enabled + } + + isInSecondStep () { + return !!this.secondStepType + } + + isRootUser () { + return this.auth.getUser().username === 'root' + } +} 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 @@ +import { NgModule } from '@angular/core' +import { CanDeactivateGuard } from '@app/core' +import { VideoEditModule } from './shared/video-edit.module' +import { DragDropDirective } from './video-add-components/drag-drop.directive' +import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' +import { VideoImportUrlComponent } from './video-add-components/video-import-url.component' +import { VideoUploadComponent } from './video-add-components/video-upload.component' +import { VideoAddRoutingModule } from './video-add-routing.module' +import { VideoAddComponent } from './video-add.component' + +@NgModule({ + imports: [ + VideoAddRoutingModule, + + VideoEditModule + ], + + declarations: [ + VideoAddComponent, + VideoUploadComponent, + VideoImportUrlComponent, + VideoImportTorrentComponent, + DragDropDirective + ], + + exports: [ ], + + providers: [ + CanDeactivateGuard + ] +}) +export class VideoAddModule { } diff --git a/client/src/app/+videos/+video-edit/video-update-routing.module.ts b/client/src/app/+videos/+video-edit/video-update-routing.module.ts new file mode 100644 index 000000000..a04351b05 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update-routing.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { CanDeactivateGuard, LoginGuard } from '@app/core' +import { MetaGuard } from '@ngx-meta/core' +import { VideoUpdateComponent } from './video-update.component' +import { VideoUpdateResolver } from './video-update.resolver' + +const videoUpdateRoutes: Routes = [ + { + path: '', + component: VideoUpdateComponent, + canActivate: [ MetaGuard, LoginGuard ], + canDeactivate: [ CanDeactivateGuard ], + resolve: { + videoData: VideoUpdateResolver + } + } +] + +@NgModule({ + imports: [ RouterModule.forChild(videoUpdateRoutes) ], + exports: [ RouterModule ] +}) +export class VideoUpdateRoutingModule {} diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html new file mode 100644 index 000000000..fbc642db9 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update.component.html @@ -0,0 +1,22 @@ +
+
+ Update + {{ video?.name }} +
+ +
+ + + +
+
+ + +
+
+
+
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 @@ +import { map, switchMap } from 'rxjs/operators' +import { Component, HostListener, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { Notifier } from '@app/core' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' +import { LoadingBarService } from '@ngx-loading-bar/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoPrivacy } from '@shared/models' + +@Component({ + selector: 'my-videos-update', + styleUrls: [ './shared/video-edit.component.scss' ], + templateUrl: './video-update.component.html' +}) +export class VideoUpdateComponent extends FormReactive implements OnInit { + video: VideoEdit + + isUpdatingVideo = false + userVideoChannels: { id: number, label: string, support: string }[] = [] + schedulePublicationPossible = false + videoCaptions: VideoCaptionEdit[] = [] + waitTranscodingEnabled = true + + private updateDone = false + + constructor ( + protected formValidatorService: FormValidatorService, + private route: ActivatedRoute, + private router: Router, + private notifier: Notifier, + private videoService: VideoService, + private loadingBar: LoadingBarService, + private videoCaptionService: VideoCaptionService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.buildForm({}) + + this.route.data + .pipe(map(data => data.videoData)) + .subscribe(({ video, videoChannels, videoCaptions }) => { + this.video = new VideoEdit(video) + this.userVideoChannels = videoChannels + this.videoCaptions = videoCaptions + + this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE + + const videoFiles = (video as VideoDetails).getFiles() + if (videoFiles.length > 1) { // Already transcoded + this.waitTranscodingEnabled = false + } + + // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout + setTimeout(() => this.hydrateFormFromVideo()) + }, + + err => { + console.error(err) + this.notifier.error(err.message) + } + ) + } + + @HostListener('window:beforeunload', [ '$event' ]) + onUnload (event: any) { + const { text, canDeactivate } = this.canDeactivate() + + if (canDeactivate) return + + event.returnValue = text + return text + } + + canDeactivate (): { canDeactivate: boolean, text?: string } { + if (this.updateDone === true) return { canDeactivate: true } + + const text = this.i18n('You have unsaved changes! If you leave, your changes will be lost.') + + for (const caption of this.videoCaptions) { + if (caption.action) return { canDeactivate: false, text } + } + + return { canDeactivate: this.formChanged === false, text } + } + + checkForm () { + this.forceCheck() + + return this.form.valid + } + + update () { + if (this.checkForm() === false + || this.isUpdatingVideo === true) { + return + } + + this.video.patch(this.form.value) + + this.loadingBar.start() + this.isUpdatingVideo = true + + // Update the video + this.videoService.updateVideo(this.video) + .pipe( + // Then update captions + switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions)) + ) + .subscribe( + () => { + this.updateDone = true + this.isUpdatingVideo = false + this.loadingBar.complete() + this.notifier.success(this.i18n('Video updated.')) + this.router.navigate([ '/videos/watch', this.video.uuid ]) + }, + + err => { + this.loadingBar.complete() + this.isUpdatingVideo = false + this.notifier.error(err.message) + console.error(err) + } + ) + } + + private hydrateFormFromVideo () { + this.form.patchValue(this.video.toFormPatch()) + + const objects = [ + { + url: 'thumbnailUrl', + name: 'thumbnailfile' + }, + { + url: 'previewUrl', + name: 'previewfile' + } + ] + + for (const obj of objects) { + fetch(this.video[obj.url]) + .then(response => response.blob()) + .then(data => { + this.form.patchValue({ + [ obj.name ]: data + }) + }) + } + } +} 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 @@ +import { NgModule } from '@angular/core' +import { CanDeactivateGuard } from '@app/core' +import { VideoEditModule } from './shared/video-edit.module' +import { VideoUpdateRoutingModule } from './video-update-routing.module' +import { VideoUpdateComponent } from './video-update.component' +import { VideoUpdateResolver } from './video-update.resolver' + +@NgModule({ + imports: [ + VideoUpdateRoutingModule, + + VideoEditModule + ], + + declarations: [ + VideoUpdateComponent + ], + + exports: [ ], + + providers: [ + VideoUpdateResolver, + CanDeactivateGuard + ] +}) +export class VideoUpdateModule { } diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts new file mode 100644 index 000000000..30bcf4d74 --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts @@ -0,0 +1,44 @@ +import { forkJoin } from 'rxjs' +import { map, switchMap } from 'rxjs/operators' +import { Injectable } from '@angular/core' +import { ActivatedRouteSnapshot, Resolve } from '@angular/router' +import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main' + +@Injectable() +export class VideoUpdateResolver implements Resolve { + constructor ( + private videoService: VideoService, + private videoChannelService: VideoChannelService, + private videoCaptionService: VideoCaptionService + ) { + } + + resolve (route: ActivatedRouteSnapshot) { + const uuid: string = route.params[ 'uuid' ] + + return this.videoService.getVideo({ videoId: uuid }) + .pipe( + switchMap(video => { + return forkJoin([ + this.videoService + .loadCompleteDescription(video.descriptionPath) + .pipe(map(description => Object.assign(video, { description }))), + + this.videoChannelService + .listAccountVideoChannels(video.account) + .pipe( + map(result => result.data), + map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))) + ), + + this.videoCaptionService + .listCaptions(video.id) + .pipe( + map(result => result.data) + ) + ]) + }), + map(([ video, videoChannels, videoCaptions ]) => ({ video, videoChannels, videoCaptions })) + ) + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html new file mode 100644 index 000000000..9b43d91da --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html @@ -0,0 +1,56 @@ +
+
+ Avatar + +
+ +
+ {{ formErrors.text }} +
+
+
+ +
+ + +
+
+ + + + + + diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss new file mode 100644 index 000000000..b3725ab94 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss @@ -0,0 +1,82 @@ +@import '_variables'; +@import '_mixins'; + +form { + margin-bottom: 30px; +} + +.avatar-and-textarea { + display: flex; + margin-bottom: 10px; + + img { + @include avatar(25px); + + vertical-align: top; + margin-right: 10px; + } + + .form-group { + flex-grow: 1; + margin: 0; + + textarea { + @include peertube-textarea(100%, 60px); + + &:focus::placeholder { + opacity: 0; + } + } + } +} + +.comment-buttons { + display: flex; + justify-content: flex-end; + + button { + @include peertube-button; + @include disable-outline; + @include disable-default-a-behaviour; + + &:not(:last-child) { + margin-right: .5rem; + } + + &:last-child { + @include orange-button; + } + } + + .cancel-button { + @include tertiary-button; + + font-weight: $font-semibold; + display: inline-block; + padding: 0 10px 0 10px; + white-space: nowrap; + background: transparent; + } +} + +@media screen and (max-width: 600px) { + textarea, .comment-buttons button { + font-size: 14px !important; + } + + textarea { + padding: 5px !important; + } +} + +.modal-body { + .btn { + @include peertube-button; + @include orange-button; + } + + span { + float: left; + margin-bottom: 20px; + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts new file mode 100644 index 000000000..79505c779 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts @@ -0,0 +1,149 @@ +import { Observable } from 'rxjs' +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { Router } from '@angular/router' +import { Notifier, User } from '@app/core' +import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms' +import { Video } from '@app/shared/shared-main' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { VideoCommentCreate } from '@shared/models' +import { VideoComment } from './video-comment.model' +import { VideoCommentService } from './video-comment.service' + +@Component({ + selector: 'my-video-comment-add', + templateUrl: './video-comment-add.component.html', + styleUrls: ['./video-comment-add.component.scss'] +}) +export class VideoCommentAddComponent extends FormReactive implements OnInit { + @Input() user: User + @Input() video: Video + @Input() parentComment: VideoComment + @Input() parentComments: VideoComment[] + @Input() focusOnInit = false + + @Output() commentCreated = new EventEmitter() + @Output() cancel = new EventEmitter() + + @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal + @ViewChild('textarea', { static: true }) textareaElement: ElementRef + + addingComment = false + + constructor ( + protected formValidatorService: FormValidatorService, + private videoCommentValidatorsService: VideoCommentValidatorsService, + private notifier: Notifier, + private videoCommentService: VideoCommentService, + private modalService: NgbModal, + private router: Router + ) { + super() + } + + ngOnInit () { + this.buildForm({ + text: this.videoCommentValidatorsService.VIDEO_COMMENT_TEXT + }) + + if (this.user) { + if (this.focusOnInit === true) { + this.textareaElement.nativeElement.focus() + } + + if (this.parentComment) { + const mentions = this.parentComments + .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves + .map(c => '@' + c.by) + + const mentionsSet = new Set(mentions) + const mentionsText = Array.from(mentionsSet).join(' ') + ' ' + + this.form.patchValue({ text: mentionsText }) + } + } + } + + onValidKey () { + this.check() + if (!this.form.valid) return + + this.formValidated() + } + + openVisitorModal (event: any) { + if (this.user === null) { // we only open it for visitors + // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error + event.srcElement.blur() + event.preventDefault() + + this.modalService.open(this.visitorModal) + } + } + + hideVisitorModal () { + this.modalService.dismissAll() + } + + formValidated () { + // If we validate very quickly the comment form, we might comment twice + if (this.addingComment) return + + this.addingComment = true + + const commentCreate: VideoCommentCreate = this.form.value + let obs: Observable + + if (this.parentComment) { + obs = this.addCommentReply(commentCreate) + } else { + obs = this.addCommentThread(commentCreate) + } + + obs.subscribe( + comment => { + this.addingComment = false + this.commentCreated.emit(comment) + this.form.reset() + }, + + err => { + this.addingComment = false + + this.notifier.error(err.text) + } + ) + } + + isAddButtonDisplayed () { + return this.form.value['text'] + } + + getUri () { + return window.location.href + } + + getAvatarUrl () { + if (this.user) return this.user.accountAvatarUrl + return window.location.origin + '/client/assets/images/default-avatar.png' + } + + gotoLogin () { + this.hideVisitorModal() + this.router.navigate([ '/login' ]) + } + + cancelCommentReply () { + this.cancel.emit(null) + this.form.value['text'] = this.textareaElement.nativeElement.value = '' + } + + private addCommentReply (commentCreate: VideoCommentCreate) { + return this.videoCommentService + .addCommentReply(this.video.id, this.parentComment.id, commentCreate) + } + + private addCommentThread (commentCreate: VideoCommentCreate) { + return this.videoCommentService + .addCommentThread(this.video.id, commentCreate) + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts new file mode 100644 index 000000000..7c2aaeadd --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts @@ -0,0 +1,7 @@ +import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models' +import { VideoComment } from './video-comment.model' + +export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { + comment: VideoComment + children: VideoCommentThreadTree[] +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/comment/video-comment.component.html new file mode 100644 index 000000000..002de57e4 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.html @@ -0,0 +1,95 @@ +
+
+ + Avatar + + +
+
+ +
+ + +
+ +
Highlighted comment
+ + +
+ +
+
Reply
+
Delete
+ + +
+
+ + + + +
+ This comment has been deleted +
+
+ + + +
+
+ +
+
+ + +
+
+
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss new file mode 100644 index 000000000..e7ef79561 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss @@ -0,0 +1,189 @@ +@import '_variables'; +@import '_mixins'; + +.root-comment { + font-size: 15px; + display: flex; + + .left { + display: flex; + flex-direction: column; + align-items: center; + margin-right: 10px; + + .vertical-border { + width: 2px; + height: 100%; + background-color: rgba(0, 0, 0, 0.05); + margin: 10px calc(1rem + 1px); + } + } + + .right { + width: 100%; + } + + .comment-avatar { + @include avatar(36px); + } + + .comment { + flex-grow: 1; + // Fix word-wrap with flex + min-width: 1px; + + .highlighted-comment { + display: inline-block; + background-color: #F5F5F5; + color: #3d3d3d; + padding: 0 5px; + font-size: 13px; + margin-bottom: 5px; + font-weight: $font-semibold; + border-radius: 3px; + } + + .comment-account-date { + display: flex; + margin-bottom: 4px; + + .video-author { + height: 20px; + background-color: #888888; + border-radius: 12px; + margin-bottom: 2px; + max-width: 100%; + box-sizing: border-box; + flex-direction: row; + align-items: center; + display: inline-flex; + padding-right: 6px; + padding-left: 6px; + color: white !important; + } + + .comment-account { + word-break: break-all; + font-weight: 600; + font-size: 90%; + + a { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + } + + .comment-account-fid { + opacity: .6; + } + } + + .comment-date { + font-size: 90%; + color: pvar(--greyForegroundColor); + margin-left: 5px; + text-decoration: none; + } + } + + .comment-html { + @include peertube-word-wrap; + + // Mentions + ::ng-deep a { + + &:not(.linkified-url) { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + + font-weight: $font-semibold; + } + + } + + // Paragraphs + ::ng-deep p { + margin-bottom: .3rem; + } + + &.comment-html-deleted { + color: pvar(--greyForegroundColor); + margin-bottom: 1rem; + } + } + + .comment-actions { + margin-bottom: 10px; + display: flex; + + ::ng-deep .dropdown-toggle, + .comment-action-reply, + .comment-action-delete { + color: pvar(--greyForegroundColor); + cursor: pointer; + margin-right: 10px; + + &:hover { + color: pvar(--mainForegroundColor); + } + } + + ::ng-deep .action-button { + background-color: transparent; + padding: 0; + font-weight: unset; + } + } + + my-video-comment-add { + ::ng-deep form { + margin-top: 1rem; + margin-bottom: 0; + } + } + } + + .children { + // Reduce avatars size for replies + .comment-avatar { + @include avatar(25px); + } + + .left { + margin-right: 6px; + } + } +} + +@media screen and (max-width: 1200px) { + .children { + margin-left: -10px; + } +} + +@media screen and (max-width: 600px) { + .root-comment { + .children { + margin-left: -20px; + + .left { + align-items: flex-start; + + .vertical-border { + margin-left: 2px; + } + } + } + + .comment { + .comment-account-date { + flex-direction: column; + + .comment-date { + margin-left: 0; + } + } + } + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts new file mode 100644 index 000000000..27846c1ad --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts @@ -0,0 +1,131 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' +import { MarkdownService, Notifier, UserService } from '@app/core' +import { AuthService } from '@app/core/auth' +import { Account, Actor, Video } from '@app/shared/shared-main' +import { User, UserRight } from '@shared/models' +import { VideoCommentThreadTree } from './video-comment-thread-tree.model' +import { VideoComment } from './video-comment.model' + +@Component({ + selector: 'my-video-comment', + templateUrl: './video-comment.component.html', + styleUrls: ['./video-comment.component.scss'] +}) +export class VideoCommentComponent implements OnInit, OnChanges { + @Input() video: Video + @Input() comment: VideoComment + @Input() parentComments: VideoComment[] = [] + @Input() commentTree: VideoCommentThreadTree + @Input() inReplyToCommentId: number + @Input() highlightedComment = false + @Input() firstInThread = false + + @Output() wantedToDelete = new EventEmitter() + @Output() wantedToReply = new EventEmitter() + @Output() threadCreated = new EventEmitter() + @Output() resetReply = new EventEmitter() + @Output() timestampClicked = new EventEmitter() + + sanitizedCommentHTML = '' + newParentComments: VideoComment[] = [] + + commentAccount: Account + commentUser: User + + constructor ( + private markdownService: MarkdownService, + private authService: AuthService, + private userService: UserService, + private notifier: Notifier + ) {} + + get user () { + return this.authService.getUser() + } + + ngOnInit () { + this.init() + } + + ngOnChanges () { + this.init() + } + + onCommentReplyCreated (createdComment: VideoComment) { + if (!this.commentTree) { + this.commentTree = { + comment: this.comment, + children: [] + } + + this.threadCreated.emit(this.commentTree) + } + + this.commentTree.children.unshift({ + comment: createdComment, + children: [] + }) + this.resetReply.emit() + } + + onWantToReply (comment?: VideoComment) { + this.wantedToReply.emit(comment || this.comment) + } + + onWantToDelete (comment?: VideoComment) { + this.wantedToDelete.emit(comment || this.comment) + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + onResetReply () { + this.resetReply.emit() + } + + handleTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + + isRemovableByUser () { + return this.comment.account && this.isUserLoggedIn() && + ( + this.user.account.id === this.comment.account.id || + this.user.account.id === this.video.account.id || + this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) + ) + } + + switchToDefaultAvatar ($event: Event) { + ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() + } + + private getUserIfNeeded (account: Account) { + if (!account.userId) return + if (!this.authService.isLoggedIn()) return + + const user = this.authService.getUser() + if (user.hasRight(UserRight.MANAGE_USERS)) { + this.userService.getUserWithCache(account.userId) + .subscribe( + user => this.commentUser = user, + + err => this.notifier.error(err.message) + ) + } + } + + private async init () { + const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) + this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) + this.newParentComments = this.parentComments.concat([ this.comment ]) + + if (this.comment.account) { + this.commentAccount = new Account(this.comment.account) + this.getUserIfNeeded(this.commentAccount) + } else { + this.comment.account = null + } + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts new file mode 100644 index 000000000..e85443196 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts @@ -0,0 +1,48 @@ +import { getAbsoluteAPIUrl } from '@app/helpers' +import { Actor } from '@app/shared/shared-main' +import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models' + +export class VideoComment implements VideoCommentServerModel { + id: number + url: string + text: string + threadId: number + inReplyToCommentId: number + videoId: number + createdAt: Date | string + updatedAt: Date | string + deletedAt: Date | string + isDeleted: boolean + account: AccountInterface + totalRepliesFromVideoAuthor: number + totalReplies: number + by: string + accountAvatarUrl: string + + isLocal: boolean + + constructor (hash: VideoCommentServerModel) { + this.id = hash.id + this.url = hash.url + this.text = hash.text + this.threadId = hash.threadId + this.inReplyToCommentId = hash.inReplyToCommentId + this.videoId = hash.videoId + this.createdAt = new Date(hash.createdAt.toString()) + this.updatedAt = new Date(hash.updatedAt.toString()) + this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null + this.isDeleted = hash.isDeleted + this.account = hash.account + this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor + this.totalReplies = hash.totalReplies + + if (this.account) { + this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) + this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) + + const absoluteAPIUrl = getAbsoluteAPIUrl() + const thisHost = new URL(absoluteAPIUrl).host + this.isLocal = this.account.host.trim() === thisHost + } + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts new file mode 100644 index 000000000..a73fb9ca8 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts @@ -0,0 +1,149 @@ +import { Observable } from 'rxjs' +import { catchError, map } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' +import { objectLineFeedToHtml } from '@app/helpers' +import { + FeedFormat, + ResultList, + VideoComment as VideoCommentServerModel, + VideoCommentCreate, + VideoCommentThreadTree as VideoCommentThreadTreeServerModel +} from '@shared/models' +import { environment } from '../../../../environments/environment' +import { VideoCommentThreadTree } from './video-comment-thread-tree.model' +import { VideoComment } from './video-comment.model' + +@Injectable() +export class VideoCommentService { + private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' + private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService + ) {} + + addCommentThread (videoId: number | string, comment: VideoCommentCreate) { + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' + const normalizedComment = objectLineFeedToHtml(comment, 'text') + + return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) + .pipe( + map(data => this.extractVideoComment(data.comment)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) { + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId + const normalizedComment = objectLineFeedToHtml(comment, 'text') + + return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) + .pipe( + map(data => this.extractVideoComment(data.comment)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoCommentThreads (parameters: { + videoId: number | string, + componentPagination: ComponentPaginationLight, + sort: string + }): Observable> { + const { videoId, componentPagination, sort } = parameters + + const pagination = this.restService.componentPaginationToRestPagination(componentPagination) + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' + return this.authHttp.get>(url, { params }) + .pipe( + map(result => this.extractVideoComments(result)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoThreadComments (parameters: { + videoId: number | string, + threadId: number + }): Observable { + const { videoId, threadId } = parameters + const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` + + return this.authHttp + .get(url) + .pipe( + map(tree => this.extractVideoCommentTree(tree)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + deleteVideoComment (videoId: number | string, commentId: number) { + const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` + + return this.authHttp + .delete(url) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoCommentsFeeds (videoUUID?: string) { + const feeds = [ + { + format: FeedFormat.RSS, + label: 'rss 2.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase() + }, + { + format: FeedFormat.ATOM, + label: 'atom 1.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase() + }, + { + format: FeedFormat.JSON, + label: 'json 1.0', + url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase() + } + ] + + if (videoUUID !== undefined) { + for (const feed of feeds) { + feed.url += '?videoId=' + videoUUID + } + } + + return feeds + } + + private extractVideoComment (videoComment: VideoCommentServerModel) { + return new VideoComment(videoComment) + } + + private extractVideoComments (result: ResultList) { + const videoCommentsJson = result.data + const totalComments = result.total + const comments: VideoComment[] = [] + + for (const videoCommentJson of videoCommentsJson) { + comments.push(new VideoComment(videoCommentJson)) + } + + return { data: comments, total: totalComments } + } + + private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { + if (!tree) return tree as VideoCommentThreadTree + + tree.comment = new VideoComment(tree.comment) + tree.children.forEach(c => this.extractVideoCommentTree(c)) + + return tree as VideoCommentThreadTree + } +} diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/comment/video-comments.component.html new file mode 100644 index 000000000..dd1d43560 --- /dev/null +++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.html @@ -0,0 +1,98 @@ +
+
+

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

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

+ Other videos +

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

{{ video.name }}

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

{{ video.name }}

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

Discover

+
+ +
No results.
+ +
+ + +
+

+ {{ object.category.label }} +

+ +
+ + +
+
+ +
+

+ #{{ object.tag }} +

+ +
+ + +
+
+ + + +
+ +
+ +
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 @@ +@import '_variables'; +@import '_mixins'; +@import '_miniature'; + +.section-title { + // make the element span a full grid row within .videos grid + grid-column: 1 / -1; +} + +.margin-content { + @include fluid-videos-miniature-layout; +} + +.section { + @include miniature-rows; +} 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 @@ +import { Subject } from 'rxjs' +import { Component, OnInit } from '@angular/core' +import { Notifier, ScreenService, User, UserService } from '@app/core' +import { Video } from '@app/shared/shared-main' +import { OverviewService } from './overview.service' +import { VideosOverview } from './videos-overview.model' + +@Component({ + selector: 'my-video-overview', + templateUrl: './video-overview.component.html', + styleUrls: [ './video-overview.component.scss' ] +}) +export class VideoOverviewComponent implements OnInit { + onDataSubject = new Subject() + + overviews: VideosOverview[] = [] + notResults = false + + userMiniature: User + + private loaded = false + private currentPage = 1 + private maxPage = 20 + private lastWasEmpty = false + private isLoading = false + + constructor ( + private notifier: Notifier, + private userService: UserService, + private overviewService: OverviewService, + private screenService: ScreenService + ) { } + + ngOnInit () { + this.loadMoreResults() + + this.userService.getAnonymousOrLoggedUser() + .subscribe(user => this.userMiniature = user) + + this.userService.listenAnonymousUpdate() + .subscribe(user => this.userMiniature = user) + } + + buildVideoChannelBy (object: { videos: Video[] }) { + return object.videos[0].byVideoChannel + } + + buildVideoChannelAvatarUrl (object: { videos: Video[] }) { + return object.videos[0].videoChannelAvatarUrl + } + + buildVideos (videos: Video[]) { + const numberOfVideos = this.screenService.getNumberOfAvailableMiniatures() + + return videos.slice(0, numberOfVideos * 2) + } + + onNearOfBottom () { + if (this.currentPage >= this.maxPage) return + if (this.lastWasEmpty) return + if (this.isLoading) return + + this.currentPage++ + this.loadMoreResults() + } + + private loadMoreResults () { + this.isLoading = true + + this.overviewService.getVideosOverview(this.currentPage) + .subscribe( + overview => { + this.isLoading = false + + if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) { + this.lastWasEmpty = true + if (this.loaded === false) this.notResults = true + + return + } + + this.loaded = true + this.onDataSubject.next(overview) + + this.overviews.push(overview) + }, + + err => { + this.notifier.error(err.message) + this.isLoading = false + } + ) + } +} 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 @@ +import { Video } from '@app/shared/shared-main' +import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '@shared/models' + +export class VideosOverview implements VideosOverviewServer { + channels: { + channel: VideoChannelSummary + videos: Video[] + }[] + + categories: { + category: VideoConstant + videos: Video[] + }[] + + tags: { + tag: string + videos: Video[] + }[] + [key: string]: any +} 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 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { immutableAssign } from '@app/helpers' +import { VideoService } from '@app/shared/shared-main' +import { AbstractVideoList } from '@app/shared/shared-video-miniature' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { UserRight, VideoFilter, VideoSortField } from '@shared/models' + +@Component({ + selector: 'my-videos-local', + styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], + templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' +}) +export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { + titlePage: string + sort = '-publishedAt' as VideoSortField + filter: VideoFilter = 'local' + + useUserVideoPreferences = true + + constructor ( + protected i18n: I18n, + protected router: Router, + protected serverService: ServerService, + protected route: ActivatedRoute, + protected notifier: Notifier, + protected authService: AuthService, + protected userService: UserService, + protected screenService: ScreenService, + protected storageService: LocalStorageService, + private videoService: VideoService, + private hooks: HooksService + ) { + super() + + this.titlePage = i18n('Local videos') + } + + ngOnInit () { + super.ngOnInit() + + if (this.authService.isLoggedIn()) { + const user = this.authService.getUser() + this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) + } + + this.generateSyndicationList() + } + + ngOnDestroy () { + super.ngOnDestroy() + } + + getVideosObservable (page: number) { + const newPagination = immutableAssign(this.pagination, { currentPage: page }) + const params = { + videoPagination: newPagination, + sort: this.sort, + filter: this.filter, + categoryOneOf: this.categoryOneOf, + languageOneOf: this.languageOneOf, + nsfwPolicy: this.nsfwPolicy, + skipCount: true + } + + return this.hooks.wrapObsFun( + this.videoService.getVideos.bind(this.videoService), + params, + 'common', + 'filter:api.local-videos.videos.list.params', + 'filter:api.local-videos.videos.list.result' + ) + } + + generateSyndicationList () { + this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf) + } + + toggleModerationDisplay () { + this.filter = this.filter === 'local' ? 'all-local' as 'all-local' : 'local' as 'local' + + this.reloadVideos() + } +} 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 @@ +import { Component, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { immutableAssign } from '@app/helpers' +import { VideoService } from '@app/shared/shared-main' +import { AbstractVideoList } from '@app/shared/shared-video-miniature' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoSortField } from '@shared/models' + +@Component({ + selector: 'my-videos-most-liked', + styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], + templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' +}) +export class VideoMostLikedComponent extends AbstractVideoList implements OnInit { + titlePage: string + defaultSort: VideoSortField = '-likes' + + useUserVideoPreferences = true + + constructor ( + protected i18n: I18n, + protected router: Router, + protected serverService: ServerService, + protected route: ActivatedRoute, + protected notifier: Notifier, + protected authService: AuthService, + protected userService: UserService, + protected screenService: ScreenService, + protected storageService: LocalStorageService, + private videoService: VideoService, + private hooks: HooksService + ) { + super() + } + + ngOnInit () { + super.ngOnInit() + + this.generateSyndicationList() + + this.titlePage = this.i18n('Most liked videos') + this.titleTooltip = this.i18n('Videos that have the higher number of likes.') + } + + getVideosObservable (page: number) { + const newPagination = immutableAssign(this.pagination, { currentPage: page }) + const params = { + videoPagination: newPagination, + sort: this.sort, + categoryOneOf: this.categoryOneOf, + languageOneOf: this.languageOneOf, + nsfwPolicy: this.nsfwPolicy, + skipCount: true + } + + return this.hooks.wrapObsFun( + this.videoService.getVideos.bind(this.videoService), + params, + 'common', + 'filter:api.most-liked-videos.videos.list.params', + 'filter:api.most-liked-videos.videos.list.result' + ) + } + + generateSyndicationList () { + this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) + } +} 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 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { immutableAssign } from '@app/helpers' +import { VideoService } from '@app/shared/shared-main' +import { AbstractVideoList } from '@app/shared/shared-video-miniature' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoSortField } from '@shared/models' + +@Component({ + selector: 'my-videos-recently-added', + styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], + templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' +}) +export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { + titlePage: string + sort: VideoSortField = '-publishedAt' + groupByDate = true + + useUserVideoPreferences = true + + constructor ( + protected i18n: I18n, + protected route: ActivatedRoute, + protected serverService: ServerService, + protected router: Router, + protected notifier: Notifier, + protected authService: AuthService, + protected userService: UserService, + protected screenService: ScreenService, + protected storageService: LocalStorageService, + private videoService: VideoService, + private hooks: HooksService + ) { + super() + + this.titlePage = i18n('Recently added') + } + + ngOnInit () { + super.ngOnInit() + + this.generateSyndicationList() + } + + ngOnDestroy () { + super.ngOnDestroy() + } + + getVideosObservable (page: number) { + const newPagination = immutableAssign(this.pagination, { currentPage: page }) + const params = { + videoPagination: newPagination, + sort: this.sort, + categoryOneOf: this.categoryOneOf, + languageOneOf: this.languageOneOf, + nsfwPolicy: this.nsfwPolicy, + skipCount: true + } + + return this.hooks.wrapObsFun( + this.videoService.getVideos.bind(this.videoService), + params, + 'common', + 'filter:api.recently-added-videos.videos.list.params', + 'filter:api.recently-added-videos.videos.list.result' + ) + } + + generateSyndicationList () { + this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) + } +} 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 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { immutableAssign } from '@app/helpers' +import { VideoService } from '@app/shared/shared-main' +import { AbstractVideoList } from '@app/shared/shared-video-miniature' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoSortField } from '@shared/models' + +@Component({ + selector: 'my-videos-trending', + styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], + templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' +}) +export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { + titlePage: string + defaultSort: VideoSortField = '-trending' + + useUserVideoPreferences = true + + constructor ( + protected i18n: I18n, + protected router: Router, + protected serverService: ServerService, + protected route: ActivatedRoute, + protected notifier: Notifier, + protected authService: AuthService, + protected userService: UserService, + protected screenService: ScreenService, + protected storageService: LocalStorageService, + private videoService: VideoService, + private hooks: HooksService + ) { + super() + } + + ngOnInit () { + super.ngOnInit() + + this.generateSyndicationList() + + this.serverService.getConfig().subscribe( + config => { + const trendingDays = config.trending.videos.intervalDays + + if (trendingDays === 1) { + this.titlePage = this.i18n('Trending for the last 24 hours') + this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours') + } else { + this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays }) + this.titleTooltip = this.i18n( + 'Trending videos are those totalizing the greatest number of views during the last {{days}} days', + { days: trendingDays } + ) + } + }) + } + + ngOnDestroy () { + super.ngOnDestroy() + } + + getVideosObservable (page: number) { + const newPagination = immutableAssign(this.pagination, { currentPage: page }) + const params = { + videoPagination: newPagination, + sort: this.sort, + categoryOneOf: this.categoryOneOf, + languageOneOf: this.languageOneOf, + nsfwPolicy: this.nsfwPolicy, + skipCount: true + } + + return this.hooks.wrapObsFun( + this.videoService.getVideos.bind(this.videoService), + params, + 'common', + 'filter:api.trending-videos.videos.list.params', + 'filter:api.trending-videos.videos.list.result' + ) + } + + generateSyndicationList () { + this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) + } +} 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 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { immutableAssign } from '@app/helpers' +import { VideoService } from '@app/shared/shared-main' +import { UserSubscriptionService } from '@app/shared/shared-user-subscription' +import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoSortField } from '@shared/models' + +@Component({ + selector: 'my-videos-user-subscriptions', + styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], + templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' +}) +export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { + titlePage: string + sort = '-publishedAt' as VideoSortField + ownerDisplayType: OwnerDisplayType = 'auto' + groupByDate = true + + constructor ( + protected i18n: I18n, + protected router: Router, + protected serverService: ServerService, + protected route: ActivatedRoute, + protected notifier: Notifier, + protected authService: AuthService, + protected userService: UserService, + protected screenService: ScreenService, + protected storageService: LocalStorageService, + private userSubscription: UserSubscriptionService, + private videoService: VideoService, + private hooks: HooksService + ) { + super() + + this.titlePage = i18n('Videos from your subscriptions') + this.actions.push({ + routerLink: '/my-account/subscriptions', + label: i18n('Subscriptions'), + iconName: 'cog' + }) + } + + ngOnInit () { + super.ngOnInit() + } + + ngOnDestroy () { + super.ngOnDestroy() + } + + getVideosObservable (page: number) { + const newPagination = immutableAssign(this.pagination, { currentPage: page }) + const params = { + videoPagination: newPagination, + sort: this.sort, + skipCount: true + } + + return this.hooks.wrapObsFun( + this.userSubscription.getUserSubscriptionVideos.bind(this.userSubscription), + params, + 'common', + 'filter:api.user-subscriptions-videos.videos.list.params', + 'filter:api.user-subscriptions-videos.videos.list.result' + ) + } + + generateSyndicationList () { + // not implemented yet + } +} 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 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { MetaGuard } from '@ngx-meta/core' +import { VideoOverviewComponent } from './video-list/overview/video-overview.component' +import { VideoLocalComponent } from './video-list/video-local.component' +import { VideoMostLikedComponent } from './video-list/video-most-liked.component' +import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' +import { VideoTrendingComponent } from './video-list/video-trending.component' +import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' +import { VideosComponent } from './videos.component' + +const videosRoutes: Routes = [ + { + path: '', + component: VideosComponent, + canActivateChild: [ MetaGuard ], + children: [ + { + path: 'overview', + component: VideoOverviewComponent, + data: { + meta: { + title: 'Discover videos' + } + } + }, + { + path: 'trending', + component: VideoTrendingComponent, + data: { + meta: { + title: 'Trending videos' + }, + reuse: { + enabled: true, + key: 'trending-videos-list' + } + } + }, + { + path: 'most-liked', + component: VideoMostLikedComponent, + data: { + meta: { + title: 'Most liked videos' + }, + reuse: { + enabled: true, + key: 'most-liked-videos-list' + } + } + }, + { + path: 'recently-added', + component: VideoRecentlyAddedComponent, + data: { + meta: { + title: 'Recently added videos' + }, + reuse: { + enabled: true, + key: 'recently-added-videos-list' + } + } + }, + { + path: 'subscriptions', + component: VideoUserSubscriptionsComponent, + data: { + meta: { + title: 'Subscriptions' + }, + reuse: { + enabled: true, + key: 'subscription-videos-list' + } + } + }, + { + path: 'local', + component: VideoLocalComponent, + data: { + meta: { + title: 'Local videos' + }, + reuse: { + enabled: true, + key: 'local-videos-list' + } + } + }, + { + path: 'upload', + loadChildren: () => import('@app/+videos/+video-edit/video-add.module').then(m => m.VideoAddModule), + data: { + meta: { + title: 'Upload a video' + } + } + }, + { + path: 'update/:uuid', + loadChildren: () => import('@app/+videos/+video-edit/video-update.module').then(m => m.VideoUpdateModule), + data: { + meta: { + title: 'Edit a video' + } + } + }, + { + path: 'watch', + loadChildren: () => import('@app/+videos/+video-watch/video-watch.module').then(m => m.VideoWatchModule), + data: { + preload: 3000 + } + } + ] + } +] + +@NgModule({ + imports: [ RouterModule.forChild(videosRoutes) ], + exports: [ RouterModule ] +}) +export class VideosRoutingModule {} diff --git a/client/src/app/+videos/videos.component.ts b/client/src/app/+videos/videos.component.ts new file mode 100644 index 000000000..585a3ad9a --- /dev/null +++ b/client/src/app/+videos/videos.component.ts @@ -0,0 +1,6 @@ +import { Component } from '@angular/core' + +@Component({ + template: '' +}) +export class VideosComponent {} diff --git a/client/src/app/+videos/videos.module.ts b/client/src/app/+videos/videos.module.ts new file mode 100644 index 000000000..1cf68bf83 --- /dev/null +++ b/client/src/app/+videos/videos.module.ts @@ -0,0 +1,47 @@ +import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' +import { SharedGlobalIconModule } from '@app/shared/shared-icons' +import { SharedMainModule } from '@app/shared/shared-main' +import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' +import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' +import { OverviewService } from './video-list' +import { VideoOverviewComponent } from './video-list/overview/video-overview.component' +import { VideoLocalComponent } from './video-list/video-local.component' +import { VideoMostLikedComponent } from './video-list/video-most-liked.component' +import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' +import { VideoTrendingComponent } from './video-list/video-trending.component' +import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' +import { VideosRoutingModule } from './videos-routing.module' +import { VideosComponent } from './videos.component' + +@NgModule({ + imports: [ + VideosRoutingModule, + + SharedMainModule, + SharedFormModule, + SharedVideoMiniatureModule, + SharedUserSubscriptionModule, + SharedGlobalIconModule + ], + + declarations: [ + VideosComponent, + + VideoTrendingComponent, + VideoMostLikedComponent, + VideoRecentlyAddedComponent, + VideoLocalComponent, + VideoUserSubscriptionsComponent, + VideoOverviewComponent + ], + + exports: [ + VideosComponent + ], + + providers: [ + OverviewService + ] +}) +export class VideosModule { } -- cgit v1.2.3