From: Chocobozzz Date: Tue, 23 Jun 2020 12:49:20 +0000 (+0200) Subject: Lazy load all routes X-Git-Url: https://git.librecmc.org/?a=commitdiff_plain;h=1942f11d5ee6926ad93dc1b79fae18325ba5de18;p=oweals%2Fpeertube.git Lazy load all routes --- diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts index 9aa70288d..5db2887fa 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts @@ -3,6 +3,7 @@ import { Actor } from '@app/shared/shared-main' import { I18n } from '@ngx-translate/i18n-polyfill' import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model' import { ProcessedVideoAbuse } from './video-abuse-list.component' +import { durationToString } from '@app/helpers' @Component({ selector: 'my-video-abuse-details', diff --git a/client/src/app/+login/login-routing.module.ts b/client/src/app/+login/login-routing.module.ts new file mode 100644 index 000000000..aad55eac8 --- /dev/null +++ b/client/src/app/+login/login-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 { LoginComponent } from './login.component' +import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service' + +const loginRoutes: Routes = [ + { + path: '', + component: LoginComponent, + canActivate: [ MetaGuard ], + data: { + meta: { + title: 'Login' + } + }, + resolve: { + serverConfig: ServerConfigResolver + } + } +] + +@NgModule({ + imports: [ RouterModule.forChild(loginRoutes) ], + exports: [ RouterModule ] +}) +export class LoginRoutingModule {} diff --git a/client/src/app/+login/login.component.html b/client/src/app/+login/login.component.html new file mode 100644 index 000000000..599b203ae --- /dev/null +++ b/client/src/app/+login/login.component.html @@ -0,0 +1,114 @@ +
+
+ Login +
+ +
+ Sorry but there was an issue with the external login process. Please contact an administrator. +
+ + + + + + + + + +
+ + + + + + + + diff --git a/client/src/app/+login/login.component.scss b/client/src/app/+login/login.component.scss new file mode 100644 index 000000000..fde6cc15e --- /dev/null +++ b/client/src/app/+login/login.component.scss @@ -0,0 +1,66 @@ +@import '_variables'; +@import '_mixins'; + +label { + display: block; +} + +input:not([type=submit]) { + @include peertube-input-text(340px); + display: inline-block; + margin-right: 5px; + +} + +input[type=submit] { + @include peertube-button; + @include orange-button; +} + +.create-an-account, .forgot-password-button { + color: pvar(--mainForegroundColor); + cursor: pointer; + transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1); + + &:hover { + text-decoration: none !important; + opacity: .7 !important; + } +} + +.login-form-and-externals { + display: flex; + flex-wrap: wrap; + font-size: 15px; + + form { + margin: 0 50px 20px 0; + } + + .external-login-blocks { + min-width: 200px; + + .block-title { + font-weight: $font-semibold; + } + + .external-login-block { + @include disable-default-a-behaviour; + + cursor: pointer; + border: 1px solid #d1d7e0; + border-radius: 5px; + color: pvar(--mainForegroundColor); + margin: 10px 10px 0 0; + display: flex; + justify-content: center; + align-items: center; + min-height: 35px; + min-width: 100px; + + &:hover { + background-color: rgba(209, 215, 224, 0.5) + } + } + } +} diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts new file mode 100644 index 000000000..cbc51ee21 --- /dev/null +++ b/client/src/app/+login/login.component.ts @@ -0,0 +1,147 @@ +import { environment } from 'src/environments/environment' +import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { AuthService, Notifier, RedirectService, UserService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { FormReactive, FormValidatorService, LoginValidatorsService } from '@app/shared/shared-forms' +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' + +@Component({ + selector: 'my-login', + templateUrl: './login.component.html', + styleUrls: [ './login.component.scss' ] +}) + +export class LoginComponent extends FormReactive implements OnInit, AfterViewInit { + @ViewChild('usernameInput', { static: false }) usernameInput: ElementRef + @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef + + error: string = null + forgotPasswordEmail = '' + + isAuthenticatedWithExternalAuth = false + externalAuthError = false + externalLogins: string[] = [] + + private openedForgotPasswordModal: NgbModalRef + private serverConfig: ServerConfig + + constructor ( + protected formValidatorService: FormValidatorService, + private route: ActivatedRoute, + private modalService: NgbModal, + private loginValidatorsService: LoginValidatorsService, + private authService: AuthService, + private userService: UserService, + private redirectService: RedirectService, + private notifier: Notifier, + private hooks: HooksService, + private i18n: I18n + ) { + super() + } + + get signupAllowed () { + return this.serverConfig.signup.allowed === true + } + + isEmailDisabled () { + return this.serverConfig.email.enabled === false + } + + ngOnInit () { + const snapshot = this.route.snapshot + + this.serverConfig = snapshot.data.serverConfig + + if (snapshot.queryParams.externalAuthToken) { + this.loadExternalAuthToken(snapshot.queryParams.username, snapshot.queryParams.externalAuthToken) + return + } + + if (snapshot.queryParams.externalAuthError) { + this.externalAuthError = true + return + } + + this.buildForm({ + username: this.loginValidatorsService.LOGIN_USERNAME, + password: this.loginValidatorsService.LOGIN_PASSWORD + }) + } + + ngAfterViewInit () { + if (this.usernameInput) { + this.usernameInput.nativeElement.focus() + } + + this.hooks.runAction('action:login.init', 'login') + } + + getExternalLogins () { + return this.serverConfig.plugin.registeredExternalAuths + } + + getAuthHref (auth: RegisteredExternalAuthConfig) { + return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` + } + + login () { + this.error = null + + const { username, password } = this.form.value + + this.authService.login(username, password) + .subscribe( + () => this.redirectService.redirectToPreviousRoute(), + + err => this.handleError(err) + ) + } + + askResetPassword () { + this.userService.askResetPassword(this.forgotPasswordEmail) + .subscribe( + () => { + const message = this.i18n( + 'An email with the reset password instructions will be sent to {{email}}. The link will expire within 1 hour.', + { email: this.forgotPasswordEmail } + ) + this.notifier.success(message) + this.hideForgotPasswordModal() + }, + + err => this.notifier.error(err.message) + ) + } + + openForgotPasswordModal () { + this.openedForgotPasswordModal = this.modalService.open(this.forgotPasswordModal) + } + + hideForgotPasswordModal () { + this.openedForgotPasswordModal.close() + } + + private loadExternalAuthToken (username: string, token: string) { + this.isAuthenticatedWithExternalAuth = true + + this.authService.login(username, null, token) + .subscribe( + () => this.redirectService.redirectToPreviousRoute(), + + err => { + this.handleError(err) + this.isAuthenticatedWithExternalAuth = false + } + ) + } + + private handleError (err: any) { + if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.') + else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.') + else this.error = err.message + } +} diff --git a/client/src/app/+login/login.module.ts b/client/src/app/+login/login.module.ts new file mode 100644 index 000000000..c41902426 --- /dev/null +++ b/client/src/app/+login/login.module.ts @@ -0,0 +1,28 @@ +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 { LoginRoutingModule } from './login-routing.module' +import { LoginComponent } from './login.component' + +@NgModule({ + imports: [ + LoginRoutingModule, + + SharedMainModule, + SharedFormModule, + SharedGlobalIconModule + ], + + declarations: [ + LoginComponent + ], + + exports: [ + LoginComponent + ], + + providers: [ + ] +}) +export class LoginModule { } diff --git a/client/src/app/+reset-password/reset-password-routing.module.ts b/client/src/app/+reset-password/reset-password-routing.module.ts new file mode 100644 index 000000000..31bc08709 --- /dev/null +++ b/client/src/app/+reset-password/reset-password-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { MetaGuard } from '@ngx-meta/core' +import { ResetPasswordComponent } from './reset-password.component' + +const resetPasswordRoutes: Routes = [ + { + path: '', + component: ResetPasswordComponent, + canActivate: [ MetaGuard ], + data: { + meta: { + title: 'Reset password' + } + } + } +] + +@NgModule({ + imports: [ RouterModule.forChild(resetPasswordRoutes) ], + exports: [ RouterModule ] +}) +export class ResetPasswordRoutingModule {} diff --git a/client/src/app/+reset-password/reset-password.component.html b/client/src/app/+reset-password/reset-password.component.html new file mode 100644 index 000000000..af30af4a0 --- /dev/null +++ b/client/src/app/+reset-password/reset-password.component.html @@ -0,0 +1,31 @@ +
+
+ Reset my password +
+ +
+
+ + +
+ {{ formErrors.password }} +
+
+ +
+ + +
+ {{ formErrors['password-confirm'] }} +
+
+ + +
+
diff --git a/client/src/app/+reset-password/reset-password.component.scss b/client/src/app/+reset-password/reset-password.component.scss new file mode 100644 index 000000000..efec6b706 --- /dev/null +++ b/client/src/app/+reset-password/reset-password.component.scss @@ -0,0 +1,12 @@ +@import '_variables'; +@import '_mixins'; + +input:not([type=submit]) { + @include peertube-input-text(340px); + display: block; +} + +input[type=submit] { + @include peertube-button; + @include orange-button; +} diff --git a/client/src/app/+reset-password/reset-password.component.ts b/client/src/app/+reset-password/reset-password.component.ts new file mode 100644 index 000000000..8d50e9839 --- /dev/null +++ b/client/src/app/+reset-password/reset-password.component.ts @@ -0,0 +1,61 @@ +import { Component, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { Notifier, UserService } from '@app/core' +import { FormReactive, FormValidatorService, ResetPasswordValidatorsService, UserValidatorsService } from '@app/shared/shared-forms' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-login', + templateUrl: './reset-password.component.html', + styleUrls: [ './reset-password.component.scss' ] +}) + +export class ResetPasswordComponent extends FormReactive implements OnInit { + private userId: number + private verificationString: string + + constructor ( + protected formValidatorService: FormValidatorService, + private resetPasswordValidatorsService: ResetPasswordValidatorsService, + private userValidatorsService: UserValidatorsService, + private userService: UserService, + private notifier: Notifier, + private router: Router, + private route: ActivatedRoute, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.buildForm({ + password: this.userValidatorsService.USER_PASSWORD, + 'password-confirm': this.resetPasswordValidatorsService.RESET_PASSWORD_CONFIRM + }) + + this.userId = this.route.snapshot.queryParams['userId'] + this.verificationString = this.route.snapshot.queryParams['verificationString'] + + if (!this.userId || !this.verificationString) { + this.notifier.error(this.i18n('Unable to find user id or verification string.')) + this.router.navigate([ '/' ]) + } + } + + resetPassword () { + this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password) + .subscribe( + () => { + this.notifier.success(this.i18n('Your password has been successfully reset!')) + this.router.navigate([ '/login' ]) + }, + + err => this.notifier.error(err.message) + ) + } + + isConfirmedPasswordValid () { + const values = this.form.value + return values.password === values['password-confirm'] + } +} diff --git a/client/src/app/+reset-password/reset-password.module.ts b/client/src/app/+reset-password/reset-password.module.ts new file mode 100644 index 000000000..c77f1c4b0 --- /dev/null +++ b/client/src/app/+reset-password/reset-password.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' +import { SharedMainModule } from '@app/shared/shared-main' +import { ResetPasswordRoutingModule } from './reset-password-routing.module' +import { ResetPasswordComponent } from './reset-password.component' + +@NgModule({ + imports: [ + ResetPasswordRoutingModule, + + SharedMainModule, + SharedFormModule + ], + + declarations: [ + ResetPasswordComponent + ], + + exports: [ + ResetPasswordComponent + ], + + providers: [ + ] +}) +export class ResetPasswordModule { } diff --git a/client/src/app/+search/channel-lazy-load.resolver.ts b/client/src/app/+search/channel-lazy-load.resolver.ts new file mode 100644 index 000000000..17a212829 --- /dev/null +++ b/client/src/app/+search/channel-lazy-load.resolver.ts @@ -0,0 +1,43 @@ +import { map } from 'rxjs/operators' +import { Injectable } from '@angular/core' +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' +import { SearchService } from '@app/shared/shared-search' + +@Injectable() +export class ChannelLazyLoadResolver implements Resolve { + constructor ( + private router: Router, + private searchService: SearchService + ) { } + + resolve (route: ActivatedRouteSnapshot) { + const url = route.params.url + const externalRedirect = route.params.externalRedirect + const fromPath = route.params.fromPath + + if (!url) { + console.error('Could not find url param.', { params: route.params }) + return this.router.navigateByUrl('/404') + } + + if (externalRedirect === 'true') { + window.open(url) + this.router.navigateByUrl(fromPath) + return + } + + return this.searchService.searchVideoChannels({ search: url }) + .pipe( + map(result => { + if (result.data.length !== 1) { + console.error('Cannot find result for this URL') + return this.router.navigateByUrl('/404') + } + + const channel = result.data[0] + + return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost) + }) + ) + } +} diff --git a/client/src/app/+search/search-filters.component.html b/client/src/app/+search/search-filters.component.html new file mode 100644 index 000000000..e20aef8fb --- /dev/null +++ b/client/src/app/+search/search-filters.component.html @@ -0,0 +1,193 @@ +
+ +
+
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+ +
+
+ + + +
+ +
+ + + +
+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+ + + +
+
diff --git a/client/src/app/+search/search-filters.component.scss b/client/src/app/+search/search-filters.component.scss new file mode 100644 index 000000000..a88a1c0b0 --- /dev/null +++ b/client/src/app/+search/search-filters.component.scss @@ -0,0 +1,69 @@ +@import '_variables'; +@import '_mixins'; + +form { + margin-top: 40px; +} + +.radio-label { + font-size: 15px; + font-weight: $font-bold; +} + +.peertube-radio-container { + @include peertube-radio-container; + + display: inline-block; + margin-right: 30px; +} + +.peertube-select-container { + @include peertube-select-container(auto); + + margin-bottom: 1rem; +} + +.form-group { + margin-bottom: 25px; +} + +input[type=text] { + @include peertube-input-text(100%); + display: block; +} + +input[type=submit] { + @include peertube-button-link; + @include orange-button; +} + +.submit-button { + text-align: right; +} + +.reset-button { + @include peertube-button; + + font-weight: $font-semibold; + display: inline-block; + padding: 0 10px 0 10px; + white-space: nowrap; + background: transparent; + + margin-right: 1rem; +} + +.reset-button-small { + font-size: 80%; + height: unset; + line-height: unset; + margin: unset; + margin-bottom: 0.5rem; +} + +.label-container { + display: flex; + white-space: nowrap; +} + +@include ng2-tags; diff --git a/client/src/app/+search/search-filters.component.ts b/client/src/app/+search/search-filters.component.ts new file mode 100644 index 000000000..fc1db3258 --- /dev/null +++ b/client/src/app/+search/search-filters.component.ts @@ -0,0 +1,269 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' +import { ValidatorFn } from '@angular/forms' +import { ServerService } from '@app/core' +import { VideoValidatorsService } from '@app/shared/shared-forms' +import { AdvancedSearch } from '@app/shared/shared-search' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { ServerConfig, VideoConstant } from '@shared/models' + +@Component({ + selector: 'my-search-filters', + styleUrls: [ './search-filters.component.scss' ], + templateUrl: './search-filters.component.html' +}) +export class SearchFiltersComponent implements OnInit { + @Input() advancedSearch: AdvancedSearch = new AdvancedSearch() + + @Output() filtered = new EventEmitter() + + videoCategories: VideoConstant[] = [] + videoLicences: VideoConstant[] = [] + videoLanguages: VideoConstant[] = [] + + tagValidators: ValidatorFn[] + tagValidatorsMessages: { [ name: string ]: string } + + publishedDateRanges: { id: string, label: string }[] = [] + sorts: { id: string, label: string }[] = [] + durationRanges: { id: string, label: string }[] = [] + + publishedDateRange: string + durationRange: string + + originallyPublishedStartYear: string + originallyPublishedEndYear: string + + private serverConfig: ServerConfig + + constructor ( + private i18n: I18n, + private videoValidatorsService: VideoValidatorsService, + private serverService: ServerService + ) { + this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS + this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES + this.publishedDateRanges = [ + { + id: 'any_published_date', + label: this.i18n('Any') + }, + { + id: 'today', + label: this.i18n('Today') + }, + { + id: 'last_7days', + label: this.i18n('Last 7 days') + }, + { + id: 'last_30days', + label: this.i18n('Last 30 days') + }, + { + id: 'last_365days', + label: this.i18n('Last 365 days') + } + ] + + this.durationRanges = [ + { + id: 'any_duration', + label: this.i18n('Any') + }, + { + id: 'short', + label: this.i18n('Short (< 4 min)') + }, + { + id: 'medium', + label: this.i18n('Medium (4-10 min)') + }, + { + id: 'long', + label: this.i18n('Long (> 10 min)') + } + ] + + this.sorts = [ + { + id: '-match', + label: this.i18n('Relevance') + }, + { + id: '-publishedAt', + label: this.i18n('Publish date') + }, + { + id: '-views', + label: this.i18n('Views') + } + ] + } + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.serverService.getVideoCategories().subscribe(categories => this.videoCategories = categories) + this.serverService.getVideoLicences().subscribe(licences => this.videoLicences = licences) + this.serverService.getVideoLanguages().subscribe(languages => this.videoLanguages = languages) + + this.loadFromDurationRange() + this.loadFromPublishedRange() + this.loadOriginallyPublishedAtYears() + } + + inputUpdated () { + this.updateModelFromDurationRange() + this.updateModelFromPublishedRange() + this.updateModelFromOriginallyPublishedAtYears() + } + + formUpdated () { + this.inputUpdated() + this.filtered.emit(this.advancedSearch) + } + + reset () { + this.advancedSearch.reset() + this.durationRange = undefined + this.publishedDateRange = undefined + this.originallyPublishedStartYear = undefined + this.originallyPublishedEndYear = undefined + this.inputUpdated() + } + + resetField (fieldName: string, value?: any) { + this.advancedSearch[fieldName] = value + } + + resetLocalField (fieldName: string, value?: any) { + this[fieldName] = value + this.inputUpdated() + } + + resetOriginalPublicationYears () { + this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined + } + + isSearchTargetEnabled () { + return this.serverConfig.search.searchIndex.enabled && this.serverConfig.search.searchIndex.disableLocalSearch !== true + } + + private loadOriginallyPublishedAtYears () { + this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate + ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString() + : null + + this.originallyPublishedEndYear = this.advancedSearch.originallyPublishedEndDate + ? new Date(this.advancedSearch.originallyPublishedEndDate).getFullYear().toString() + : null + } + + private loadFromDurationRange () { + if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) { + const fourMinutes = 60 * 4 + const tenMinutes = 60 * 10 + + if (this.advancedSearch.durationMin === fourMinutes && this.advancedSearch.durationMax === tenMinutes) { + this.durationRange = 'medium' + } else if (this.advancedSearch.durationMax === fourMinutes) { + this.durationRange = 'short' + } else if (this.advancedSearch.durationMin === tenMinutes) { + this.durationRange = 'long' + } + } + } + + private loadFromPublishedRange () { + if (this.advancedSearch.startDate) { + const date = new Date(this.advancedSearch.startDate) + const now = new Date() + + const diff = Math.abs(date.getTime() - now.getTime()) + + const dayMS = 1000 * 3600 * 24 + const numberOfDays = diff / dayMS + + if (numberOfDays >= 365) this.publishedDateRange = 'last_365days' + else if (numberOfDays >= 30) this.publishedDateRange = 'last_30days' + else if (numberOfDays >= 7) this.publishedDateRange = 'last_7days' + else if (numberOfDays >= 0) this.publishedDateRange = 'today' + } + } + + private updateModelFromOriginallyPublishedAtYears () { + const baseDate = new Date() + baseDate.setHours(0, 0, 0, 0) + baseDate.setMonth(0, 1) + + if (this.originallyPublishedStartYear) { + const year = parseInt(this.originallyPublishedStartYear, 10) + const start = new Date(baseDate) + start.setFullYear(year) + + this.advancedSearch.originallyPublishedStartDate = start.toISOString() + } else { + this.advancedSearch.originallyPublishedStartDate = null + } + + if (this.originallyPublishedEndYear) { + const year = parseInt(this.originallyPublishedEndYear, 10) + const end = new Date(baseDate) + end.setFullYear(year) + + this.advancedSearch.originallyPublishedEndDate = end.toISOString() + } else { + this.advancedSearch.originallyPublishedEndDate = null + } + } + + private updateModelFromDurationRange () { + if (!this.durationRange) return + + const fourMinutes = 60 * 4 + const tenMinutes = 60 * 10 + + switch (this.durationRange) { + case 'short': + this.advancedSearch.durationMin = undefined + this.advancedSearch.durationMax = fourMinutes + break + + case 'medium': + this.advancedSearch.durationMin = fourMinutes + this.advancedSearch.durationMax = tenMinutes + break + + case 'long': + this.advancedSearch.durationMin = tenMinutes + this.advancedSearch.durationMax = undefined + break + } + } + + private updateModelFromPublishedRange () { + if (!this.publishedDateRange) return + + // today + const date = new Date() + date.setHours(0, 0, 0, 0) + + switch (this.publishedDateRange) { + case 'last_7days': + date.setDate(date.getDate() - 7) + break + + case 'last_30days': + date.setDate(date.getDate() - 30) + break + + case 'last_365days': + date.setDate(date.getDate() - 365) + break + } + + this.advancedSearch.startDate = date.toISOString() + } +} diff --git a/client/src/app/+search/search-routing.module.ts b/client/src/app/+search/search-routing.module.ts new file mode 100644 index 000000000..14a0d0a13 --- /dev/null +++ b/client/src/app/+search/search-routing.module.ts @@ -0,0 +1,41 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { MetaGuard } from '@ngx-meta/core' +import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' +import { SearchComponent } from './search.component' +import { VideoLazyLoadResolver } from './video-lazy-load.resolver' + +const searchRoutes: Routes = [ + { + path: '', + component: SearchComponent, + canActivate: [ MetaGuard ], + data: { + meta: { + title: 'Search' + } + } + }, + { + path: 'lazy-load-video', + component: SearchComponent, + canActivate: [ MetaGuard ], + resolve: { + data: VideoLazyLoadResolver + } + }, + { + path: 'lazy-load-channel', + component: SearchComponent, + canActivate: [ MetaGuard ], + resolve: { + data: ChannelLazyLoadResolver + } + } +] + +@NgModule({ + imports: [ RouterModule.forChild(searchRoutes) ], + exports: [ RouterModule ] +}) +export class SearchRoutingModule {} diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html new file mode 100644 index 000000000..9bff024ad --- /dev/null +++ b/client/src/app/+search/search.component.html @@ -0,0 +1,63 @@ +
+
+
+
+ {{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} + + on this instance + on the vidiverse + + + for {{ currentSearch }} + +
+ +
+ + + Filters + {{ numberOfFilters() }} + +
+
+ +
+ +
+
+ +
+ No results found +
+ + +
+ + Avatar + + +
+ +
{{ result.displayName }}
+
{{ result.nameWithHost }}
+
+ +
{{ result.followersCount }} subscribers
+
+ + +
+ +
+ +
+
+ +
diff --git a/client/src/app/+search/search.component.scss b/client/src/app/+search/search.component.scss new file mode 100644 index 000000000..6e59adb60 --- /dev/null +++ b/client/src/app/+search/search.component.scss @@ -0,0 +1,191 @@ +@import '_variables'; +@import '_mixins'; + +.search-result { + padding: 40px; + + .results-header { + font-size: 16px; + padding-bottom: 20px; + margin-bottom: 30px; + border-bottom: 1px solid #DADADA; + + .first-line { + display: flex; + flex-direction: row; + + .results-counter { + flex-grow: 1; + + .search-value { + font-weight: $font-semibold; + } + } + + .results-filter-button { + cursor: pointer; + + .icon.icon-filter { + @include icon(20px); + + position: relative; + top: -1px; + margin-right: 5px; + background-image: url('../../assets/images/search/filter.svg'); + } + } + } + } + + .entry { + display: flex; + min-height: 130px; + padding-bottom: 20px; + margin-bottom: 20px; + + &.video-channel { + img { + $image-size: 130px; + $margin-size: ($video-thumbnail-width - $image-size) / 2; // So we have the same width than the video miniature + + @include avatar($image-size); + + margin: 0 ($margin-size + 10) 0 $margin-size; + } + + .video-channel-info { + flex-grow: 1; + width: fit-content; + + .video-channel-names { + @include disable-default-a-behaviour; + + display: flex; + align-items: baseline; + color: pvar(--mainForegroundColor); + width: fit-content; + + .video-channel-display-name { + font-weight: $font-semibold; + font-size: 18px; + } + + .video-channel-name { + font-size: 14px; + color: $grey-actor-name; + margin-left: 5px; + } + } + } + } + } +} + +@media screen and (min-width: $small-view) and (max-width: breakpoint(xl)) { + .video-channel-info .video-channel-names { + flex-direction: column !important; + + .video-channel-name { + @include ellipsis; // Ellipsis and max-width on channel-name to not break screen + + max-width: 250px; + margin-left: 0 !important; + } + } + + :host-context(.main-col:not(.expanded)) { + // Override the min-width: 500px to not break screen + ::ng-deep .video-miniature-information { + min-width: 300px !important; + } + } +} + +@media screen and (min-width: $small-view) and (max-width: breakpoint(lg)) { + :host-context(.main-col:not(.expanded)) { + .video-channel-info .video-channel-names { + .video-channel-name { + max-width: 160px; + } + } + + // Override the min-width: 500px to not break screen + ::ng-deep .video-miniature-information { + min-width: $video-thumbnail-width !important; + } + } + + :host-context(.expanded) { + // Override the min-width: 500px to not break screen + ::ng-deep .video-miniature-information { + min-width: 300px !important; + } + } +} + +@media screen and (max-width: $small-view) { + .search-result { + .entry.video-channel, + .entry.video { + flex-direction: column; + height: auto; + justify-content: center; + align-items: center; + text-align: center; + + img { + margin: 0; + } + + img { + margin: 0; + } + + .video-channel-info .video-channel-names { + align-items: center; + flex-direction: column !important; + + .video-channel-name { + margin-left: 0 !important; + } + } + + my-subscribe-button { + margin-top: 5px; + } + } + } +} + +@media screen and (max-width: $mobile-view) { + .search-result { + padding: 20px 10px; + + .results-header { + font-size: 15px !important; + } + + .entry { + &.video { + .video-info-name, + .video-info-account { + margin: auto; + } + + my-video-thumbnail { + margin-right: 0 !important; + + ::ng-deep .video-thumbnail { + width: 100%; + height: auto; + + img { + width: 100%; + height: auto; + } + } + } + } + } + } +} diff --git a/client/src/app/+search/search.component.ts b/client/src/app/+search/search.component.ts new file mode 100644 index 000000000..1ed54937b --- /dev/null +++ b/client/src/app/+search/search.component.ts @@ -0,0 +1,259 @@ +import { forkJoin, of, Subscription } from 'rxjs' +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core' +import { immutableAssign } from '@app/helpers' +import { Video, VideoChannel } from '@app/shared/shared-main' +import { AdvancedSearch, SearchService } from '@app/shared/shared-search' +import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' +import { MetaService } from '@ngx-meta/core' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { SearchTargetType, ServerConfig } from '@shared/models' + +@Component({ + selector: 'my-search', + styleUrls: [ './search.component.scss' ], + templateUrl: './search.component.html' +}) +export class SearchComponent implements OnInit, OnDestroy { + results: (Video | VideoChannel)[] = [] + + pagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 10, // Only for videos, use another variable for channels + totalItems: null + } + advancedSearch: AdvancedSearch = new AdvancedSearch() + isSearchFilterCollapsed = true + currentSearch: string + + videoDisplayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: false, + privacyLabel: false, + privacyText: false, + state: false, + blacklistInfo: false + } + + errorMessage: string + serverConfig: ServerConfig + + userMiniature: User + + private subActivatedRoute: Subscription + private isInitialLoad = false // set to false to show the search filters on first arrival + private firstSearch = true + + private channelsPerPage = 2 + + private lastSearchTarget: SearchTargetType + + constructor ( + private i18n: I18n, + private route: ActivatedRoute, + private router: Router, + private metaService: MetaService, + private notifier: Notifier, + private searchService: SearchService, + private authService: AuthService, + private userService: UserService, + private hooks: HooksService, + private serverService: ServerService + ) { } + + ngOnInit () { + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.subActivatedRoute = this.route.queryParams.subscribe( + async queryParams => { + const querySearch = queryParams['search'] + const searchTarget = queryParams['searchTarget'] + + // Search updated, reset filters + if (this.currentSearch !== querySearch || searchTarget !== this.advancedSearch.searchTarget) { + this.resetPagination() + this.advancedSearch.reset() + + this.currentSearch = querySearch || undefined + this.updateTitle() + } + + this.advancedSearch = new AdvancedSearch(queryParams) + if (!this.advancedSearch.searchTarget) { + this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget() + } + + // Don't hide filters if we have some of them AND the user just came on the webpage + this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues() + this.isInitialLoad = false + + this.search() + }, + + err => this.notifier.error(err.text) + ) + + this.userService.getAnonymousOrLoggedUser() + .subscribe(user => this.userMiniature = user) + + this.hooks.runAction('action:search.init', 'search') + } + + ngOnDestroy () { + if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() + } + + isVideoChannel (d: VideoChannel | Video): d is VideoChannel { + return d instanceof VideoChannel + } + + isVideo (v: VideoChannel | Video): v is Video { + return v instanceof Video + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + search () { + forkJoin([ + this.getVideosObs(), + this.getVideoChannelObs() + ]).subscribe( + ([videosResult, videoChannelsResult]) => { + this.results = this.results + .concat(videoChannelsResult.data) + .concat(videosResult.data) + + this.pagination.totalItems = videosResult.total + videoChannelsResult.total + this.lastSearchTarget = this.advancedSearch.searchTarget + + // Focus on channels if there are no enough videos + if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) { + this.resetPagination() + this.firstSearch = false + + this.channelsPerPage = 10 + this.search() + } + + this.firstSearch = false + }, + + err => { + if (this.advancedSearch.searchTarget !== 'search-index') { + this.notifier.error(err.message) + return + } + + this.notifier.error( + this.i18n('Search index is unavailable. Retrying with instance results instead.'), + this.i18n('Search error') + ) + this.advancedSearch.searchTarget = 'local' + this.search() + } + ) + } + + onNearOfBottom () { + // Last page + if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return + + this.pagination.currentPage += 1 + this.search() + } + + onFiltered () { + this.resetPagination() + + this.updateUrlFromAdvancedSearch() + } + + numberOfFilters () { + return this.advancedSearch.size() + } + + // Add VideoChannel for typings, but the template already checks "video" argument is a video + removeVideoFromArray (video: Video | VideoChannel) { + this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) + } + + getChannelUrl (channel: VideoChannel) { + if (this.advancedSearch.searchTarget === 'search-index' && channel.url) { + const remoteUriConfig = this.serverConfig.search.remoteUri + + // Redirect on the external instance if not allowed to fetch remote data + const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users + const fromPath = window.location.pathname + window.location.search + + return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ] + } + + return [ '/video-channels', channel.nameWithHost ] + } + + hideActions () { + return this.lastSearchTarget === 'search-index' + } + + private resetPagination () { + this.pagination.currentPage = 1 + this.pagination.totalItems = null + this.channelsPerPage = 2 + + this.results = [] + } + + private updateTitle () { + const suffix = this.currentSearch ? ' ' + this.currentSearch : '' + this.metaService.setTitle(this.i18n('Search') + suffix) + } + + private updateUrlFromAdvancedSearch () { + const search = this.currentSearch || undefined + + this.router.navigate([], { + relativeTo: this.route, + queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search }) + }) + } + + private getVideosObs () { + const params = { + search: this.currentSearch, + componentPagination: this.pagination, + advancedSearch: this.advancedSearch + } + + return this.hooks.wrapObsFun( + this.searchService.searchVideos.bind(this.searchService), + params, + 'search', + 'filter:api.search.videos.list.params', + 'filter:api.search.videos.list.result' + ) + } + + private getVideoChannelObs () { + if (!this.currentSearch) return of({ data: [], total: 0 }) + + const params = { + search: this.currentSearch, + componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }), + searchTarget: this.advancedSearch.searchTarget + } + + return this.hooks.wrapObsFun( + this.searchService.searchVideoChannels.bind(this.searchService), + params, + 'search', + 'filter:api.search.video-channels.list.params', + 'filter:api.search.video-channels.list.result' + ) + } +} diff --git a/client/src/app/+search/search.module.ts b/client/src/app/+search/search.module.ts new file mode 100644 index 000000000..ee4f07ad1 --- /dev/null +++ b/client/src/app/+search/search.module.ts @@ -0,0 +1,44 @@ +import { TagInputModule } from 'ngx-chips' +import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' +import { SharedMainModule } from '@app/shared/shared-main' +import { SharedSearchModule } from '@app/shared/shared-search' +import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' +import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' +import { SearchService } from '../shared/shared-search/search.service' +import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' +import { SearchFiltersComponent } from './search-filters.component' +import { SearchRoutingModule } from './search-routing.module' +import { SearchComponent } from './search.component' +import { VideoLazyLoadResolver } from './video-lazy-load.resolver' + +@NgModule({ + imports: [ + TagInputModule, + + SearchRoutingModule, + + SharedMainModule, + SharedSearchModule, + SharedFormModule, + SharedUserSubscriptionModule, + SharedVideoMiniatureModule + ], + + declarations: [ + SearchComponent, + SearchFiltersComponent + ], + + exports: [ + TagInputModule, + SearchComponent + ], + + providers: [ + SearchService, + VideoLazyLoadResolver, + ChannelLazyLoadResolver + ] +}) +export class SearchModule { } diff --git a/client/src/app/+search/video-lazy-load.resolver.ts b/client/src/app/+search/video-lazy-load.resolver.ts new file mode 100644 index 000000000..e8b2b8c74 --- /dev/null +++ b/client/src/app/+search/video-lazy-load.resolver.ts @@ -0,0 +1,43 @@ +import { map } from 'rxjs/operators' +import { Injectable } from '@angular/core' +import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' +import { SearchService } from '@app/shared/shared-search' + +@Injectable() +export class VideoLazyLoadResolver implements Resolve { + constructor ( + private router: Router, + private searchService: SearchService + ) { } + + resolve (route: ActivatedRouteSnapshot) { + const url = route.params.url + const externalRedirect = route.params.externalRedirect + const fromPath = route.params.fromPath + + if (!url) { + console.error('Could not find url param.', { params: route.params }) + return this.router.navigateByUrl('/404') + } + + if (externalRedirect === 'true') { + window.open(url) + this.router.navigateByUrl(fromPath) + return + } + + return this.searchService.searchVideos({ search: url }) + .pipe( + map(result => { + if (result.data.length !== 1) { + console.error('Cannot find result for this URL') + return this.router.navigateByUrl('/404') + } + + const video = result.data[0] + + return this.router.navigateByUrl('/videos/watch/' + video.uuid) + }) + ) + } +} 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 { } diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index ceda41415..a39be17dc 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts @@ -4,6 +4,7 @@ import { AppComponent } from '@app/app.component' import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy' import { MenuGuards } from '@app/core/routing/menu-guard.service' import { PreloadSelectedModulesList } from './core' +import { EmptyComponent } from './empty.component' const routes: Routes = [ { @@ -36,9 +37,25 @@ const routes: Routes = [ path: 'signup', loadChildren: () => import('./+signup/+register/register.module').then(m => m.RegisterModule) }, + { + path: 'reset-password', + loadChildren: () => import('./+reset-password/reset-password.module').then(m => m.ResetPasswordModule) + }, + { + path: 'login', + loadChildren: () => import('./+login/login.module').then(m => m.LoginModule) + }, + { + path: 'search', + loadChildren: () => import('./+search/search.module').then(m => m.SearchModule) + }, + { + path: 'videos', + loadChildren: () => import('./+videos/videos.module').then(m => m.VideosModule) + }, { path: '', - component: AppComponent // Avoid 404, app component will redirect dynamically + component: EmptyComponent // Avoid 404, app component will redirect dynamically }, { path: '**', diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index 7fbc6463b..c6e9d7315 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -4,15 +4,13 @@ import { LOCALE_ID, NgModule, TRANSLATIONS, TRANSLATIONS_FORMAT } from '@angular import { BrowserModule } from '@angular/platform-browser' import { ServerService } from '@app/core' import localeOc from '@app/helpers/locales/oc' -import { ResetPasswordModule } from '@app/reset-password' -import { SearchModule } from '@app/search' import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models' import { AppRoutingModule } from './app-routing.module' import { AppComponent } from './app.component' import { CoreModule } from './core' import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header' -import { LoginModule } from './login' +import { HighlightPipe } from './header/highlight.pipe' import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' import { ConfirmComponent } from './modal/confirm.component' import { CustomModalComponent } from './modal/custom-modal.component' @@ -24,7 +22,6 @@ import { SharedGlobalIconModule } from './shared/shared-icons' import { SharedInstanceModule } from './shared/shared-instance' import { SharedMainModule } from './shared/shared-main' import { SharedUserInterfaceSettingsModule } from './shared/shared-user-settings' -import { VideosModule } from './videos' registerLocaleData(localeOc, 'oc') @@ -41,6 +38,7 @@ registerLocaleData(localeOc, 'oc') HeaderComponent, SearchTypeaheadComponent, SuggestionComponent, + HighlightPipe, CustomModalComponent, WelcomeModalComponent, @@ -58,12 +56,6 @@ registerLocaleData(localeOc, 'oc') SharedGlobalIconModule, SharedInstanceModule, - LoginModule, - ResetPasswordModule, - SearchModule, - - VideosModule, - MetaModule.forRoot({ provide: MetaLoader, useFactory: (serverService: ServerService) => { diff --git a/client/src/app/empty.component.ts b/client/src/app/empty.component.ts new file mode 100644 index 000000000..5cde05a32 --- /dev/null +++ b/client/src/app/empty.component.ts @@ -0,0 +1,10 @@ + +import { Component } from '@angular/core' + +@Component({ + selector: 'my-empty', + template: '' +}) +export class EmptyComponent { + +} diff --git a/client/src/app/header/highlight.pipe.ts b/client/src/app/header/highlight.pipe.ts new file mode 100644 index 000000000..50ee5c1bd --- /dev/null +++ b/client/src/app/header/highlight.pipe.ts @@ -0,0 +1,54 @@ +import { PipeTransform, Pipe } from '@angular/core' +import { SafeHtml } from '@angular/platform-browser' + +// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369 +@Pipe({ name: 'highlight' }) +export class HighlightPipe implements PipeTransform { + /* use this for single match search */ + static SINGLE_MATCH = 'Single-Match' + /* use this for single match search with a restriction that target should start with search string */ + static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match' + /* use this for global search */ + static MULTI_MATCH = 'Multi-Match' + + transform ( + contentString: string = null, + stringToHighlight: string = null, + option = 'Single-And-StartsWith-Match', + caseSensitive = false, + highlightStyleName = 'search-highlight' + ): SafeHtml { + if (stringToHighlight && contentString && option) { + let regex: any = '' + const caseFlag: string = !caseSensitive ? 'i' : '' + + switch (option) { + case 'Single-Match': { + regex = new RegExp(stringToHighlight, caseFlag) + break + } + case 'Single-And-StartsWith-Match': { + regex = new RegExp('^' + stringToHighlight, caseFlag) + break + } + case 'Multi-Match': { + regex = new RegExp(stringToHighlight, 'g' + caseFlag) + break + } + default: { + // default will be a global case-insensitive match + regex = new RegExp(stringToHighlight, 'gi') + } + } + + const replaced = contentString.replace( + regex, + (match) => `${match}` + ) + + return replaced + } else { + return contentString + } + } +} diff --git a/client/src/app/login/index.ts b/client/src/app/login/index.ts deleted file mode 100644 index f1301d8b5..000000000 --- a/client/src/app/login/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './login-routing.module' -export * from './login.component' -export * from './login.module' diff --git a/client/src/app/login/login-routing.module.ts b/client/src/app/login/login-routing.module.ts deleted file mode 100644 index 22f59b4d9..000000000 --- a/client/src/app/login/login-routing.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { MetaGuard } from '@ngx-meta/core' -import { LoginComponent } from './login.component' -import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service' - -const loginRoutes: Routes = [ - { - path: 'login', - component: LoginComponent, - canActivate: [ MetaGuard ], - data: { - meta: { - title: 'Login' - } - }, - resolve: { - serverConfig: ServerConfigResolver - } - } -] - -@NgModule({ - imports: [ RouterModule.forChild(loginRoutes) ], - exports: [ RouterModule ] -}) -export class LoginRoutingModule {} diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html deleted file mode 100644 index 599b203ae..000000000 --- a/client/src/app/login/login.component.html +++ /dev/null @@ -1,114 +0,0 @@ -
-
- Login -
- -
- Sorry but there was an issue with the external login process. Please contact an administrator. -
- - - - - - - - - -
- - - - - - - - diff --git a/client/src/app/login/login.component.scss b/client/src/app/login/login.component.scss deleted file mode 100644 index fde6cc15e..000000000 --- a/client/src/app/login/login.component.scss +++ /dev/null @@ -1,66 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -label { - display: block; -} - -input:not([type=submit]) { - @include peertube-input-text(340px); - display: inline-block; - margin-right: 5px; - -} - -input[type=submit] { - @include peertube-button; - @include orange-button; -} - -.create-an-account, .forgot-password-button { - color: pvar(--mainForegroundColor); - cursor: pointer; - transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1); - - &:hover { - text-decoration: none !important; - opacity: .7 !important; - } -} - -.login-form-and-externals { - display: flex; - flex-wrap: wrap; - font-size: 15px; - - form { - margin: 0 50px 20px 0; - } - - .external-login-blocks { - min-width: 200px; - - .block-title { - font-weight: $font-semibold; - } - - .external-login-block { - @include disable-default-a-behaviour; - - cursor: pointer; - border: 1px solid #d1d7e0; - border-radius: 5px; - color: pvar(--mainForegroundColor); - margin: 10px 10px 0 0; - display: flex; - justify-content: center; - align-items: center; - min-height: 35px; - min-width: 100px; - - &:hover { - background-color: rgba(209, 215, 224, 0.5) - } - } - } -} diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts deleted file mode 100644 index cbc51ee21..000000000 --- a/client/src/app/login/login.component.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { environment } from 'src/environments/environment' -import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' -import { ActivatedRoute } from '@angular/router' -import { AuthService, Notifier, RedirectService, UserService } from '@app/core' -import { HooksService } from '@app/core/plugins/hooks.service' -import { FormReactive, FormValidatorService, LoginValidatorsService } from '@app/shared/shared-forms' -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' - -@Component({ - selector: 'my-login', - templateUrl: './login.component.html', - styleUrls: [ './login.component.scss' ] -}) - -export class LoginComponent extends FormReactive implements OnInit, AfterViewInit { - @ViewChild('usernameInput', { static: false }) usernameInput: ElementRef - @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef - - error: string = null - forgotPasswordEmail = '' - - isAuthenticatedWithExternalAuth = false - externalAuthError = false - externalLogins: string[] = [] - - private openedForgotPasswordModal: NgbModalRef - private serverConfig: ServerConfig - - constructor ( - protected formValidatorService: FormValidatorService, - private route: ActivatedRoute, - private modalService: NgbModal, - private loginValidatorsService: LoginValidatorsService, - private authService: AuthService, - private userService: UserService, - private redirectService: RedirectService, - private notifier: Notifier, - private hooks: HooksService, - private i18n: I18n - ) { - super() - } - - get signupAllowed () { - return this.serverConfig.signup.allowed === true - } - - isEmailDisabled () { - return this.serverConfig.email.enabled === false - } - - ngOnInit () { - const snapshot = this.route.snapshot - - this.serverConfig = snapshot.data.serverConfig - - if (snapshot.queryParams.externalAuthToken) { - this.loadExternalAuthToken(snapshot.queryParams.username, snapshot.queryParams.externalAuthToken) - return - } - - if (snapshot.queryParams.externalAuthError) { - this.externalAuthError = true - return - } - - this.buildForm({ - username: this.loginValidatorsService.LOGIN_USERNAME, - password: this.loginValidatorsService.LOGIN_PASSWORD - }) - } - - ngAfterViewInit () { - if (this.usernameInput) { - this.usernameInput.nativeElement.focus() - } - - this.hooks.runAction('action:login.init', 'login') - } - - getExternalLogins () { - return this.serverConfig.plugin.registeredExternalAuths - } - - getAuthHref (auth: RegisteredExternalAuthConfig) { - return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` - } - - login () { - this.error = null - - const { username, password } = this.form.value - - this.authService.login(username, password) - .subscribe( - () => this.redirectService.redirectToPreviousRoute(), - - err => this.handleError(err) - ) - } - - askResetPassword () { - this.userService.askResetPassword(this.forgotPasswordEmail) - .subscribe( - () => { - const message = this.i18n( - 'An email with the reset password instructions will be sent to {{email}}. The link will expire within 1 hour.', - { email: this.forgotPasswordEmail } - ) - this.notifier.success(message) - this.hideForgotPasswordModal() - }, - - err => this.notifier.error(err.message) - ) - } - - openForgotPasswordModal () { - this.openedForgotPasswordModal = this.modalService.open(this.forgotPasswordModal) - } - - hideForgotPasswordModal () { - this.openedForgotPasswordModal.close() - } - - private loadExternalAuthToken (username: string, token: string) { - this.isAuthenticatedWithExternalAuth = true - - this.authService.login(username, null, token) - .subscribe( - () => this.redirectService.redirectToPreviousRoute(), - - err => { - this.handleError(err) - this.isAuthenticatedWithExternalAuth = false - } - ) - } - - private handleError (err: any) { - if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.') - else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.') - else this.error = err.message - } -} diff --git a/client/src/app/login/login.module.ts b/client/src/app/login/login.module.ts deleted file mode 100644 index c41902426..000000000 --- a/client/src/app/login/login.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 { LoginRoutingModule } from './login-routing.module' -import { LoginComponent } from './login.component' - -@NgModule({ - imports: [ - LoginRoutingModule, - - SharedMainModule, - SharedFormModule, - SharedGlobalIconModule - ], - - declarations: [ - LoginComponent - ], - - exports: [ - LoginComponent - ], - - providers: [ - ] -}) -export class LoginModule { } diff --git a/client/src/app/reset-password/index.ts b/client/src/app/reset-password/index.ts deleted file mode 100644 index 438dc576a..000000000 --- a/client/src/app/reset-password/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './reset-password-routing.module' -export * from './reset-password.component' -export * from './reset-password.module' diff --git a/client/src/app/reset-password/reset-password-routing.module.ts b/client/src/app/reset-password/reset-password-routing.module.ts deleted file mode 100644 index d443b51d6..000000000 --- a/client/src/app/reset-password/reset-password-routing.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { MetaGuard } from '@ngx-meta/core' -import { ResetPasswordComponent } from './reset-password.component' - -const resetPasswordRoutes: Routes = [ - { - path: 'reset-password', - component: ResetPasswordComponent, - canActivate: [ MetaGuard ], - data: { - meta: { - title: 'Reset password' - } - } - } -] - -@NgModule({ - imports: [ RouterModule.forChild(resetPasswordRoutes) ], - exports: [ RouterModule ] -}) -export class ResetPasswordRoutingModule {} diff --git a/client/src/app/reset-password/reset-password.component.html b/client/src/app/reset-password/reset-password.component.html deleted file mode 100644 index af30af4a0..000000000 --- a/client/src/app/reset-password/reset-password.component.html +++ /dev/null @@ -1,31 +0,0 @@ -
-
- Reset my password -
- -
-
- - -
- {{ formErrors.password }} -
-
- -
- - -
- {{ formErrors['password-confirm'] }} -
-
- - -
-
diff --git a/client/src/app/reset-password/reset-password.component.scss b/client/src/app/reset-password/reset-password.component.scss deleted file mode 100644 index efec6b706..000000000 --- a/client/src/app/reset-password/reset-password.component.scss +++ /dev/null @@ -1,12 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -input:not([type=submit]) { - @include peertube-input-text(340px); - display: block; -} - -input[type=submit] { - @include peertube-button; - @include orange-button; -} diff --git a/client/src/app/reset-password/reset-password.component.ts b/client/src/app/reset-password/reset-password.component.ts deleted file mode 100644 index 8d50e9839..000000000 --- a/client/src/app/reset-password/reset-password.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Component, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { Notifier, UserService } from '@app/core' -import { FormReactive, FormValidatorService, ResetPasswordValidatorsService, UserValidatorsService } from '@app/shared/shared-forms' -import { I18n } from '@ngx-translate/i18n-polyfill' - -@Component({ - selector: 'my-login', - templateUrl: './reset-password.component.html', - styleUrls: [ './reset-password.component.scss' ] -}) - -export class ResetPasswordComponent extends FormReactive implements OnInit { - private userId: number - private verificationString: string - - constructor ( - protected formValidatorService: FormValidatorService, - private resetPasswordValidatorsService: ResetPasswordValidatorsService, - private userValidatorsService: UserValidatorsService, - private userService: UserService, - private notifier: Notifier, - private router: Router, - private route: ActivatedRoute, - private i18n: I18n - ) { - super() - } - - ngOnInit () { - this.buildForm({ - password: this.userValidatorsService.USER_PASSWORD, - 'password-confirm': this.resetPasswordValidatorsService.RESET_PASSWORD_CONFIRM - }) - - this.userId = this.route.snapshot.queryParams['userId'] - this.verificationString = this.route.snapshot.queryParams['verificationString'] - - if (!this.userId || !this.verificationString) { - this.notifier.error(this.i18n('Unable to find user id or verification string.')) - this.router.navigate([ '/' ]) - } - } - - resetPassword () { - this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password) - .subscribe( - () => { - this.notifier.success(this.i18n('Your password has been successfully reset!')) - this.router.navigate([ '/login' ]) - }, - - err => this.notifier.error(err.message) - ) - } - - isConfirmedPasswordValid () { - const values = this.form.value - return values.password === values['password-confirm'] - } -} diff --git a/client/src/app/reset-password/reset-password.module.ts b/client/src/app/reset-password/reset-password.module.ts deleted file mode 100644 index c77f1c4b0..000000000 --- a/client/src/app/reset-password/reset-password.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NgModule } from '@angular/core' -import { SharedFormModule } from '@app/shared/shared-forms' -import { SharedMainModule } from '@app/shared/shared-main' -import { ResetPasswordRoutingModule } from './reset-password-routing.module' -import { ResetPasswordComponent } from './reset-password.component' - -@NgModule({ - imports: [ - ResetPasswordRoutingModule, - - SharedMainModule, - SharedFormModule - ], - - declarations: [ - ResetPasswordComponent - ], - - exports: [ - ResetPasswordComponent - ], - - providers: [ - ] -}) -export class ResetPasswordModule { } diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts deleted file mode 100644 index 516854a8c..000000000 --- a/client/src/app/search/advanced-search.model.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { NSFWQuery, SearchTargetType } from '@shared/models' - -export class AdvancedSearch { - startDate: string // ISO 8601 - endDate: string // ISO 8601 - - originallyPublishedStartDate: string // ISO 8601 - originallyPublishedEndDate: string // ISO 8601 - - nsfw: NSFWQuery - - categoryOneOf: string - - licenceOneOf: string - - languageOneOf: string - - tagsOneOf: string - tagsAllOf: string - - durationMin: number // seconds - durationMax: number // seconds - - sort: string - - searchTarget: SearchTargetType - - // Filters we don't want to count, because they are mandatory - private silentFilters = new Set([ 'sort', 'searchTarget' ]) - - constructor (options?: { - startDate?: string - endDate?: string - originallyPublishedStartDate?: string - originallyPublishedEndDate?: string - nsfw?: NSFWQuery - categoryOneOf?: string - licenceOneOf?: string - languageOneOf?: string - tagsOneOf?: string - tagsAllOf?: string - durationMin?: string - durationMax?: string - sort?: string - searchTarget?: SearchTargetType - }) { - if (!options) return - - this.startDate = options.startDate || undefined - this.endDate = options.endDate || undefined - this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined - this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined - - this.nsfw = options.nsfw || undefined - this.categoryOneOf = options.categoryOneOf || undefined - this.licenceOneOf = options.licenceOneOf || undefined - this.languageOneOf = options.languageOneOf || undefined - this.tagsOneOf = options.tagsOneOf || undefined - this.tagsAllOf = options.tagsAllOf || undefined - this.durationMin = parseInt(options.durationMin, 10) - this.durationMax = parseInt(options.durationMax, 10) - - this.searchTarget = options.searchTarget || undefined - - if (isNaN(this.durationMin)) this.durationMin = undefined - if (isNaN(this.durationMax)) this.durationMax = undefined - - this.sort = options.sort || '-match' - } - - containsValues () { - const exceptions = new Set([ 'sort', 'searchTarget' ]) - - const obj = this.toUrlObject() - for (const k of Object.keys(obj)) { - if (this.silentFilters.has(k)) continue - - if (obj[k] !== undefined && obj[k] !== '') return true - } - - return false - } - - reset () { - this.startDate = undefined - this.endDate = undefined - this.originallyPublishedStartDate = undefined - this.originallyPublishedEndDate = undefined - this.nsfw = undefined - this.categoryOneOf = undefined - this.licenceOneOf = undefined - this.languageOneOf = undefined - this.tagsOneOf = undefined - this.tagsAllOf = undefined - this.durationMin = undefined - this.durationMax = undefined - - this.sort = '-match' - } - - toUrlObject () { - return { - startDate: this.startDate, - endDate: this.endDate, - originallyPublishedStartDate: this.originallyPublishedStartDate, - originallyPublishedEndDate: this.originallyPublishedEndDate, - nsfw: this.nsfw, - categoryOneOf: this.categoryOneOf, - licenceOneOf: this.licenceOneOf, - languageOneOf: this.languageOneOf, - tagsOneOf: this.tagsOneOf, - tagsAllOf: this.tagsAllOf, - durationMin: this.durationMin, - durationMax: this.durationMax, - sort: this.sort, - searchTarget: this.searchTarget - } - } - - toAPIObject () { - return { - startDate: this.startDate, - endDate: this.endDate, - originallyPublishedStartDate: this.originallyPublishedStartDate, - originallyPublishedEndDate: this.originallyPublishedEndDate, - nsfw: this.nsfw, - categoryOneOf: this.intoArray(this.categoryOneOf), - licenceOneOf: this.intoArray(this.licenceOneOf), - languageOneOf: this.intoArray(this.languageOneOf), - tagsOneOf: this.intoArray(this.tagsOneOf), - tagsAllOf: this.intoArray(this.tagsAllOf), - durationMin: this.durationMin, - durationMax: this.durationMax, - sort: this.sort, - searchTarget: this.searchTarget - } - } - - size () { - let acc = 0 - - const obj = this.toUrlObject() - for (const k of Object.keys(obj)) { - if (this.silentFilters.has(k)) continue - - if (obj[k] !== undefined && obj[k] !== '') acc++ - } - - return acc - } - - private intoArray (value: any) { - if (!value) return undefined - if (Array.isArray(value)) return value - - if (typeof value === 'string') return value.split(',') - - return [ value ] - } -} diff --git a/client/src/app/search/channel-lazy-load.resolver.ts b/client/src/app/search/channel-lazy-load.resolver.ts deleted file mode 100644 index 5b6961e98..000000000 --- a/client/src/app/search/channel-lazy-load.resolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { map } from 'rxjs/operators' -import { Injectable } from '@angular/core' -import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' -import { SearchService } from './search.service' - -@Injectable() -export class ChannelLazyLoadResolver implements Resolve { - constructor ( - private router: Router, - private searchService: SearchService - ) { } - - resolve (route: ActivatedRouteSnapshot) { - const url = route.params.url - const externalRedirect = route.params.externalRedirect - const fromPath = route.params.fromPath - - if (!url) { - console.error('Could not find url param.', { params: route.params }) - return this.router.navigateByUrl('/404') - } - - if (externalRedirect === 'true') { - window.open(url) - this.router.navigateByUrl(fromPath) - return - } - - return this.searchService.searchVideoChannels({ search: url }) - .pipe( - map(result => { - if (result.data.length !== 1) { - console.error('Cannot find result for this URL') - return this.router.navigateByUrl('/404') - } - - const channel = result.data[0] - - return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost) - }) - ) - } -} diff --git a/client/src/app/search/highlight.pipe.ts b/client/src/app/search/highlight.pipe.ts deleted file mode 100644 index 50ee5c1bd..000000000 --- a/client/src/app/search/highlight.pipe.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { PipeTransform, Pipe } from '@angular/core' -import { SafeHtml } from '@angular/platform-browser' - -// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369 -@Pipe({ name: 'highlight' }) -export class HighlightPipe implements PipeTransform { - /* use this for single match search */ - static SINGLE_MATCH = 'Single-Match' - /* use this for single match search with a restriction that target should start with search string */ - static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match' - /* use this for global search */ - static MULTI_MATCH = 'Multi-Match' - - transform ( - contentString: string = null, - stringToHighlight: string = null, - option = 'Single-And-StartsWith-Match', - caseSensitive = false, - highlightStyleName = 'search-highlight' - ): SafeHtml { - if (stringToHighlight && contentString && option) { - let regex: any = '' - const caseFlag: string = !caseSensitive ? 'i' : '' - - switch (option) { - case 'Single-Match': { - regex = new RegExp(stringToHighlight, caseFlag) - break - } - case 'Single-And-StartsWith-Match': { - regex = new RegExp('^' + stringToHighlight, caseFlag) - break - } - case 'Multi-Match': { - regex = new RegExp(stringToHighlight, 'g' + caseFlag) - break - } - default: { - // default will be a global case-insensitive match - regex = new RegExp(stringToHighlight, 'gi') - } - } - - const replaced = contentString.replace( - regex, - (match) => `${match}` - ) - - return replaced - } else { - return contentString - } - } -} diff --git a/client/src/app/search/index.ts b/client/src/app/search/index.ts deleted file mode 100644 index 40f4e021f..000000000 --- a/client/src/app/search/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './search-routing.module' -export * from './search.component' -export * from './search.module' diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html deleted file mode 100644 index e20aef8fb..000000000 --- a/client/src/app/search/search-filters.component.html +++ /dev/null @@ -1,193 +0,0 @@ -
- -
-
-
-
- - -
- -
- - -
-
- -
-
- - -
- -
- - -
- -
- - -
-
- -
-
- - -
- -
- - -
-
- -
-
- - -
- -
-
- -
-
- -
-
-
- -
- -
-
-
- - -
- -
- - -
-
- -
- - -
- -
-
- -
- - -
- -
-
- -
- - -
- -
-
-
- -
-
- - - -
- -
- - - -
- -
-
- -
- -
- - -
- -
- - -
-
-
-
- -
- - - -
-
diff --git a/client/src/app/search/search-filters.component.scss b/client/src/app/search/search-filters.component.scss deleted file mode 100644 index a88a1c0b0..000000000 --- a/client/src/app/search/search-filters.component.scss +++ /dev/null @@ -1,69 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -form { - margin-top: 40px; -} - -.radio-label { - font-size: 15px; - font-weight: $font-bold; -} - -.peertube-radio-container { - @include peertube-radio-container; - - display: inline-block; - margin-right: 30px; -} - -.peertube-select-container { - @include peertube-select-container(auto); - - margin-bottom: 1rem; -} - -.form-group { - margin-bottom: 25px; -} - -input[type=text] { - @include peertube-input-text(100%); - display: block; -} - -input[type=submit] { - @include peertube-button-link; - @include orange-button; -} - -.submit-button { - text-align: right; -} - -.reset-button { - @include peertube-button; - - font-weight: $font-semibold; - display: inline-block; - padding: 0 10px 0 10px; - white-space: nowrap; - background: transparent; - - margin-right: 1rem; -} - -.reset-button-small { - font-size: 80%; - height: unset; - line-height: unset; - margin: unset; - margin-bottom: 0.5rem; -} - -.label-container { - display: flex; - white-space: nowrap; -} - -@include ng2-tags; diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts deleted file mode 100644 index 14a5d0484..000000000 --- a/client/src/app/search/search-filters.component.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' -import { ValidatorFn } from '@angular/forms' -import { ServerService } from '@app/core' -import { AdvancedSearch } from '@app/search/advanced-search.model' -import { VideoValidatorsService } from '@app/shared/shared-forms' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { ServerConfig, VideoConstant } from '@shared/models' - -@Component({ - selector: 'my-search-filters', - styleUrls: [ './search-filters.component.scss' ], - templateUrl: './search-filters.component.html' -}) -export class SearchFiltersComponent implements OnInit { - @Input() advancedSearch: AdvancedSearch = new AdvancedSearch() - - @Output() filtered = new EventEmitter() - - videoCategories: VideoConstant[] = [] - videoLicences: VideoConstant[] = [] - videoLanguages: VideoConstant[] = [] - - tagValidators: ValidatorFn[] - tagValidatorsMessages: { [ name: string ]: string } - - publishedDateRanges: { id: string, label: string }[] = [] - sorts: { id: string, label: string }[] = [] - durationRanges: { id: string, label: string }[] = [] - - publishedDateRange: string - durationRange: string - - originallyPublishedStartYear: string - originallyPublishedEndYear: string - - private serverConfig: ServerConfig - - constructor ( - private i18n: I18n, - private videoValidatorsService: VideoValidatorsService, - private serverService: ServerService - ) { - this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS - this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES - this.publishedDateRanges = [ - { - id: 'any_published_date', - label: this.i18n('Any') - }, - { - id: 'today', - label: this.i18n('Today') - }, - { - id: 'last_7days', - label: this.i18n('Last 7 days') - }, - { - id: 'last_30days', - label: this.i18n('Last 30 days') - }, - { - id: 'last_365days', - label: this.i18n('Last 365 days') - } - ] - - this.durationRanges = [ - { - id: 'any_duration', - label: this.i18n('Any') - }, - { - id: 'short', - label: this.i18n('Short (< 4 min)') - }, - { - id: 'medium', - label: this.i18n('Medium (4-10 min)') - }, - { - id: 'long', - label: this.i18n('Long (> 10 min)') - } - ] - - this.sorts = [ - { - id: '-match', - label: this.i18n('Relevance') - }, - { - id: '-publishedAt', - label: this.i18n('Publish date') - }, - { - id: '-views', - label: this.i18n('Views') - } - ] - } - - ngOnInit () { - this.serverConfig = this.serverService.getTmpConfig() - this.serverService.getConfig() - .subscribe(config => this.serverConfig = config) - - this.serverService.getVideoCategories().subscribe(categories => this.videoCategories = categories) - this.serverService.getVideoLicences().subscribe(licences => this.videoLicences = licences) - this.serverService.getVideoLanguages().subscribe(languages => this.videoLanguages = languages) - - this.loadFromDurationRange() - this.loadFromPublishedRange() - this.loadOriginallyPublishedAtYears() - } - - inputUpdated () { - this.updateModelFromDurationRange() - this.updateModelFromPublishedRange() - this.updateModelFromOriginallyPublishedAtYears() - } - - formUpdated () { - this.inputUpdated() - this.filtered.emit(this.advancedSearch) - } - - reset () { - this.advancedSearch.reset() - this.durationRange = undefined - this.publishedDateRange = undefined - this.originallyPublishedStartYear = undefined - this.originallyPublishedEndYear = undefined - this.inputUpdated() - } - - resetField (fieldName: string, value?: any) { - this.advancedSearch[fieldName] = value - } - - resetLocalField (fieldName: string, value?: any) { - this[fieldName] = value - this.inputUpdated() - } - - resetOriginalPublicationYears () { - this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined - } - - isSearchTargetEnabled () { - return this.serverConfig.search.searchIndex.enabled && this.serverConfig.search.searchIndex.disableLocalSearch !== true - } - - private loadOriginallyPublishedAtYears () { - this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate - ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString() - : null - - this.originallyPublishedEndYear = this.advancedSearch.originallyPublishedEndDate - ? new Date(this.advancedSearch.originallyPublishedEndDate).getFullYear().toString() - : null - } - - private loadFromDurationRange () { - if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) { - const fourMinutes = 60 * 4 - const tenMinutes = 60 * 10 - - if (this.advancedSearch.durationMin === fourMinutes && this.advancedSearch.durationMax === tenMinutes) { - this.durationRange = 'medium' - } else if (this.advancedSearch.durationMax === fourMinutes) { - this.durationRange = 'short' - } else if (this.advancedSearch.durationMin === tenMinutes) { - this.durationRange = 'long' - } - } - } - - private loadFromPublishedRange () { - if (this.advancedSearch.startDate) { - const date = new Date(this.advancedSearch.startDate) - const now = new Date() - - const diff = Math.abs(date.getTime() - now.getTime()) - - const dayMS = 1000 * 3600 * 24 - const numberOfDays = diff / dayMS - - if (numberOfDays >= 365) this.publishedDateRange = 'last_365days' - else if (numberOfDays >= 30) this.publishedDateRange = 'last_30days' - else if (numberOfDays >= 7) this.publishedDateRange = 'last_7days' - else if (numberOfDays >= 0) this.publishedDateRange = 'today' - } - } - - private updateModelFromOriginallyPublishedAtYears () { - const baseDate = new Date() - baseDate.setHours(0, 0, 0, 0) - baseDate.setMonth(0, 1) - - if (this.originallyPublishedStartYear) { - const year = parseInt(this.originallyPublishedStartYear, 10) - const start = new Date(baseDate) - start.setFullYear(year) - - this.advancedSearch.originallyPublishedStartDate = start.toISOString() - } else { - this.advancedSearch.originallyPublishedStartDate = null - } - - if (this.originallyPublishedEndYear) { - const year = parseInt(this.originallyPublishedEndYear, 10) - const end = new Date(baseDate) - end.setFullYear(year) - - this.advancedSearch.originallyPublishedEndDate = end.toISOString() - } else { - this.advancedSearch.originallyPublishedEndDate = null - } - } - - private updateModelFromDurationRange () { - if (!this.durationRange) return - - const fourMinutes = 60 * 4 - const tenMinutes = 60 * 10 - - switch (this.durationRange) { - case 'short': - this.advancedSearch.durationMin = undefined - this.advancedSearch.durationMax = fourMinutes - break - - case 'medium': - this.advancedSearch.durationMin = fourMinutes - this.advancedSearch.durationMax = tenMinutes - break - - case 'long': - this.advancedSearch.durationMin = tenMinutes - this.advancedSearch.durationMax = undefined - break - } - } - - private updateModelFromPublishedRange () { - if (!this.publishedDateRange) return - - // today - const date = new Date() - date.setHours(0, 0, 0, 0) - - switch (this.publishedDateRange) { - case 'last_7days': - date.setDate(date.getDate() - 7) - break - - case 'last_30days': - date.setDate(date.getDate() - 30) - break - - case 'last_365days': - date.setDate(date.getDate() - 365) - break - } - - this.advancedSearch.startDate = date.toISOString() - } -} diff --git a/client/src/app/search/search-routing.module.ts b/client/src/app/search/search-routing.module.ts deleted file mode 100644 index 9da900e9a..000000000 --- a/client/src/app/search/search-routing.module.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { SearchComponent } from '@app/search/search.component' -import { MetaGuard } from '@ngx-meta/core' -import { VideoLazyLoadResolver } from './video-lazy-load.resolver' -import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' - -const searchRoutes: Routes = [ - { - path: 'search', - component: SearchComponent, - canActivate: [ MetaGuard ], - data: { - meta: { - title: 'Search' - } - } - }, - { - path: 'search/lazy-load-video', - component: SearchComponent, - canActivate: [ MetaGuard ], - resolve: { - data: VideoLazyLoadResolver - } - }, - { - path: 'search/lazy-load-channel', - component: SearchComponent, - canActivate: [ MetaGuard ], - resolve: { - data: ChannelLazyLoadResolver - } - } -] - -@NgModule({ - imports: [ RouterModule.forChild(searchRoutes) ], - exports: [ RouterModule ] -}) -export class SearchRoutingModule {} diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html deleted file mode 100644 index 9bff024ad..000000000 --- a/client/src/app/search/search.component.html +++ /dev/null @@ -1,63 +0,0 @@ -
-
-
-
- {{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} - - on this instance - on the vidiverse - - - for {{ currentSearch }} - -
- -
- - - Filters - {{ numberOfFilters() }} - -
-
- -
- -
-
- -
- No results found -
- - -
- - Avatar - - -
- -
{{ result.displayName }}
-
{{ result.nameWithHost }}
-
- -
{{ result.followersCount }} subscribers
-
- - -
- -
- -
-
- -
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss deleted file mode 100644 index 6e59adb60..000000000 --- a/client/src/app/search/search.component.scss +++ /dev/null @@ -1,191 +0,0 @@ -@import '_variables'; -@import '_mixins'; - -.search-result { - padding: 40px; - - .results-header { - font-size: 16px; - padding-bottom: 20px; - margin-bottom: 30px; - border-bottom: 1px solid #DADADA; - - .first-line { - display: flex; - flex-direction: row; - - .results-counter { - flex-grow: 1; - - .search-value { - font-weight: $font-semibold; - } - } - - .results-filter-button { - cursor: pointer; - - .icon.icon-filter { - @include icon(20px); - - position: relative; - top: -1px; - margin-right: 5px; - background-image: url('../../assets/images/search/filter.svg'); - } - } - } - } - - .entry { - display: flex; - min-height: 130px; - padding-bottom: 20px; - margin-bottom: 20px; - - &.video-channel { - img { - $image-size: 130px; - $margin-size: ($video-thumbnail-width - $image-size) / 2; // So we have the same width than the video miniature - - @include avatar($image-size); - - margin: 0 ($margin-size + 10) 0 $margin-size; - } - - .video-channel-info { - flex-grow: 1; - width: fit-content; - - .video-channel-names { - @include disable-default-a-behaviour; - - display: flex; - align-items: baseline; - color: pvar(--mainForegroundColor); - width: fit-content; - - .video-channel-display-name { - font-weight: $font-semibold; - font-size: 18px; - } - - .video-channel-name { - font-size: 14px; - color: $grey-actor-name; - margin-left: 5px; - } - } - } - } - } -} - -@media screen and (min-width: $small-view) and (max-width: breakpoint(xl)) { - .video-channel-info .video-channel-names { - flex-direction: column !important; - - .video-channel-name { - @include ellipsis; // Ellipsis and max-width on channel-name to not break screen - - max-width: 250px; - margin-left: 0 !important; - } - } - - :host-context(.main-col:not(.expanded)) { - // Override the min-width: 500px to not break screen - ::ng-deep .video-miniature-information { - min-width: 300px !important; - } - } -} - -@media screen and (min-width: $small-view) and (max-width: breakpoint(lg)) { - :host-context(.main-col:not(.expanded)) { - .video-channel-info .video-channel-names { - .video-channel-name { - max-width: 160px; - } - } - - // Override the min-width: 500px to not break screen - ::ng-deep .video-miniature-information { - min-width: $video-thumbnail-width !important; - } - } - - :host-context(.expanded) { - // Override the min-width: 500px to not break screen - ::ng-deep .video-miniature-information { - min-width: 300px !important; - } - } -} - -@media screen and (max-width: $small-view) { - .search-result { - .entry.video-channel, - .entry.video { - flex-direction: column; - height: auto; - justify-content: center; - align-items: center; - text-align: center; - - img { - margin: 0; - } - - img { - margin: 0; - } - - .video-channel-info .video-channel-names { - align-items: center; - flex-direction: column !important; - - .video-channel-name { - margin-left: 0 !important; - } - } - - my-subscribe-button { - margin-top: 5px; - } - } - } -} - -@media screen and (max-width: $mobile-view) { - .search-result { - padding: 20px 10px; - - .results-header { - font-size: 15px !important; - } - - .entry { - &.video { - .video-info-name, - .video-info-account { - margin: auto; - } - - my-video-thumbnail { - margin-right: 0 !important; - - ::ng-deep .video-thumbnail { - width: 100%; - height: auto; - - img { - width: 100%; - height: auto; - } - } - } - } - } - } -} diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts deleted file mode 100644 index 83b06e0ce..000000000 --- a/client/src/app/search/search.component.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { forkJoin, of, Subscription } from 'rxjs' -import { Component, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core' -import { immutableAssign } from '@app/helpers' -import { Video, VideoChannel } from '@app/shared/shared-main' -import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' -import { MetaService } from '@ngx-meta/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { SearchTargetType, ServerConfig } from '@shared/models' -import { AdvancedSearch } from './advanced-search.model' -import { SearchService } from './search.service' - -@Component({ - selector: 'my-search', - styleUrls: [ './search.component.scss' ], - templateUrl: './search.component.html' -}) -export class SearchComponent implements OnInit, OnDestroy { - results: (Video | VideoChannel)[] = [] - - pagination: ComponentPagination = { - currentPage: 1, - itemsPerPage: 10, // Only for videos, use another variable for channels - totalItems: null - } - advancedSearch: AdvancedSearch = new AdvancedSearch() - isSearchFilterCollapsed = true - currentSearch: string - - videoDisplayOptions: MiniatureDisplayOptions = { - date: true, - views: true, - by: true, - avatar: false, - privacyLabel: false, - privacyText: false, - state: false, - blacklistInfo: false - } - - errorMessage: string - serverConfig: ServerConfig - - userMiniature: User - - private subActivatedRoute: Subscription - private isInitialLoad = false // set to false to show the search filters on first arrival - private firstSearch = true - - private channelsPerPage = 2 - - private lastSearchTarget: SearchTargetType - - constructor ( - private i18n: I18n, - private route: ActivatedRoute, - private router: Router, - private metaService: MetaService, - private notifier: Notifier, - private searchService: SearchService, - private authService: AuthService, - private userService: UserService, - private hooks: HooksService, - private serverService: ServerService - ) { } - - ngOnInit () { - this.serverService.getConfig() - .subscribe(config => this.serverConfig = config) - - this.subActivatedRoute = this.route.queryParams.subscribe( - async queryParams => { - const querySearch = queryParams['search'] - const searchTarget = queryParams['searchTarget'] - - // Search updated, reset filters - if (this.currentSearch !== querySearch || searchTarget !== this.advancedSearch.searchTarget) { - this.resetPagination() - this.advancedSearch.reset() - - this.currentSearch = querySearch || undefined - this.updateTitle() - } - - this.advancedSearch = new AdvancedSearch(queryParams) - if (!this.advancedSearch.searchTarget) { - this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget() - } - - // Don't hide filters if we have some of them AND the user just came on the webpage - this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues() - this.isInitialLoad = false - - this.search() - }, - - err => this.notifier.error(err.text) - ) - - this.userService.getAnonymousOrLoggedUser() - .subscribe(user => this.userMiniature = user) - - this.hooks.runAction('action:search.init', 'search') - } - - ngOnDestroy () { - if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() - } - - isVideoChannel (d: VideoChannel | Video): d is VideoChannel { - return d instanceof VideoChannel - } - - isVideo (v: VideoChannel | Video): v is Video { - return v instanceof Video - } - - isUserLoggedIn () { - return this.authService.isLoggedIn() - } - - search () { - forkJoin([ - this.getVideosObs(), - this.getVideoChannelObs() - ]).subscribe( - ([videosResult, videoChannelsResult]) => { - this.results = this.results - .concat(videoChannelsResult.data) - .concat(videosResult.data) - - this.pagination.totalItems = videosResult.total + videoChannelsResult.total - this.lastSearchTarget = this.advancedSearch.searchTarget - - // Focus on channels if there are no enough videos - if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) { - this.resetPagination() - this.firstSearch = false - - this.channelsPerPage = 10 - this.search() - } - - this.firstSearch = false - }, - - err => { - if (this.advancedSearch.searchTarget !== 'search-index') { - this.notifier.error(err.message) - return - } - - this.notifier.error( - this.i18n('Search index is unavailable. Retrying with instance results instead.'), - this.i18n('Search error') - ) - this.advancedSearch.searchTarget = 'local' - this.search() - } - ) - } - - onNearOfBottom () { - // Last page - if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return - - this.pagination.currentPage += 1 - this.search() - } - - onFiltered () { - this.resetPagination() - - this.updateUrlFromAdvancedSearch() - } - - numberOfFilters () { - return this.advancedSearch.size() - } - - // Add VideoChannel for typings, but the template already checks "video" argument is a video - removeVideoFromArray (video: Video | VideoChannel) { - this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) - } - - getChannelUrl (channel: VideoChannel) { - if (this.advancedSearch.searchTarget === 'search-index' && channel.url) { - const remoteUriConfig = this.serverConfig.search.remoteUri - - // Redirect on the external instance if not allowed to fetch remote data - const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users - const fromPath = window.location.pathname + window.location.search - - return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ] - } - - return [ '/video-channels', channel.nameWithHost ] - } - - hideActions () { - return this.lastSearchTarget === 'search-index' - } - - private resetPagination () { - this.pagination.currentPage = 1 - this.pagination.totalItems = null - this.channelsPerPage = 2 - - this.results = [] - } - - private updateTitle () { - const suffix = this.currentSearch ? ' ' + this.currentSearch : '' - this.metaService.setTitle(this.i18n('Search') + suffix) - } - - private updateUrlFromAdvancedSearch () { - const search = this.currentSearch || undefined - - this.router.navigate([], { - relativeTo: this.route, - queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search }) - }) - } - - private getVideosObs () { - const params = { - search: this.currentSearch, - componentPagination: this.pagination, - advancedSearch: this.advancedSearch - } - - return this.hooks.wrapObsFun( - this.searchService.searchVideos.bind(this.searchService), - params, - 'search', - 'filter:api.search.videos.list.params', - 'filter:api.search.videos.list.result' - ) - } - - private getVideoChannelObs () { - if (!this.currentSearch) return of({ data: [], total: 0 }) - - const params = { - search: this.currentSearch, - componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }), - searchTarget: this.advancedSearch.searchTarget - } - - return this.hooks.wrapObsFun( - this.searchService.searchVideoChannels.bind(this.searchService), - params, - 'search', - 'filter:api.search.video-channels.list.params', - 'filter:api.search.video-channels.list.result' - ) - } -} diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts deleted file mode 100644 index 65c954de8..000000000 --- a/client/src/app/search/search.module.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { TagInputModule } from 'ngx-chips' -import { NgModule } from '@angular/core' -import { SharedFormModule } from '@app/shared/shared-forms' -import { SharedMainModule } from '@app/shared/shared-main' -import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' -import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' -import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' -import { HighlightPipe } from './highlight.pipe' -import { SearchFiltersComponent } from './search-filters.component' -import { SearchRoutingModule } from './search-routing.module' -import { SearchComponent } from './search.component' -import { SearchService } from './search.service' -import { VideoLazyLoadResolver } from './video-lazy-load.resolver' - -@NgModule({ - imports: [ - TagInputModule, - - SearchRoutingModule, - SharedMainModule, - SharedFormModule, - SharedUserSubscriptionModule, - SharedVideoMiniatureModule - ], - - declarations: [ - SearchComponent, - SearchFiltersComponent - ], - - exports: [ - TagInputModule, - SearchComponent - ], - - providers: [ - SearchService, - VideoLazyLoadResolver, - ChannelLazyLoadResolver, - HighlightPipe - ] -}) -export class SearchModule { } diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts deleted file mode 100644 index 36342034f..000000000 --- a/client/src/app/search/search.service.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Observable } from 'rxjs' -import { catchError, map, switchMap } from 'rxjs/operators' -import { HttpClient, HttpParams } from '@angular/common/http' -import { Injectable } from '@angular/core' -import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core' -import { peertubeLocalStorage } from '@app/helpers' -import { AdvancedSearch } from '@app/search/advanced-search.model' -import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' -import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models' -import { SearchTargetType } from '@shared/models/search/search-target-query.model' -import { environment } from '../../environments/environment' - -@Injectable() -export class SearchService { - static BASE_SEARCH_URL = environment.apiUrl + '/api/v1/search/' - - constructor ( - private authHttp: HttpClient, - private restExtractor: RestExtractor, - private restService: RestService, - private videoService: VideoService - ) { - // Add ability to override search endpoint if the user updated this local storage key - const searchUrl = peertubeLocalStorage.getItem('search-url') - if (searchUrl) SearchService.BASE_SEARCH_URL = searchUrl - } - - searchVideos (parameters: { - search: string, - componentPagination?: ComponentPaginationLight, - advancedSearch?: AdvancedSearch - }): Observable> { - const { search, componentPagination, advancedSearch } = parameters - - const url = SearchService.BASE_SEARCH_URL + 'videos' - let pagination: RestPagination - - if (componentPagination) { - pagination = this.restService.componentPaginationToRestPagination(componentPagination) - } - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination) - - if (search) params = params.append('search', search) - - if (advancedSearch) { - const advancedSearchObject = advancedSearch.toAPIObject() - params = this.restService.addObjectParams(params, advancedSearchObject) - } - - return this.authHttp - .get>(url, { params }) - .pipe( - switchMap(res => this.videoService.extractVideos(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } - - searchVideoChannels (parameters: { - search: string, - searchTarget?: SearchTargetType, - componentPagination?: ComponentPaginationLight - }): Observable> { - const { search, componentPagination, searchTarget } = parameters - - const url = SearchService.BASE_SEARCH_URL + 'video-channels' - - let pagination: RestPagination - if (componentPagination) { - pagination = this.restService.componentPaginationToRestPagination(componentPagination) - } - - let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination) - params = params.append('search', search) - - if (searchTarget) { - params = params.append('searchTarget', searchTarget as string) - } - - return this.authHttp - .get>(url, { params }) - .pipe( - map(res => VideoChannelService.extractVideoChannels(res)), - catchError(err => this.restExtractor.handleError(err)) - ) - } -} diff --git a/client/src/app/search/video-lazy-load.resolver.ts b/client/src/app/search/video-lazy-load.resolver.ts deleted file mode 100644 index 8d846d367..000000000 --- a/client/src/app/search/video-lazy-load.resolver.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { map } from 'rxjs/operators' -import { Injectable } from '@angular/core' -import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' -import { SearchService } from './search.service' - -@Injectable() -export class VideoLazyLoadResolver implements Resolve { - constructor ( - private router: Router, - private searchService: SearchService - ) { } - - resolve (route: ActivatedRouteSnapshot) { - const url = route.params.url - const externalRedirect = route.params.externalRedirect - const fromPath = route.params.fromPath - - if (!url) { - console.error('Could not find url param.', { params: route.params }) - return this.router.navigateByUrl('/404') - } - - if (externalRedirect === 'true') { - window.open(url) - this.router.navigateByUrl(fromPath) - return - } - - return this.searchService.searchVideos({ search: url }) - .pipe( - map(result => { - if (result.data.length !== 1) { - console.error('Cannot find result for this URL') - return this.router.navigateByUrl('/404') - } - - const video = result.data[0] - - return this.router.navigateByUrl('/videos/watch/' + video.uuid) - }) - ) - } -} diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts new file mode 100644 index 000000000..516854a8c --- /dev/null +++ b/client/src/app/shared/shared-search/advanced-search.model.ts @@ -0,0 +1,160 @@ +import { NSFWQuery, SearchTargetType } from '@shared/models' + +export class AdvancedSearch { + startDate: string // ISO 8601 + endDate: string // ISO 8601 + + originallyPublishedStartDate: string // ISO 8601 + originallyPublishedEndDate: string // ISO 8601 + + nsfw: NSFWQuery + + categoryOneOf: string + + licenceOneOf: string + + languageOneOf: string + + tagsOneOf: string + tagsAllOf: string + + durationMin: number // seconds + durationMax: number // seconds + + sort: string + + searchTarget: SearchTargetType + + // Filters we don't want to count, because they are mandatory + private silentFilters = new Set([ 'sort', 'searchTarget' ]) + + constructor (options?: { + startDate?: string + endDate?: string + originallyPublishedStartDate?: string + originallyPublishedEndDate?: string + nsfw?: NSFWQuery + categoryOneOf?: string + licenceOneOf?: string + languageOneOf?: string + tagsOneOf?: string + tagsAllOf?: string + durationMin?: string + durationMax?: string + sort?: string + searchTarget?: SearchTargetType + }) { + if (!options) return + + this.startDate = options.startDate || undefined + this.endDate = options.endDate || undefined + this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined + this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined + + this.nsfw = options.nsfw || undefined + this.categoryOneOf = options.categoryOneOf || undefined + this.licenceOneOf = options.licenceOneOf || undefined + this.languageOneOf = options.languageOneOf || undefined + this.tagsOneOf = options.tagsOneOf || undefined + this.tagsAllOf = options.tagsAllOf || undefined + this.durationMin = parseInt(options.durationMin, 10) + this.durationMax = parseInt(options.durationMax, 10) + + this.searchTarget = options.searchTarget || undefined + + if (isNaN(this.durationMin)) this.durationMin = undefined + if (isNaN(this.durationMax)) this.durationMax = undefined + + this.sort = options.sort || '-match' + } + + containsValues () { + const exceptions = new Set([ 'sort', 'searchTarget' ]) + + const obj = this.toUrlObject() + for (const k of Object.keys(obj)) { + if (this.silentFilters.has(k)) continue + + if (obj[k] !== undefined && obj[k] !== '') return true + } + + return false + } + + reset () { + this.startDate = undefined + this.endDate = undefined + this.originallyPublishedStartDate = undefined + this.originallyPublishedEndDate = undefined + this.nsfw = undefined + this.categoryOneOf = undefined + this.licenceOneOf = undefined + this.languageOneOf = undefined + this.tagsOneOf = undefined + this.tagsAllOf = undefined + this.durationMin = undefined + this.durationMax = undefined + + this.sort = '-match' + } + + toUrlObject () { + return { + startDate: this.startDate, + endDate: this.endDate, + originallyPublishedStartDate: this.originallyPublishedStartDate, + originallyPublishedEndDate: this.originallyPublishedEndDate, + nsfw: this.nsfw, + categoryOneOf: this.categoryOneOf, + licenceOneOf: this.licenceOneOf, + languageOneOf: this.languageOneOf, + tagsOneOf: this.tagsOneOf, + tagsAllOf: this.tagsAllOf, + durationMin: this.durationMin, + durationMax: this.durationMax, + sort: this.sort, + searchTarget: this.searchTarget + } + } + + toAPIObject () { + return { + startDate: this.startDate, + endDate: this.endDate, + originallyPublishedStartDate: this.originallyPublishedStartDate, + originallyPublishedEndDate: this.originallyPublishedEndDate, + nsfw: this.nsfw, + categoryOneOf: this.intoArray(this.categoryOneOf), + licenceOneOf: this.intoArray(this.licenceOneOf), + languageOneOf: this.intoArray(this.languageOneOf), + tagsOneOf: this.intoArray(this.tagsOneOf), + tagsAllOf: this.intoArray(this.tagsAllOf), + durationMin: this.durationMin, + durationMax: this.durationMax, + sort: this.sort, + searchTarget: this.searchTarget + } + } + + size () { + let acc = 0 + + const obj = this.toUrlObject() + for (const k of Object.keys(obj)) { + if (this.silentFilters.has(k)) continue + + if (obj[k] !== undefined && obj[k] !== '') acc++ + } + + return acc + } + + private intoArray (value: any) { + if (!value) return undefined + if (Array.isArray(value)) return value + + if (typeof value === 'string') return value.split(',') + + return [ value ] + } +} diff --git a/client/src/app/shared/shared-search/index.ts b/client/src/app/shared/shared-search/index.ts new file mode 100644 index 000000000..f687f6767 --- /dev/null +++ b/client/src/app/shared/shared-search/index.ts @@ -0,0 +1,3 @@ +export * from './advanced-search.model' +export * from './search.service' +export * from './shared-search.module' diff --git a/client/src/app/shared/shared-search/search.service.ts b/client/src/app/shared/shared-search/search.service.ts new file mode 100644 index 000000000..96b954c99 --- /dev/null +++ b/client/src/app/shared/shared-search/search.service.ts @@ -0,0 +1,88 @@ +import { Observable } from 'rxjs' +import { catchError, map, switchMap } from 'rxjs/operators' +import { HttpClient, HttpParams } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core' +import { peertubeLocalStorage } from '@app/helpers' +import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' +import { ResultList, SearchTargetType, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models' +import { environment } from '../../../environments/environment' +import { AdvancedSearch } from './advanced-search.model' + +@Injectable() +export class SearchService { + static BASE_SEARCH_URL = environment.apiUrl + '/api/v1/search/' + + constructor ( + private authHttp: HttpClient, + private restExtractor: RestExtractor, + private restService: RestService, + private videoService: VideoService + ) { + // Add ability to override search endpoint if the user updated this local storage key + const searchUrl = peertubeLocalStorage.getItem('search-url') + if (searchUrl) SearchService.BASE_SEARCH_URL = searchUrl + } + + searchVideos (parameters: { + search: string, + componentPagination?: ComponentPaginationLight, + advancedSearch?: AdvancedSearch + }): Observable> { + const { search, componentPagination, advancedSearch } = parameters + + const url = SearchService.BASE_SEARCH_URL + 'videos' + let pagination: RestPagination + + if (componentPagination) { + pagination = this.restService.componentPaginationToRestPagination(componentPagination) + } + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + + if (search) params = params.append('search', search) + + if (advancedSearch) { + const advancedSearchObject = advancedSearch.toAPIObject() + params = this.restService.addObjectParams(params, advancedSearchObject) + } + + return this.authHttp + .get>(url, { params }) + .pipe( + switchMap(res => this.videoService.extractVideos(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + searchVideoChannels (parameters: { + search: string, + searchTarget?: SearchTargetType, + componentPagination?: ComponentPaginationLight + }): Observable> { + const { search, componentPagination, searchTarget } = parameters + + const url = SearchService.BASE_SEARCH_URL + 'video-channels' + + let pagination: RestPagination + if (componentPagination) { + pagination = this.restService.componentPaginationToRestPagination(componentPagination) + } + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination) + params = params.append('search', search) + + if (searchTarget) { + params = params.append('searchTarget', searchTarget as string) + } + + return this.authHttp + .get>(url, { params }) + .pipe( + map(res => VideoChannelService.extractVideoChannels(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } +} diff --git a/client/src/app/shared/shared-search/shared-search.module.ts b/client/src/app/shared/shared-search/shared-search.module.ts new file mode 100644 index 000000000..134300d88 --- /dev/null +++ b/client/src/app/shared/shared-search/shared-search.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core' +import { SharedMainModule } from '../shared-main' +import { SearchService } from './search.service' + +@NgModule({ + imports: [ + SharedMainModule + ], + + declarations: [ + ], + + exports: [ + ], + + providers: [ + SearchService + ] +}) +export class SharedSearchModule { } 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 deleted file mode 100644 index b05852ff8..000000000 --- a/client/src/app/videos/+video-edit/shared/i18n-primeng-calendar.service.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 6a9e31b5a..000000000 --- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - 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 deleted file mode 100644 index b257a16a9..000000000 --- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss +++ /dev/null @@ -1,20 +0,0 @@ -@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 deleted file mode 100644 index a90d04ce8..000000000 --- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index c11a60dce..000000000 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.html +++ /dev/null @@ -1,280 +0,0 @@ -
- - -
-
- - 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 deleted file mode 100644 index 69b907288..000000000 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ /dev/null @@ -1,197 +0,0 @@ -// 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 deleted file mode 100644 index 239e453ad..000000000 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts +++ /dev/null @@ -1,274 +0,0 @@ -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 deleted file mode 100644 index 96061a300..000000000 --- a/client/src/app/videos/+video-edit/shared/video-edit.module.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 7b1a38c62..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 7287f799d..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html +++ /dev/null @@ -1,76 +0,0 @@ -
-
- - -
- 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 deleted file mode 100644 index 1fef74994..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss +++ /dev/null @@ -1,18 +0,0 @@ -@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 deleted file mode 100644 index 5b453a1d9..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 '@app/videos/+video-edit/video-add-components/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 deleted file mode 100644 index 1910da403..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html +++ /dev/null @@ -1,72 +0,0 @@ -
-
- - -
- - - - - - 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 deleted file mode 100644 index d0bd1f54d..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts +++ /dev/null @@ -1,178 +0,0 @@ -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 '@app/videos/+video-edit/video-add-components/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 deleted file mode 100644 index ebe14c59e..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-send.scss +++ /dev/null @@ -1,46 +0,0 @@ -@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 deleted file mode 100644 index 94479321d..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-send.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index dad88a661..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html +++ /dev/null @@ -1,90 +0,0 @@ -
-
- - -
- 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 deleted file mode 100644 index a4f87b0b8..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss +++ /dev/null @@ -1,49 +0,0 @@ -@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 deleted file mode 100644 index eb7ac32ae..000000000 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts +++ /dev/null @@ -1,306 +0,0 @@ -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 { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send' -import { LoadingBarService } from '@ngx-loading-bar/core' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { VideoPrivacy } from '@shared/models' - -@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 deleted file mode 100644 index 9ff66bea0..000000000 --- a/client/src/app/videos/+video-edit/video-add-routing.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 79bfc6e5c..000000000 --- a/client/src/app/videos/+video-edit/video-add.component.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
- 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 deleted file mode 100644 index 0ad57d897..000000000 --- a/client/src/app/videos/+video-edit/video-add.component.scss +++ /dev/null @@ -1,89 +0,0 @@ -@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 deleted file mode 100644 index 5bd768809..000000000 --- a/client/src/app/videos/+video-edit/video-add.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 477c1cf5e..000000000 --- a/client/src/app/videos/+video-edit/video-add.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index a04351b05..000000000 --- a/client/src/app/videos/+video-edit/video-update-routing.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index fbc642db9..000000000 --- a/client/src/app/videos/+video-edit/video-update.component.html +++ /dev/null @@ -1,22 +0,0 @@ -
-
- 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 deleted file mode 100644 index 7bd6eb553..000000000 --- a/client/src/app/videos/+video-edit/video-update.component.ts +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index 322c69629..000000000 --- a/client/src/app/videos/+video-edit/video-update.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NgModule } from '@angular/core' -import { CanDeactivateGuard } from '@app/core' -import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver' -import { VideoEditModule } from './shared/video-edit.module' -import { VideoUpdateRoutingModule } from './video-update-routing.module' -import { VideoUpdateComponent } from './video-update.component' - -@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 deleted file mode 100644 index 30bcf4d74..000000000 --- a/client/src/app/videos/+video-edit/video-update.resolver.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 9b43d91da..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html +++ /dev/null @@ -1,56 +0,0 @@ -
-
- 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 deleted file mode 100644 index b3725ab94..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss +++ /dev/null @@ -1,82 +0,0 @@ -@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 deleted file mode 100644 index 79505c779..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index 7c2aaeadd..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 002de57e4..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.html +++ /dev/null @@ -1,95 +0,0 @@ -
-
- - 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 deleted file mode 100644 index e7ef79561..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.scss +++ /dev/null @@ -1,189 +0,0 @@ -@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 deleted file mode 100644 index 27846c1ad..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index e85443196..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.model.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index a73fb9ca8..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comment.service.ts +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index dd1d43560..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.html +++ /dev/null @@ -1,98 +0,0 @@ -
-
-

- - - 1 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 deleted file mode 100644 index df42fae73..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.scss +++ /dev/null @@ -1,53 +0,0 @@ -@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 deleted file mode 100644 index df0018ec6..000000000 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts +++ /dev/null @@ -1,232 +0,0 @@ -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 deleted file mode 100644 index 5e6a2d518..000000000 --- a/client/src/app/videos/+video-watch/modal/video-share.component.html +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss deleted file mode 100644 index 091d4dc3b..000000000 --- a/client/src/app/videos/+video-watch/modal/video-share.component.scss +++ /dev/null @@ -1,79 +0,0 @@ -@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 deleted file mode 100644 index b42b775c1..000000000 --- a/client/src/app/videos/+video-watch/modal/video-share.component.ts +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 935656d23..000000000 --- a/client/src/app/videos/+video-watch/modal/video-support.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.scss b/client/src/app/videos/+video-watch/modal/video-support.component.scss deleted file mode 100644 index 184e09027..000000000 --- a/client/src/app/videos/+video-watch/modal/video-support.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.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 deleted file mode 100644 index 48d5f2948..000000000 --- a/client/src/app/videos/+video-watch/modal/video-support.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/timestamp-route-transformer.directive.ts b/client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts deleted file mode 100644 index 45e023695..000000000 --- a/client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 4b6767415..000000000 --- a/client/src/app/videos/+video-watch/video-duration-formatter.pipe.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 246ef83cf..000000000 --- a/client/src/app/videos/+video-watch/video-watch-playlist.component.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
-
- {{ 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 deleted file mode 100644 index 0b0a2a899..000000000 --- a/client/src/app/videos/+video-watch/video-watch-playlist.component.scss +++ /dev/null @@ -1,83 +0,0 @@ -@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 deleted file mode 100644 index 2c21be643..000000000 --- a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts +++ /dev/null @@ -1,201 +0,0 @@ -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 deleted file mode 100644 index d8fecb87d..000000000 --- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 0447268f0..000000000 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ /dev/null @@ -1,277 +0,0 @@ -
- -
-
- 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 deleted file mode 100644 index 2e083982e..000000000 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ /dev/null @@ -1,607 +0,0 @@ -@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 deleted file mode 100644 index 5b0b34c80..000000000 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ /dev/null @@ -1,782 +0,0 @@ -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 deleted file mode 100644 index a1c54f065..000000000 --- a/client/src/app/videos/+video-watch/video-watch.module.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 '@app/videos/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 deleted file mode 100644 index 028a5854b..000000000 --- a/client/src/app/videos/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './videos.module' diff --git a/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts b/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts deleted file mode 100644 index a376453bf..000000000 --- a/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Observable, of } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' -import { Injectable } from '@angular/core' -import { ServerService, UserService } from '@app/core' -import { AdvancedSearch } from '@app/search/advanced-search.model' -import { SearchService } from '@app/search/search.service' -import { Video, VideoService } from '@app/shared/shared-main' -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/recommendations/recommendation-info.model.ts b/client/src/app/videos/recommendations/recommendation-info.model.ts deleted file mode 100644 index 0233563bb..000000000 --- a/client/src/app/videos/recommendations/recommendation-info.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RecommendationInfo { - uuid: string - tags?: string[] -} diff --git a/client/src/app/videos/recommendations/recommendations.module.ts b/client/src/app/videos/recommendations/recommendations.module.ts deleted file mode 100644 index 03cc272ca..000000000 --- a/client/src/app/videos/recommendations/recommendations.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { InputSwitchModule } from 'primeng/inputswitch' -import { CommonModule } from '@angular/common' -import { NgModule } from '@angular/core' -import { SharedMainModule } from '@app/shared/shared-main' -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, - SharedVideoPlaylistModule, - SharedVideoMiniatureModule - ], - declarations: [ - RecommendedVideosComponent - ], - exports: [ - RecommendedVideosComponent - ], - providers: [ - RecommendedVideosStore, - RecentVideosRecommendationService - ] -}) -export class RecommendationsModule { -} diff --git a/client/src/app/videos/recommendations/recommendations.service.ts b/client/src/app/videos/recommendations/recommendations.service.ts deleted file mode 100644 index 1d79d35f6..000000000 --- a/client/src/app/videos/recommendations/recommendations.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/recommendations/recommended-videos.component.html b/client/src/app/videos/recommendations/recommended-videos.component.html deleted file mode 100644 index 0467cabf5..000000000 --- a/client/src/app/videos/recommendations/recommended-videos.component.html +++ /dev/null @@ -1,24 +0,0 @@ -
- -
-

- Other videos -

-
- AUTOPLAY - -
-
- - - - - -
-
-
-
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.scss b/client/src/app/videos/recommendations/recommended-videos.component.scss deleted file mode 100644 index b278c9654..000000000 --- a/client/src/app/videos/recommendations/recommended-videos.component.scss +++ /dev/null @@ -1,31 +0,0 @@ -.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/recommendations/recommended-videos.component.ts b/client/src/app/videos/recommendations/recommended-videos.component.ts deleted file mode 100644 index 016975341..000000000 --- a/client/src/app/videos/recommendations/recommended-videos.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -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/recommendations/recommended-videos.store.ts b/client/src/app/videos/recommendations/recommended-videos.store.ts deleted file mode 100644 index 8c3fb6480..000000000 --- a/client/src/app/videos/recommendations/recommended-videos.store.ts +++ /dev/null @@ -1,37 +0,0 @@ -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-list/index.ts b/client/src/app/videos/video-list/index.ts deleted file mode 100644 index af1bd58b7..000000000 --- a/client/src/app/videos/video-list/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index e6cfa4802..000000000 --- a/client/src/app/videos/video-list/overview/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 4458454d5..000000000 --- a/client/src/app/videos/video-list/overview/overview.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index ca986c634..000000000 --- a/client/src/app/videos/video-list/overview/video-overview.component.html +++ /dev/null @@ -1,52 +0,0 @@ -

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 deleted file mode 100644 index c1d10188a..000000000 --- a/client/src/app/videos/video-list/overview/video-overview.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -@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 deleted file mode 100644 index b3be1d7b5..000000000 --- a/client/src/app/videos/video-list/overview/video-overview.component.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 6765ad9b7..000000000 --- a/client/src/app/videos/video-list/overview/videos-overview.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index b4c71ac49..000000000 --- a/client/src/app/videos/video-list/video-local.component.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index ca14851bb..000000000 --- a/client/src/app/videos/video-list/video-most-liked.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index c9395133f..000000000 --- a/client/src/app/videos/video-list/video-recently-added.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 10eab18de..000000000 --- a/client/src/app/videos/video-list/video-trending.component.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index 41ad9b277..000000000 --- a/client/src/app/videos/video-list/video-user-subscriptions.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 16b65be63..000000000 --- a/client/src/app/videos/videos-routing.module.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { VideoLocalComponent } from '@app/videos/video-list/video-local.component' -import { MetaGuard } from '@ngx-meta/core' -import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' -import { VideoTrendingComponent } from './video-list/video-trending.component' -import { VideoMostLikedComponent } from './video-list/video-most-liked.component' -import { VideosComponent } from './videos.component' -import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' -import { VideoOverviewComponent } from './video-list/overview/video-overview.component' - -const videosRoutes: Routes = [ - { - path: 'videos', - 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 deleted file mode 100644 index 585a3ad9a..000000000 --- a/client/src/app/videos/videos.component.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 217e5bb50..000000000 --- a/client/src/app/videos/videos.module.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 { 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: [] -}) -export class VideosModule { } diff --git a/shared/models/videos/abuse/index.ts b/shared/models/videos/abuse/index.ts index bdeef1dad..f70bc736f 100644 --- a/shared/models/videos/abuse/index.ts +++ b/shared/models/videos/abuse/index.ts @@ -1,4 +1,5 @@ export * from './video-abuse-create.model' +export * from './video-abuse-reason.model' export * from './video-abuse-state.model' export * from './video-abuse-update.model' export * from './video-abuse-video-is.type'