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',
--- /dev/null
+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 {}
--- /dev/null
+<div class="margin-content">
+ <div i18n class="title-page title-page-single">
+ Login
+ </div>
+
+ <div class="alert alert-danger" i18n *ngIf="externalAuthError">
+ Sorry but there was an issue with the external login process. Please <a routerLink="/about">contact an administrator</a>.
+ </div>
+
+ <ng-container *ngIf="!externalAuthError && !isAuthenticatedWithExternalAuth">
+ <div class="looking-for-account alert alert-info" *ngIf="signupAllowed === false" role="alert">
+ <h6 class="alert-heading" i18n>
+ If you are looking for an account…
+ </h6>
+
+ <div i18n>
+ Currently this instance doesn't allow for user registration, but you can find an instance
+ that gives you the possibility to sign up for an account and upload your videos there.
+
+ <br />
+
+ Find yours among multiple instances at <a class="alert-link" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>.
+ </div>
+ </div>
+
+ <div *ngIf="error" class="alert alert-danger">{{ error }}
+ <span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span>
+ </div>
+
+ <div class="login-form-and-externals">
+
+ <form role="form" (ngSubmit)="login()" [formGroup]="form">
+ <div class="form-group">
+ <div>
+ <label i18n for="username">User</label>
+ <input
+ type="text" id="username" i18n-placeholder placeholder="Username or email address" required tabindex="1"
+ formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" #usernameInput
+ >
+ <a i18n *ngIf="signupAllowed === true" routerLink="/signup" class="create-an-account">
+ or create an account
+ </a>
+ </div>
+
+ <div *ngIf="formErrors.username" class="form-error">
+ {{ formErrors.username }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="password">Password</label>
+ <div>
+ <input
+ type="password" name="password" id="password" i18n-placeholder placeholder="Password" required tabindex="2" autocomplete="current-password"
+ formControlName="password" class="form-control" [ngClass]="{ 'input-error': formErrors['password'] }"
+ >
+ <a i18n-title class="forgot-password-button" (click)="openForgotPasswordModal()" title="Click here to reset your password">I forgot my password</a>
+ </div>
+ <div *ngIf="formErrors.password" class="form-error">
+ {{ formErrors.password }}
+ </div>
+ </div>
+
+ <input type="submit" i18n-value value="Login" [disabled]="!form.valid">
+ </form>
+
+ <div class="external-login-blocks" *ngIf="getExternalLogins().length !== 0">
+ <div class="block-title" i18n>Or sign in with</div>
+
+ <div>
+ <a class="external-login-block" *ngFor="let auth of getExternalLogins()" [href]="getAuthHref(auth)" role="button">
+ {{ auth.authDisplayName }}
+ </a>
+ </div>
+ </div>
+ </div>
+
+ </ng-container>
+</div>
+
+<ng-template #forgotPasswordModal>
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Forgot your password</h4>
+
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideForgotPasswordModal()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+
+ <div *ngIf="isEmailDisabled()" class="alert alert-danger" i18n>
+ We are sorry, you cannot recover your password because your instance administrator did not configure the PeerTube email system.
+ </div>
+
+ <div class="form-group" [hidden]="isEmailDisabled()">
+ <label i18n for="forgot-password-email">Email</label>
+ <input
+ type="email" id="forgot-password-email" i18n-placeholder placeholder="Email address" required
+ [(ngModel)]="forgotPasswordEmail" #forgotPasswordEmailInput
+ >
+ </div>
+ </div>
+
+ <div class="modal-footer inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hideForgotPasswordModal()" (key.enter)="hideForgotPasswordModal()"
+ >
+
+ <input
+ type="submit" i18n-value value="Send me an email to reset my password" class="action-button-submit"
+ (click)="askResetPassword()" [disabled]="!forgotPasswordEmailInput.validity.valid"
+ >
+ </div>
+</ng-template>
--- /dev/null
+@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)
+ }
+ }
+ }
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+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 {}
--- /dev/null
+<div class="margin-content">
+ <div i18n class="title-page title-page-single">
+ Reset my password
+ </div>
+
+ <form role="form" (ngSubmit)="resetPassword()" [formGroup]="form">
+ <div class="form-group">
+ <label i18n for="password">Password</label>
+ <input
+ type="password" name="password" id="password" i18n-placeholder placeholder="Password" required autocomplete="new-password"
+ formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
+ >
+ <div *ngIf="formErrors.password" class="form-error">
+ {{ formErrors.password }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="password-confirm">Confirm password</label>
+ <input
+ type="password" name="password-confirm" id="password-confirm" i18n-placeholder placeholder="Confirmed password" required autocomplete="new-password"
+ formControlName="password-confirm" [ngClass]="{ 'input-error': formErrors['password-confirm'] }"
+ >
+ <div *ngIf="formErrors['password-confirm']" class="form-error">
+ {{ formErrors['password-confirm'] }}
+ </div>
+ </div>
+
+ <input type="submit" i18n-value value="Reset my password" [disabled]="!form.valid || !isConfirmedPasswordValid()">
+ </form>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+input:not([type=submit]) {
+ @include peertube-input-text(340px);
+ display: block;
+}
+
+input[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+}
--- /dev/null
+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']
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+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<any> {
+ 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)
+ })
+ )
+ }
+}
--- /dev/null
+<form role="form" (ngSubmit)="formUpdated()">
+
+ <div class="row">
+ <div class="col-lg-4 col-md-6 col-xs-12">
+ <div class="form-group">
+ <div class="radio-label label-container">
+ <label i18n>Sort</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetField('sort', '-match')" *ngIf="advancedSearch.sort !== '-match'">
+ Reset
+ </button>
+ </div>
+
+ <div class="peertube-radio-container" *ngFor="let sort of sorts">
+ <input type="radio" name="sort" [id]="sort.id" [value]="sort.id" [(ngModel)]="advancedSearch.sort">
+ <label [for]="sort.id" class="radio">{{ sort.label }}</label>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="radio-label label-container">
+ <label i18n>Display sensitive content</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
+ Reset
+ </button>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
+ <label i18n for="sensitiveContentYes" class="radio">Yes</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
+ <label i18n for="sensitiveContentNo" class="radio">No</label>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="radio-label label-container">
+ <label i18n>Published date</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetLocalField('publishedDateRange')" *ngIf="publishedDateRange !== undefined">
+ Reset
+ </button>
+ </div>
+
+ <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
+ <input type="radio" (change)="inputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
+ <label [for]="date.id" class="radio">{{ date.label }}</label>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="label-container">
+ <label i18n for="original-publication-after">Original publication year</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetOriginalPublicationYears()" *ngIf="originallyPublishedStartYear || originallyPublishedEndYear">
+ Reset
+ </button>
+ </div>
+
+ <div class="row">
+ <div class="pl-0 col-sm-6">
+ <input
+ (change)="inputUpdated()"
+ (keydown.enter)="$event.preventDefault()"
+ type="text" id="original-publication-after" name="original-publication-after"
+ i18n-placeholder placeholder="After..."
+ [(ngModel)]="originallyPublishedStartYear"
+ class="form-control"
+ >
+ </div>
+ <div class="pr-0 col-sm-6">
+ <input
+ (change)="inputUpdated()"
+ (keydown.enter)="$event.preventDefault()"
+ type="text" id="original-publication-before" name="original-publication-before"
+ i18n-placeholder placeholder="Before..."
+ [(ngModel)]="originallyPublishedEndYear"
+ class="form-control"
+ >
+ </div>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="col-lg-4 col-md-6 col-xs-12">
+ <div class="form-group">
+ <div class="radio-label label-container">
+ <label i18n>Duration</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetLocalField('durationRange')" *ngIf="durationRange !== undefined">
+ Reset
+ </button>
+ </div>
+
+ <div class="peertube-radio-container" *ngFor="let duration of durationRanges">
+ <input type="radio" (change)="inputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
+ <label [for]="duration.id" class="radio">{{ duration.label }}</label>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="category">Category</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined">
+ Reset
+ </button>
+ <div class="peertube-select-container">
+ <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf" class="form-control">
+ <option [value]="undefined" i18n>Display all categories</option>
+ <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="licence">Licence</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetField('licenceOneOf')" *ngIf="advancedSearch.licenceOneOf !== undefined">
+ Reset
+ </button>
+ <div class="peertube-select-container">
+ <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf" class="form-control">
+ <option [value]="undefined" i18n>Display all licenses</option>
+ <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="language">Language</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetField('languageOneOf')" *ngIf="advancedSearch.languageOneOf !== undefined">
+ Reset
+ </button>
+ <div class="peertube-select-container">
+ <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf" class="form-control">
+ <option [value]="undefined" i18n>Display all languages</option>
+ <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-lg-4 col-md-6 col-xs-12">
+ <div class="form-group">
+ <label i18n for="tagsAllOf">All of these tags</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetField('tagsAllOf')" *ngIf="advancedSearch.tagsAllOf">
+ Reset
+ </button>
+ <tag-input
+ [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf"
+ [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
+ i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
+ [maxItems]="5" [modelAsStrings]="true"
+ ></tag-input>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="tagsOneOf">One of these tags</label>
+ <button i18n class="reset-button reset-button-small" (click)="resetField('tagsOneOf')" *ngIf="advancedSearch.tagsOneOf">
+ Reset
+ </button>
+ <tag-input
+ [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf"
+ [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
+ i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
+ [maxItems]="5" [modelAsStrings]="true"
+ ></tag-input>
+ </div>
+
+ <div class="form-group" *ngIf="isSearchTargetEnabled()">
+ <div class="radio-label label-container">
+ <label i18n>Search target</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input type="radio" name="searchTarget" id="searchTargetLocal" value="local" [(ngModel)]="advancedSearch.searchTarget">
+ <label i18n for="searchTargetLocal" class="radio">Instance</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input type="radio" name="searchTarget" id="searchTargetSearchIndex" value="search-index" [(ngModel)]="advancedSearch.searchTarget">
+ <label i18n for="searchTargetSearchIndex" class="radio">Vidiverse</label>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="submit-button">
+ <button i18n class="reset-button" (click)="reset()" *ngIf="advancedSearch.size()">
+ Reset
+ </button>
+
+ <input type="submit" i18n-value value="Filter">
+ </div>
+</form>
--- /dev/null
+@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;
--- /dev/null
+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<AdvancedSearch>()
+
+ videoCategories: VideoConstant<number>[] = []
+ videoLicences: VideoConstant<number>[] = []
+ videoLanguages: VideoConstant<string>[] = []
+
+ 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()
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
+ <div class="results-header">
+ <div class="first-line">
+ <div class="results-counter" *ngIf="pagination.totalItems">
+ <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} </span>
+
+ <span i18n *ngIf="advancedSearch.searchTarget === 'local'">on this instance</span>
+ <span i18n *ngIf="advancedSearch.searchTarget === 'search-index'">on the vidiverse</span>
+
+ <span *ngIf="currentSearch" i18n>
+ for <span class="search-value">{{ currentSearch }}</span>
+ </span>
+ </div>
+
+ <div
+ class="results-filter-button ml-auto" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed" role="button"
+ [attr.aria-expanded]="!isSearchFilterCollapsed" aria-controls="collapseBasic"
+ >
+ <span class="icon icon-filter"></span>
+ <ng-container i18n>
+ Filters
+ <span *ngIf="numberOfFilters() > 0" class="badge badge-secondary">{{ numberOfFilters() }}</span>
+ </ng-container>
+ </div>
+ </div>
+
+ <div class="results-filter collapse-transition" [ngbCollapse]="isSearchFilterCollapsed">
+ <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
+ </div>
+ </div>
+
+ <div i18n *ngIf="pagination.totalItems === 0 && results.length === 0" class="no-results">
+ No results found
+ </div>
+
+ <ng-container *ngFor="let result of results">
+ <div *ngIf="isVideoChannel(result)" class="entry video-channel">
+ <a [routerLink]="getChannelUrl(result)">
+ <img [src]="result.avatarUrl" alt="Avatar" />
+ </a>
+
+ <div class="video-channel-info">
+ <a [routerLink]="getChannelUrl(result)" class="video-channel-names">
+ <div class="video-channel-display-name">{{ result.displayName }}</div>
+ <div class="video-channel-name">{{ result.nameWithHost }}</div>
+ </a>
+
+ <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div>
+ </div>
+
+ <my-subscribe-button *ngIf="!hideActions()" [videoChannels]="[result]"></my-subscribe-button>
+ </div>
+
+ <div *ngIf="isVideo(result)" class="entry video">
+ <my-video-miniature
+ [video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
+ [displayOptions]="videoDisplayOptions" [useLazyLoadUrl]="advancedSearch.searchTarget === 'search-index'"
+ (videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
+ ></my-video-miniature>
+ </div>
+ </ng-container>
+
+</div>
--- /dev/null
+@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;
+ }
+ }
+ }
+ }
+ }
+ }
+}
--- /dev/null
+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'
+ )
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+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<any> {
+ 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)
+ })
+ )
+ }
+}
--- /dev/null
+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.'
+ })
+ }
+}
--- /dev/null
+<ng-template #modal>
+ <ng-container [formGroup]="form">
+
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Add caption</h4>
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+ <label i18n for="language">Language</label>
+ <div class="peertube-select-container">
+ <select id="language" formControlName="language" class="form-control">
+ <option></option>
+ <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
+ </select>
+ </div>
+
+ <div *ngIf="formErrors.language" class="form-error">
+ {{ formErrors.language }}
+ </div>
+
+ <div class="caption-file">
+ <my-reactive-file
+ formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file"
+ [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true"
+ i18n-ngbTooltip [ngbTooltip]="'(extensions: ' + videoCaptionExtensions.join(', ') + ')'"
+ ></my-reactive-file>
+ </div>
+
+ <div *ngIf="isReplacingExistingCaption()" class="warning-replace-caption" i18n>
+ This will replace an existing caption!
+ </div>
+ </div>
+
+ <div class="modal-footer inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hide()" (key.enter)="hide()"
+ >
+
+ <input
+ type="submit" i18n-value value="Add this caption" class="action-button-submit"
+ [disabled]="!form.valid" (click)="addCaption()"
+ >
+ </div>
+ </ng-container>
+</ng-template>
--- /dev/null
+@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
--- /dev/null
+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<VideoCaptionEdit>()
+
+ @ViewChild('modal', { static: true }) modal: ElementRef
+
+ videoCaptionLanguages: VideoConstant<string>[] = []
+
+ 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()
+ }
+}
--- /dev/null
+<div class="video-edit" [formGroup]="form">
+ <div ngbNav #nav="ngbNav" class="nav-tabs">
+
+ <ng-container ngbNavItem>
+ <a ngbNavLink i18n>Basic info</a>
+
+ <ng-template ngbNavContent>
+ <div class="row">
+ <div class="col-video-edit">
+ <div class="form-group">
+ <label i18n for="name">Title</label>
+ <input type="text" id="name" class="form-control" formControlName="name" />
+ <div *ngIf="formErrors.name" class="form-error">
+ {{ formErrors.name }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n class="label-tags">Tags</label>
+
+ <my-help>
+ <ng-template ptTemplate="customHtml">
+ <ng-container i18n>
+ Tags could be used to suggest relevant recommendations. <br />
+ There is a maximum of 5 tags. <br />
+ Press Enter to add a new tag.
+ </ng-container>
+ </ng-template>
+ </my-help>
+
+ <tag-input
+ [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
+ i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag"
+ formControlName="tags" [maxItems]="5" [modelAsStrings]="true"
+ ></tag-input>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="description">Description</label>
+
+ <my-help helpType="markdownText">
+ <ng-template ptTemplate="preHtml">
+ <ng-container i18n>
+ Video descriptions are truncated by default and require manual action to expand them.
+ </ng-container>
+ </ng-template>
+ </my-help>
+
+ <my-markdown-textarea [truncate]="250" formControlName="description" [markdownVideo]="true"></my-markdown-textarea>
+
+ <div *ngIf="formErrors.description" class="form-error">
+ {{ formErrors.description }}
+ </div>
+ </div>
+ </div>
+
+ <div class="col-video-edit">
+ <div class="form-group">
+ <label i18n>Channel</label>
+ <div class="peertube-select-container">
+ <select formControlName="channelId" class="form-control">
+ <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="category">Category</label>
+ <div class="peertube-select-container">
+ <select id="category" formControlName="category" class="form-control">
+ <option></option>
+ <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
+ </select>
+ </div>
+
+ <div *ngIf="formErrors.category" class="form-error">
+ {{ formErrors.category }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="licence">Licence</label>
+ <div class="peertube-select-container">
+ <select id="licence" formControlName="licence" class="form-control">
+ <option></option>
+ <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
+ </select>
+ </div>
+
+ <div *ngIf="formErrors.licence" class="form-error">
+ {{ formErrors.licence }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="language">Language</label>
+ <div class="peertube-select-container">
+ <select id="language" formControlName="language" class="form-control">
+ <option></option>
+ <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
+ </select>
+ </div>
+
+ <div *ngIf="formErrors.language" class="form-error">
+ {{ formErrors.language }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="privacy">Privacy</label>
+ <div class="peertube-select-container">
+ <select id="privacy" formControlName="privacy" class="form-control">
+ <option></option>
+ <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+ <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
+ </select>
+ </div>
+
+ <div *ngIf="formErrors.privacy" class="form-error">
+ {{ formErrors.privacy }}
+ </div>
+ </div>
+
+ <div *ngIf="schedulePublicationEnabled" class="form-group">
+ <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
+ <p-calendar
+ id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
+ [locale]="calendarLocale" [minDate]="minScheduledDate" [showTime]="true" [hideOnDateTimeSelect]="true"
+ >
+ </p-calendar>
+
+ <div *ngIf="formErrors.schedulePublicationAt" class="form-error">
+ {{ formErrors.schedulePublicationAt }}
+ </div>
+ </div>
+
+ <my-peertube-checkbox inputName="nsfw" formControlName="nsfw" helpPlacement="bottom-right">
+ <ng-template ptTemplate="label">
+ <ng-container i18n>This video contains mature or explicit content</ng-container>
+ </ng-template>
+
+ <ng-template ptTemplate="help">
+ <ng-container i18n>Some instances do not list videos containing mature or explicit content by default.</ng-container>
+ </ng-template>
+ </my-peertube-checkbox>
+
+ <my-peertube-checkbox *ngIf="waitTranscodingEnabled" inputName="waitTranscoding" formControlName="waitTranscoding" helpPlacement="bottom-right">
+ <ng-template ptTemplate="label">
+ <ng-container i18n>Wait transcoding before publishing the video</ng-container>
+ </ng-template>
+
+ <ng-template ptTemplate="help">
+ <ng-container i18n>If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends.</ng-container>
+ </ng-template>
+ </my-peertube-checkbox>
+
+ </div>
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem>
+ <a ngbNavLink i18n>Captions</a>
+
+ <ng-template ngbNavContent>
+ <div class="captions">
+
+ <div class="captions-header">
+ <a (click)="openAddCaptionModal()" class="create-caption">
+ <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+ <ng-container i18n>Add another caption</ng-container>
+ </a>
+ </div>
+
+ <div class="form-group" *ngFor="let videoCaption of videoCaptions">
+
+ <div class="caption-entry">
+ <ng-container *ngIf="!videoCaption.action">
+ <a
+ i18n-title title="See the subtitle file" class="caption-entry-label" target="_blank" rel="noopener noreferrer"
+ [href]="videoCaption.captionPath"
+ >{{ videoCaption.language.label }}</a>
+
+ <div i18n class="caption-entry-state">Already uploaded ✔</div>
+
+ <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
+ </ng-container>
+
+ <ng-container *ngIf="videoCaption.action === 'CREATE'">
+ <span class="caption-entry-label">{{ videoCaption.language.label }}</span>
+
+ <div i18n class="caption-entry-state caption-entry-state-create">Will be created on update</div>
+
+ <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel create</span>
+ </ng-container>
+
+ <ng-container *ngIf="videoCaption.action === 'REMOVE'">
+ <span class="caption-entry-label">{{ videoCaption.language.label }}</span>
+
+ <div i18n class="caption-entry-state caption-entry-state-delete">Will be deleted on update</div>
+
+ <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel deletion</span>
+ </ng-container>
+ </div>
+ </div>
+
+ <div i18n class="no-caption" *ngIf="videoCaptions?.length === 0">
+ No captions for now.
+ </div>
+
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem>
+ <a ngbNavLink i18n>Advanced settings</a>
+
+ <ng-template ngbNavContent>
+ <div class="row advanced-settings">
+ <div class="col-md-12 col-xl-8">
+
+ <div class="form-group">
+ <label i18n for="previewfile">Video preview</label>
+
+ <my-preview-upload
+ i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"
+ previewWidth="360px" previewHeight="200px"
+ ></my-preview-upload>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="support">Support</label>
+ <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help>
+ <my-markdown-textarea
+ id="support" formControlName="support" markdownType="enhanced"
+ [classes]="{ 'input-error': formErrors['support'] }"
+ ></my-markdown-textarea>
+ <div *ngIf="formErrors.support" class="form-error">
+ {{ formErrors.support }}
+ </div>
+ </div>
+ </div>
+
+ <div class="col-md-12 col-xl-4">
+ <div class="form-group originally-published-at">
+ <label i18n for="originallyPublishedAt">Original publication date</label>
+ <my-help i18n-preHtml preHtml="This is the date when the content was originally published (e.g. the release date for a film)"></my-help>
+ <p-calendar
+ id="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat"
+ [locale]="calendarLocale" [showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange"
+ >
+ </p-calendar>
+
+ <div *ngIf="formErrors.originallyPublishedAt" class="form-error">
+ {{ formErrors.originallyPublishedAt }}
+ </div>
+ </div>
+
+ <my-peertube-checkbox
+ inputName="commentsEnabled" formControlName="commentsEnabled"
+ i18n-labelText labelText="Enable video comments"
+ ></my-peertube-checkbox>
+
+ <my-peertube-checkbox
+ inputName="downloadEnabled" formControlName="downloadEnabled"
+ i18n-labelText labelText="Enable download"
+ ></my-peertube-checkbox>
+ </div>
+ </div>
+ </ng-template>
+ </ng-container>
+
+ </div>
+
+ <div [ngbNavOutlet]="nav"></div>
+</div>
+
+<my-video-caption-add-modal
+ #videoCaptionAddModal [existingCaptions]="existingCaptions" [serverConfig]="serverConfig" (captionAdded)="onCaptionAdded($event)"
+></my-video-caption-add-modal>
--- /dev/null
+// 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);
+ }
+ }
+ }
+}
--- /dev/null
+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<VideoPrivacy>[] = []
+ videoCategories: VideoConstant<number>[] = []
+ videoLicences: VideoConstant<number>[] = []
+ videoLanguages: VideoConstant<string>[] = []
+
+ 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 || '' })
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+import { Directive, Output, EventEmitter, HostBinding, HostListener } from '@angular/core'
+
+@Directive({
+ selector: '[dragDrop]'
+})
+export class DragDropDirective {
+ @Output() fileDropped = new EventEmitter<FileList>()
+
+ @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)
+ }
+}
--- /dev/null
+<div *ngIf="!hasImportedVideo" class="upload-video-container" dragDrop (fileDropped)="setTorrentFile($event)">
+ <div class="first-step-block">
+ <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
+
+ <div class="button-file form-control" [ngbTooltip]="'(extensions: .torrent)'">
+ <span i18n>Select the torrent to import</span>
+ <input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" />
+ </div>
+
+ <div class="torrent-or-magnet" i18n-data-content data-content="OR"></div>
+
+ <div class="form-group form-group-magnet-uri">
+ <label i18n for="magnetUri">Paste magnet URI</label>
+ <my-help>
+ <ng-template ptTemplate="customHtml">
+ <ng-container i18n>
+ 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.
+ </ng-container>
+ </ng-template>
+ </my-help>
+
+ <input type="text" id="magnetUri" [(ngModel)]="magnetUri" class="form-control" />
+ </div>
+
+ <div class="form-group">
+ <label i18n for="first-step-channel">Channel</label>
+ <div class="peertube-select-container">
+ <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
+ <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="first-step-privacy">Privacy</label>
+ <div class="peertube-select-container">
+ <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
+ <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <input
+ type="button" i18n-value value="Import"
+ [disabled]="!isMagnetUrlValid() || isImportingVideo" (click)="importVideo()"
+ />
+ </div>
+</div>
+
+<div *ngIf="error" class="alert alert-danger">
+ <div i18n>Sorry, but something went wrong</div>
+ {{ error }}
+</div>
+
+<div *ngIf="hasImportedVideo && !error" class="alert alert-info" i18n>
+ Congratulations, the video will be imported with BitTorrent! You can already add information about this video.
+</div>
+
+<!-- Hidden because we want to load the component -->
+<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
+ <my-video-edit
+ [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
+ [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
+ ></my-video-edit>
+
+ <div class="submit-container">
+ <div class="submit-button"
+ (click)="updateSecondStep()"
+ [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }"
+ >
+ <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
+ <input type="button" i18n-value value="Update" />
+ </div>
+ </div>
+</form>
--- /dev/null
+@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;
+ }
+}
+
+
--- /dev/null
+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<string>()
+ @Output() firstStepError = new EventEmitter<void>()
+ @ViewChild('torrentfileInput') torrentfileInput: ElementRef<HTMLInputElement>
+
+ 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())
+ }
+}
--- /dev/null
+<div *ngIf="!hasImportedVideo" class="upload-video-container">
+ <div class="first-step-block">
+ <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
+
+ <div class="form-group">
+ <label i18n for="targetUrl">URL</label>
+
+ <my-help>
+ <ng-template ptTemplate="customHtml">
+ <ng-container i18n>
+ You can import any URL <a href='https://rg3.github.io/youtube-dl/supportedsites.html' target='_blank' rel='noopener noreferrer'>supported by youtube-dl</a>
+ 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.
+ </ng-container>
+ </ng-template>
+ </my-help>
+
+ <input type="text" id="targetUrl" [(ngModel)]="targetUrl" class="form-control" />
+ </div>
+
+ <div class="form-group">
+ <label i18n for="first-step-channel">Channel</label>
+ <div class="peertube-select-container">
+ <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
+ <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="first-step-privacy">Privacy</label>
+ <div class="peertube-select-container">
+ <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
+ <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <input
+ type="button" i18n-value value="Import"
+ [disabled]="!isTargetUrlValid() || isImportingVideo" (click)="importVideo()"
+ />
+ </div>
+</div>
+
+
+<div *ngIf="error" class="alert alert-danger">
+ <div i18n>Sorry, but something went wrong</div>
+ {{ error }}
+</div>
+
+<div *ngIf="!error && hasImportedVideo" class="alert alert-info" i18n>
+ Congratulations, the video behind {{ targetUrl }} will be imported! You can already add information about this video.
+</div>
+
+<!-- Hidden because we want to load the component -->
+<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
+ <my-video-edit
+ [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
+ [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
+ ></my-video-edit>
+
+ <div class="submit-container">
+ <div class="submit-button"
+ (click)="updateSecondStep()"
+ [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }"
+ >
+ <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
+ <input type="button" i18n-value value="Update" />
+ </div>
+ </div>
+</form>
--- /dev/null
+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<string>()
+ @Output() firstStepError = new EventEmitter<void>()
+
+ 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
+ })
+ })
+ }
+ }
+}
--- /dev/null
+@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);
+ }
+}
--- /dev/null
+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<VideoPrivacy>[] = []
+ videoCaptions: VideoCaptionEdit[] = []
+
+ firstStepPrivacyId = 0
+ firstStepChannelId = 0
+
+ abstract firstStepDone: EventEmitter<string>
+ abstract firstStepError: EventEmitter<void>
+ 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
+ })
+ )
+ }
+}
--- /dev/null
+<div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)">
+ <div class="first-step-block">
+ <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
+
+ <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'">
+ <span i18n>Select the file to upload</span>
+ <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus />
+ </div>
+
+ <div class="form-group form-group-channel">
+ <label i18n for="first-step-channel">Channel</label>
+ <div class="peertube-select-container">
+ <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
+ <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="first-step-privacy">Privacy</label>
+ <div class="peertube-select-container">
+ <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
+ <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+ <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
+ </select>
+ </div>
+ </div>
+
+ <ng-container *ngIf="isUploadingAudioFile">
+ <div class="form-group audio-preview">
+ <label i18n for="previewfileUpload">Video background image</label>
+
+ <div i18n class="audio-image-info">
+ Image that will be merged with your audio file.
+ <br />
+ The chosen image will be definitive and cannot be modified.
+ </div>
+
+ <my-preview-upload
+ i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="previewfileUpload"
+ previewWidth="360px" previewHeight="200px"
+ ></my-preview-upload>
+ </div>
+
+ <div class="form-group upload-audio-button">
+ <my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button>
+ </div>
+ </ng-container>
+ </div>
+</div>
+
+<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
+ <div class="progress" i18n-title title="Total video quota">
+ <div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100">
+ <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
+ <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
+ </div>
+ </div>
+ <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" />
+</div>
+
+<div *ngIf="error" class="alert alert-danger">
+ <div i18n>Sorry, but something went wrong</div>
+ {{ error }}
+</div>
+
+<div *ngIf="videoUploaded && !error" class="alert alert-info" i18n>
+ Congratulations! Your video is now available in your private library.
+</div>
+
+<!-- Hidden because we want to load the component -->
+<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form" class="mb-3">
+ <my-video-edit
+ [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
+ [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
+ [waitTranscodingEnabled]="waitTranscodingEnabled"
+ ></my-video-edit>
+
+ <div class="submit-container">
+ <div i18n *ngIf="videoUploaded === false" class="message-submit">Publish will be available when upload is finished</div>
+
+ <div class="submit-button"
+ (click)="updateSecondStep()"
+ [ngClass]="{ disabled: isPublishingButtonDisabled() }"
+ >
+ <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
+ <input [disabled]="isPublishingButtonDisabled()" type="button" i18n-value value="Publish" />
+ </div>
+ </div>
+</form>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+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<string>()
+ @Output() firstStepError = new EventEmitter<void>()
+ @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
+
+ // 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))
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+<div class="margin-content">
+ <div class="alert alert-warning" *ngIf="isRootUser()" i18n>
+ We recommend you to not use the <strong>root</strong> user to publish your videos, since it's the super-admin account of your instance.
+ <br />
+ Instead, <a routerLink="/admin/users">create a dedicated account</a> to upload your videos.
+ </div>
+
+ <div class="title-page title-page-single" *ngIf="isInSecondStep()">
+ <ng-container *ngIf="secondStepType === 'import-url' || secondStepType === 'import-torrent'" i18n>Import {{ videoName }}</ng-container>
+ <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container>
+ </div>
+
+ <div ngbNav #nav="ngbNav" class="nav-tabs video-add-nav" [ngClass]="{ 'hide-nav': secondStepType !== undefined }">
+ <ng-container ngbNavItem>
+ <a ngbNavLink>
+ <span i18n>Upload a file</span>
+ </a>
+
+ <ng-template ngbNavContent>
+ <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)" (firstStepError)="onError()"></my-video-upload>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem *ngIf="isVideoImportHttpEnabled()">
+ <a ngbNavLink>
+ <span i18n>Import with URL</span>
+ </a>
+
+ <ng-template ngbNavContent>
+ <my-video-import-url #videoImportUrl (firstStepDone)="onFirstStepDone('import-url', $event)" (firstStepError)="onError()"></my-video-import-url>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem *ngIf="isVideoImportTorrentEnabled()">
+ <a ngbNavLink>
+ <span i18n>Import with torrent</span>
+ </a>
+
+ <ng-template ngbNavContent>
+ <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent>
+ </ng-template>
+ </ng-container>
+ </div>
+
+ <div [ngbNavOutlet]="nav"></div>
+</div>
--- /dev/null
+@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();
+ }
+}
--- /dev/null
+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'
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+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 {}
--- /dev/null
+<div class="margin-content">
+ <div class="title-page title-page-single">
+ <span class="mr-1" i18n>Update</span>
+ <a [routerLink]="[ '/videos/watch', video.uuid ]">{{ video?.name }}</a>
+ </div>
+
+ <form novalidate [formGroup]="form">
+
+ <my-video-edit
+ [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
+ [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
+ [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
+ ></my-video-edit>
+
+ <div class="submit-container">
+ <div class="submit-button" (click)="update()" [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }">
+ <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
+ <input type="button" i18n-value value="Update" />
+ </div>
+ </div>
+ </form>
+</div>
--- /dev/null
+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
+ })
+ })
+ }
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+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<any> {
+ 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 }))
+ )
+ }
+}
--- /dev/null
+<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
+ <div class="avatar-and-textarea">
+ <img [src]="getAvatarUrl()" alt="Avatar" />
+
+ <div class="form-group">
+ <textarea i18n-placeholder placeholder="Add comment..." myAutoResize
+ [readonly]="(user === null) ? true : false"
+ (click)="openVisitorModal($event)"
+ formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }"
+ (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()" #textarea>
+
+ </textarea>
+ <div *ngIf="formErrors.text" class="form-error">
+ {{ formErrors.text }}
+ </div>
+ </div>
+ </div>
+
+ <div class="comment-buttons">
+ <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" type="button" i18n>
+ Cancel
+ </button>
+ <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n>
+ Reply
+ </button>
+ </div>
+</form>
+
+<ng-template #visitorModal let-modal>
+ <div class="modal-header">
+ <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4>
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideVisitorModal()"></my-global-icon>
+ </div>
+ <div class="modal-body">
+ <span i18n>
+ You can comment using an account on any ActivityPub-compatible instance.
+ On most platforms, you can find the video by typing its URL in the search bar and then comment it
+ from within the software's interface.
+ </span>
+ <span i18n>
+ If you have an account on Mastodon or Pleroma, you can open it directly in their interface:
+ </span>
+ <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe>
+ </div>
+ <div class="modal-footer inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hideVisitorModal()" (key.enter)="hideVisitorModal()"
+ >
+
+ <input
+ type="submit" i18n-value value="Login to comment" class="action-button-submit"
+ (click)="gotoLogin()"
+ >
+ </div>
+</ng-template>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+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<VideoComment>()
+ @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<VideoComment>
+
+ 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)
+ }
+}
--- /dev/null
+import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
+import { VideoComment } from './video-comment.model'
+
+export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
+ comment: VideoComment
+ children: VideoCommentThreadTree[]
+}
--- /dev/null
+<div class="root-comment">
+ <div class="left">
+ <a *ngIf="!comment.isDeleted" [href]="comment.account.url" target="_blank" rel="noopener noreferrer">
+ <img
+ class="comment-avatar"
+ [src]="comment.accountAvatarUrl"
+ (error)="switchToDefaultAvatar($event)"
+ alt="Avatar"
+ />
+ </a>
+
+ <div class="vertical-border"></div>
+ </div>
+
+ <div class="right" [ngClass]="{ 'mb-3': firstInThread }">
+ <span *ngIf="comment.isDeleted" class="comment-avatar"></span>
+
+ <div class="comment">
+ <ng-container *ngIf="!comment.isDeleted">
+ <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div>
+
+ <div class="comment-account-date">
+ <div class="comment-account">
+ <a
+ [routerLink]="[ '/accounts', comment.by ]"
+ class="comment-account-name" [ngClass]="{ 'video-author': video.account.id === comment.account.id }"
+ >
+ {{ comment.account.displayName }}
+ </a>
+
+ <a [href]="comment.account.url" target="_blank" rel="noopener noreferrer" class="comment-account-fid ml-1">{{ comment.by }}</a>
+ </div>
+ <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
+ class="comment-date" [title]="comment.createdAt">{{ comment.createdAt | myFromNow }}</a>
+ </div>
+ <div
+ class="comment-html"
+ [innerHTML]="sanitizedCommentHTML"
+ (timestampClicked)="handleTimestampClicked($event)"
+ timestampRouteTransformer
+ ></div>
+
+ <div class="comment-actions">
+ <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
+ <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
+
+ <my-user-moderation-dropdown
+ buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
+ ></my-user-moderation-dropdown>
+ </div>
+ </ng-container>
+
+ <ng-container *ngIf="comment.isDeleted">
+ <div class="comment-account-date">
+ <span class="comment-account" i18n>Deleted</span>
+ <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
+ class="comment-date">{{ comment.createdAt | myFromNow }}</a>
+ </div>
+
+ <div *ngIf="comment.isDeleted" class="comment-html comment-html-deleted">
+ <i i18n>This comment has been deleted</i>
+ </div>
+ </ng-container>
+
+ <my-video-comment-add
+ *ngIf="!comment.isDeleted && isUserLoggedIn() && inReplyToCommentId === comment.id"
+ [user]="user"
+ [video]="video"
+ [parentComment]="comment"
+ [parentComments]="newParentComments"
+ [focusOnInit]="true"
+ (commentCreated)="onCommentReplyCreated($event)"
+ (cancel)="onResetReply()"
+ ></my-video-comment-add>
+
+ <div *ngIf="commentTree" class="children">
+ <div *ngFor="let commentChild of commentTree.children">
+ <my-video-comment
+ [comment]="commentChild.comment"
+ [video]="video"
+ [inReplyToCommentId]="inReplyToCommentId"
+ [commentTree]="commentChild"
+ [parentComments]="newParentComments"
+ (wantedToReply)="onWantToReply($event)"
+ (wantedToDelete)="onWantToDelete($event)"
+ (resetReply)="onResetReply()"
+ (timestampClicked)="handleTimestampClicked($event)"
+ ></my-video-comment>
+ </div>
+ </div>
+
+ <ng-content></ng-content>
+ </div>
+ </div>
+</div>
--- /dev/null
+@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;
+ }
+ }
+ }
+ }
+}
--- /dev/null
+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<VideoComment>()
+ @Output() wantedToReply = new EventEmitter<VideoComment>()
+ @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
+ @Output() resetReply = new EventEmitter()
+ @Output() timestampClicked = new EventEmitter<number>()
+
+ 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
+ }
+ }
+}
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+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<ResultList<VideoComment>> {
+ 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<ResultList<VideoComment>>(url, { params })
+ .pipe(
+ map(result => this.extractVideoComments(result)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getVideoThreadComments (parameters: {
+ videoId: number | string,
+ threadId: number
+ }): Observable<VideoCommentThreadTree> {
+ const { videoId, threadId } = parameters
+ const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
+
+ return this.authHttp
+ .get<VideoCommentThreadTreeServerModel>(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<VideoCommentServerModel>) {
+ 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
+ }
+}
--- /dev/null
+<div>
+ <div class="title-block">
+ <h2 class="title-page title-page-single">
+ <ng-container *ngIf="componentPagination.totalItems > 0; then hasComments; else noComments"></ng-container>
+ <ng-template #hasComments>
+ <ng-container i18n *ngIf="componentPagination.totalItems === 1; else manyComments">1 Comment</ng-container>
+ <ng-template i18n #manyComments>{{ componentPagination.totalItems }} Comments</ng-template>
+ </ng-template>
+ <ng-template i18n #noComments>Comments</ng-template>
+ </h2>
+
+ <my-feed [syndicationItems]="syndicationItems"></my-feed>
+
+ <div ngbDropdown class="d-inline-block ml-4">
+ <button class="btn btn-sm btn-outline-secondary" id="dropdown-sort-comments" ngbDropdownToggle i18n>
+ SORT BY
+ </button>
+ <div ngbDropdownMenu aria-labelledby="dropdown-sort-comments">
+ <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button>
+ <button (click)="handleSortChange('-totalReplies')" ngbDropdownItem i18n>Most replies first</button>
+ </div>
+ </div>
+ </div>
+
+ <ng-template [ngIf]="video.commentsEnabled === true">
+ <my-video-comment-add
+ [video]="video"
+ [user]="user"
+ (commentCreated)="onCommentThreadCreated($event)"
+ ></my-video-comment-add>
+
+ <div *ngIf="componentPagination.totalItems === 0 && comments.length === 0" i18n>No comments.</div>
+
+ <div
+ class="comment-threads"
+ myInfiniteScroller
+ [autoInit]="true"
+ (nearOfBottom)="onNearOfBottom()"
+ [dataObservable]="onDataSubject.asObservable()"
+ >
+ <div>
+ <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
+ <my-video-comment
+ *ngIf="highlightedThread"
+ [comment]="highlightedThread"
+ [video]="video"
+ [inReplyToCommentId]="inReplyToCommentId"
+ [commentTree]="threadComments[highlightedThread.id]"
+ [highlightedComment]="true"
+ [firstInThread]="true"
+ (wantedToReply)="onWantedToReply($event)"
+ (wantedToDelete)="onWantedToDelete($event)"
+ (threadCreated)="onThreadCreated($event)"
+ (resetReply)="onResetReply()"
+ (timestampClicked)="handleTimestampClicked($event)"
+ ></my-video-comment>
+ </div>
+
+ <div *ngFor="let comment of comments; index as i">
+ <my-video-comment
+ *ngIf="!highlightedThread || comment.id !== highlightedThread.id"
+ [comment]="comment"
+ [video]="video"
+ [inReplyToCommentId]="inReplyToCommentId"
+ [commentTree]="threadComments[comment.id]"
+ [firstInThread]="i + 1 !== comments.length"
+ (wantedToReply)="onWantedToReply($event)"
+ (wantedToDelete)="onWantedToDelete($event)"
+ (threadCreated)="onThreadCreated($event)"
+ (resetReply)="onResetReply()"
+ (timestampClicked)="handleTimestampClicked($event)"
+ >
+ <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies mb-2">
+ <span class="glyphicon glyphicon-menu-down"></span>
+
+ <ng-container *ngIf="comment.totalRepliesFromVideoAuthor > 0; then hasAuthorComments; else noAuthorComments"></ng-container>
+ <ng-template #hasAuthorComments>
+ <ng-container *ngIf="comment.totalReplies !== comment.totalRepliesFromVideoAuthor; else onlyAuthorComments" i18n>
+ View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }} and others
+ </ng-container>
+ <ng-template i18n #onlyAuthorComments>
+ View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }}
+ </ng-template>
+ </ng-template>
+ <ng-template i18n #noAuthorComments>View {{ comment.totalReplies }} replies</ng-template>
+
+ <my-small-loader class="comment-thread-loading ml-1" [loading]="threadLoading[comment.id]"></my-small-loader>
+ </div>
+ </my-video-comment>
+
+ </div>
+ </div>
+ </ng-template>
+
+ <div *ngIf="video.commentsEnabled === false" i18n>
+ Comments are disabled.
+ </div>
+</div>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+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<number>()
+
+ 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<any[]>()
+
+ 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)
+ }
+}
--- /dev/null
+<ng-template #modal let-hide="close">
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Share</h4>
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+
+ <div class="modal-body">
+ <div class="playlist" *ngIf="hasPlaylist()">
+ <div class="title-page title-page-single" i18n>Share the playlist</div>
+
+ <my-input-readonly-copy [value]="getPlaylistUrl()"></my-input-readonly-copy>
+
+ <div class="filters">
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="includeVideoInPlaylist" [(ngModel)]="includeVideoInPlaylist"
+ i18n-labelText labelText="Share the playlist at this video position"
+ ></my-peertube-checkbox>
+ </div>
+
+ </div>
+ </div>
+
+
+ <div class="video">
+ <div class="title-page title-page-single" *ngIf="hasPlaylist()" i18n>Share the video</div>
+
+ <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeId">
+
+ <ng-container ngbNavItem="url">
+ <a ngbNavLink i18n>URL</a>
+
+ <ng-template ngbNavContent>
+ <div class="nav-content">
+ <my-input-readonly-copy [value]="getVideoUrl()"></my-input-readonly-copy>
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem="qrcode">
+ <a ngbNavLink i18n>QR-Code</a>
+
+ <ng-template ngbNavContent>
+ <div class="nav-content">
+ <qrcode [qrdata]="getVideoUrl()" [size]="256" level="Q"></qrcode>
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem="embed">
+ <a ngbNavLink i18n>Embed</a>
+
+ <ng-template ngbNavContent>
+ <div class="nav-content">
+ <my-input-readonly-copy [value]="getVideoIframeCode()"></my-input-readonly-copy>
+
+ <div i18n *ngIf="notSecure()" class="alert alert-warning">
+ The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS websites).
+ </div>
+ </div>
+ </ng-template>
+ </ng-container>
+
+ </div>
+
+ <div [ngbNavOutlet]="nav"></div>
+
+ <div class="filters">
+ <div>
+ <div class="form-group start-at">
+ <my-peertube-checkbox
+ inputName="startAt" [(ngModel)]="customizations.startAtCheckbox"
+ i18n-labelText labelText="Start at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="customizations.startAt"
+ [maxTimestamp]="video.duration"
+ [disabled]="!customizations.startAtCheckbox"
+ [(ngModel)]="customizations.startAt"
+ >
+ </my-timestamp-input>
+ </div>
+
+ <div *ngIf="videoCaptions.length !== 0" class="form-group video-caption-block">
+ <my-peertube-checkbox
+ inputName="subtitleCheckbox" [(ngModel)]="customizations.subtitleCheckbox"
+ i18n-labelText labelText="Auto select subtitle"
+ ></my-peertube-checkbox>
+
+ <div class="peertube-select-container" [ngClass]="{ disabled: !customizations.subtitleCheckbox }">
+ <select [(ngModel)]="customizations.subtitle" [disabled]="!customizations.subtitleCheckbox">
+ <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed">
+ <div>
+ <div class="form-group stop-at">
+ <my-peertube-checkbox
+ inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
+ i18n-labelText labelText="Stop at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="customizations.stopAt"
+ [maxTimestamp]="video.duration"
+ [disabled]="!customizations.stopAtCheckbox"
+ [(ngModel)]="customizations.stopAt"
+ >
+ </my-timestamp-input>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="autoplay" [(ngModel)]="customizations.autoplay"
+ i18n-labelText labelText="Autoplay"
+ ></my-peertube-checkbox>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="muted" [(ngModel)]="customizations.muted"
+ i18n-labelText labelText="Muted"
+ ></my-peertube-checkbox>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="loop" [(ngModel)]="customizations.loop"
+ i18n-labelText labelText="Loop"
+ ></my-peertube-checkbox>
+ </div>
+ </div>
+
+ <ng-container *ngIf="isInEmbedTab()">
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="title" [(ngModel)]="customizations.title"
+ i18n-labelText labelText="Display video title"
+ ></my-peertube-checkbox>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="warningTitle" [(ngModel)]="customizations.warningTitle"
+ i18n-labelText labelText="Display privacy warning"
+ ></my-peertube-checkbox>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="controls" [(ngModel)]="customizations.controls"
+ i18n-labelText labelText="Display player controls"
+ ></my-peertube-checkbox>
+ </div>
+ </ng-container>
+ </div>
+
+ <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button"
+ [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic">
+
+ <ng-container *ngIf="isAdvancedCustomizationCollapsed">
+ <span class="glyphicon glyphicon-menu-down"></span>
+
+ <ng-container i18n>
+ More customization
+ </ng-container>
+ </ng-container>
+
+ <ng-container *ngIf="!isAdvancedCustomizationCollapsed">
+ <span class="glyphicon glyphicon-menu-up"></span>
+
+ <ng-container i18n>
+ Less customization
+ </ng-container>
+ </ng-container>
+ </div>
+ </div>
+ </div>
+ </div>
+
+</ng-template>
--- /dev/null
+@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;
+ }
+ }
+}
--- /dev/null
+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
+ }
+ }
+}
--- /dev/null
+<ng-template #modal let-hide="close">
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Support {{ video.account.displayName }}</h4>
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body" [innerHTML]="videoHTMLSupport"></div>
+
+ <div class="modal-footer inputs">
+ <input
+ type="button" role="button" i18n-value value="Maybe later" class="action-button action-button-cancel"
+ (click)="hide()" (key.enter)="hide()"
+ >
+ </div>
+</ng-template>
--- /dev/null
+.action-button-cancel {
+ margin-right: 0 !important;
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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<Video[]> {
+ 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<Video[]> {
+ 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)
+ })
+ )
+ }
+}
--- /dev/null
+export interface RecommendationInfo {
+ uuid: string
+ tags?: string[]
+}
--- /dev/null
+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 {
+}
--- /dev/null
+import { Observable } from 'rxjs'
+import { Video } from '@app/shared/shared-main'
+import { RecommendationInfo } from './recommendation-info.model'
+
+export interface RecommendationService {
+ getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
+}
--- /dev/null
+<div class="other-videos">
+ <ng-container *ngIf="hasVideos$ | async">
+ <div class="title-page-container">
+ <h2 i18n class="title-page title-page-single">
+ Other videos
+ </h2>
+ <div *ngIf="!playlist" class="title-page-autoplay"
+ [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto"
+ >
+ <span i18n>AUTOPLAY</span>
+ <p-inputSwitch class="small" [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></p-inputSwitch>
+ </div>
+ </div>
+
+ <ng-container *ngFor="let video of (videos$ | async); let i = index; let length = count">
+ <my-video-miniature
+ [displayOptions]="displayOptions" [video]="video" [user]="userMiniature"
+ (videoBlocked)="onVideoRemoved()" (videoRemoved)="onVideoRemoved()">
+ </my-video-miniature>
+
+ <hr *ngIf="!playlist && i == 0 && length > 1" />
+ </ng-container>
+ </ng-container>
+</div>
--- /dev/null
+.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;
+}
--- /dev/null
+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<Video[]>()
+
+ autoPlayNextVideo: boolean
+ autoPlayNextVideoTooltip: string
+
+ displayOptions: MiniatureDisplayOptions = {
+ date: true,
+ views: true,
+ by: true,
+ avatar: true
+ }
+
+ userMiniature: User
+
+ readonly hasVideos$: Observable<boolean>
+ readonly videos$: Observable<Video[]>
+
+ 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)
+ )
+ }
+ }
+}
--- /dev/null
+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<Video[]>
+ public readonly hasRecommendations$: Observable<boolean>
+ private readonly requestsForLoad$$ = new ReplaySubject<RecommendationInfo>(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)
+ }
+}
--- /dev/null
+import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
+
+@Directive({
+ selector: '[timestampRouteTransformer]'
+})
+export class TimestampRouteTransformerDirective {
+ @Output() timestampClicked = new EventEmitter<number>()
+
+ @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()
+ }
+}
--- /dev/null
+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 })
+ }
+}
--- /dev/null
+<div *ngIf="playlist && video" class="playlist" myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()">
+ <div class="playlist-info">
+ <div class="playlist-display-name">
+ {{ playlist.displayName }}
+
+ <span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span>
+ <span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span>
+ <span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span>
+ </div>
+
+ <div class="playlist-by-index">
+ <div class="playlist-by">{{ playlist.ownerBy }}</div>
+ <div class="playlist-index">
+ <span>{{ currentPlaylistPosition }}</span><span>{{ playlistPagination.totalItems }}</span>
+ </div>
+ </div>
+
+ <div class="playlist-controls">
+ <my-global-icon
+ iconName="videos"
+ [class.active]="autoPlayNextVideoPlaylist"
+ (click)="switchAutoPlayNextVideoPlaylist()"
+ [ngbTooltip]="autoPlayNextVideoPlaylistSwitchText"
+ placement="bottom auto"
+ container="body"
+ ></my-global-icon>
+
+ <my-global-icon
+ iconName="repeat"
+ [class.active]="loopPlaylist"
+ (click)="switchLoopPlaylist()"
+ [ngbTooltip]="loopPlaylistSwitchText"
+ placement="bottom auto"
+ container="body"
+ ></my-global-icon>
+ </div>
+ </div>
+
+ <div *ngFor="let playlistElement of playlistElements">
+ <my-video-playlist-element-miniature
+ [playlistElement]="playlistElement" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
+ [playing]="currentPlaylistPosition === playlistElement.position" [accountLink]="false" [position]="playlistElement.position"
+ [touchScreenEditButton]="true"
+ ></my-video-playlist-element-miniature>
+ </div>
+</div>
--- /dev/null
+@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;
+ }
+ }
+ }
+}
+
--- /dev/null
+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')
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+<div class="root" [ngClass]="{ 'theater-enabled': theaterEnabled }">
+ <!-- We need the video container for videojs so we just hide it -->
+ <div id="video-wrapper">
+ <div *ngIf="remoteServerDown" class="remote-server-down">
+ Sorry, but this video is not available because the remote instance is not responding.
+ <br />
+ Please try again later.
+ </div>
+
+ <div id="videojs-wrapper"></div>
+
+ <my-video-watch-playlist
+ #videoWatchPlaylist
+ [video]="video" [playlist]="playlist" class="playlist"
+ ></my-video-watch-playlist>
+ </div>
+
+ <div class="row">
+ <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToImport()">
+ The video is being imported, it will be available when the import is finished.
+ </div>
+
+ <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToTranscode()">
+ The video is being transcoded, it may not work properly.
+ </div>
+
+ <div i18n class="col-md-12 alert alert-info" *ngIf="hasVideoScheduledPublication()">
+ This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
+ </div>
+
+ <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted">
+ <div class="blocked-label" i18n>This video is blocked.</div>
+ {{ video.blockedReason }}
+ </div>
+ </div>
+
+ <!-- Video information -->
+ <div *ngIf="video" class="margin-content video-bottom">
+ <div class="video-info">
+ <div class="video-info-first-row">
+ <div>
+ <div class="d-block d-md-none"> <!-- only shown on medium devices, has its counterpart for larger viewports below -->
+ <h1 class="video-info-name">{{ video.name }}</h1>
+
+ <div i18n class="video-info-date-views">
+ Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span>
+ </div>
+ </div>
+
+ <div class="d-flex justify-content-between flex-direction-column">
+ <div class="d-none d-md-block">
+ <h1 class="video-info-name">{{ video.name }}</h1>
+ </div>
+
+ <div class="video-info-first-row-bottom">
+ <div i18n class="d-none d-md-block video-info-date-views">
+ Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span>
+ </div>
+
+ <div class="video-actions-rates">
+ <div class="video-actions fullWidth justify-content-end">
+ <button
+ [ngbPopover]="getRatePopoverText()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" (keyup.enter)="setLike()"
+ class="action-button action-button-like" [attr.aria-pressed]="userRating === 'like'" [attr.aria-label]="tooltipLike"
+ [ngbTooltip]="tooltipLike"
+ placement="bottom auto"
+ >
+ <my-global-icon iconName="like"></my-global-icon>
+ <span *ngIf="video.likes" class="count">{{ video.likes }}</span>
+ </button>
+
+ <button
+ [ngbPopover]="getRatePopoverText()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" (keyup.enter)="setDislike()"
+ class="action-button action-button-dislike" [attr.aria-pressed]="userRating === 'dislike'" [attr.aria-label]="tooltipDislike"
+ [ngbTooltip]="tooltipDislike"
+ placement="bottom auto"
+ >
+ <my-global-icon iconName="dislike"></my-global-icon>
+ <span *ngIf="video.dislikes" class="count">{{ video.dislikes }}</span>
+ </button>
+
+ <button *ngIf="video.support" (click)="showSupportModal()" (keyup.enter)="showSupportModal()" class="action-button action-button-support" [attr.aria-label]="tooltipSupport"
+ [ngbTooltip]="tooltipSupport"
+ placement="bottom auto"
+ >
+ <my-global-icon iconName="support" aria-hidden="true"></my-global-icon>
+ <span class="icon-text" i18n>SUPPORT</span>
+ </button>
+
+ <button (click)="showShareModal()" (keyup.enter)="showShareModal()" class="action-button">
+ <my-global-icon iconName="share" aria-hidden="true"></my-global-icon>
+ <span class="icon-text" i18n>SHARE</span>
+ </button>
+
+ <div
+ class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
+ *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)"
+ [ngbTooltip]="tooltipSaveToPlaylist"
+ placement="bottom auto"
+ >
+ <button class="action-button action-button-save" ngbDropdownToggle>
+ <my-global-icon iconName="playlist-add" aria-hidden="true"></my-global-icon>
+ <span class="icon-text" i18n>SAVE</span>
+ </button>
+
+ <div ngbDropdownMenu>
+ <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist>
+ </div>
+ </div>
+
+ <my-video-actions-dropdown
+ placement="bottom auto" buttonDirection="horizontal" [buttonStyled]="true" [video]="video" [videoCaptions]="videoCaptions"
+ (videoRemoved)="onVideoRemoved()" (modalOpened)="onModalOpened()"
+ ></my-video-actions-dropdown>
+ </div>
+
+ <div class="video-info-likes-dislikes-bar-outer-container">
+ <div
+ class="video-info-likes-dislikes-bar-inner-container"
+ *ngIf="video.likes !== 0 || video.dislikes !== 0"
+ [ngbTooltip]="likesBarTooltipText"
+ placement="bottom"
+ >
+ <div
+ class="video-info-likes-dislikes-bar"
+ >
+ <div class="likes-bar" [ngClass]="{ 'liked': userRating !== 'none' }" [ngStyle]="{ 'width.%': video.likesPercent }"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="video-info-likes-dislikes-bar"
+ *ngIf="video.likes !== 0 || video.dislikes !== 0"
+ [ngbTooltip]="likesBarTooltipText"
+ placement="bottom"
+ >
+ <div class="likes-bar" [ngStyle]="{ 'width.%': video.likesPercent }"></div>
+ </div>
+ </div>
+ </div>
+
+
+ <div class="pt-3 border-top video-info-channel d-flex">
+ <div class="video-info-channel-left d-flex">
+ <avatar-channel [video]="video"></avatar-channel>
+
+ <div class="video-info-channel-left-links ml-1">
+ <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Channel page">
+ {{ video.channel.displayName }}
+ </a>
+ <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Account page">
+ <span i18n>By {{ video.byAccount }}</span>
+ </a>
+ </div>
+ </div>
+
+ <my-subscribe-button #subscribeButton [videoChannels]="[video.channel]" size="small"></my-subscribe-button>
+ </div>
+ </div>
+
+ </div>
+
+ <div class="video-info-description">
+ <div
+ class="video-info-description-html"
+ [innerHTML]="videoHTMLDescription"
+ (timestampClicked)="handleTimestampClicked($event)"
+ timestampRouteTransformer
+ ></div>
+
+ <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()">
+ <ng-container i18n>Show more</ng-container>
+ <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span>
+ <my-small-loader class="description-loading" [loading]="descriptionLoading"></my-small-loader>
+ </div>
+
+ <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more">
+ <ng-container i18n>Show less</ng-container>
+ <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span>
+ </div>
+ </div>
+
+ <div class="video-attributes mb-3">
+ <div class="video-attribute">
+ <span i18n class="video-attribute-label">Privacy</span>
+ <span class="video-attribute-value">{{ video.privacy.label }}</span>
+ </div>
+
+ <div *ngIf="video.isLocal === false" class="video-attribute">
+ <span i18n class="video-attribute-label">Origin instance</span>
+ <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="video.originInstanceUrl">{{ video.originInstanceHost }}</a>
+ </div>
+
+ <div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
+ <span i18n class="video-attribute-label">Originally published</span>
+ <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
+ </div>
+
+ <div class="video-attribute">
+ <span i18n class="video-attribute-label">Category</span>
+ <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span>
+ <a
+ *ngIf="video.category.id" class="video-attribute-value"
+ [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }"
+ >{{ video.category.label }}</a>
+ </div>
+
+ <div class="video-attribute">
+ <span i18n class="video-attribute-label">Licence</span>
+ <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span>
+ <a
+ *ngIf="video.licence.id" class="video-attribute-value"
+ [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }"
+ >{{ video.licence.label }}</a>
+ </div>
+
+ <div class="video-attribute">
+ <span i18n class="video-attribute-label">Language</span>
+ <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span>
+ <a
+ *ngIf="video.language.id" class="video-attribute-value"
+ [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }"
+ >{{ video.language.label }}</a>
+ </div>
+
+ <div class="video-attribute video-attribute-tags">
+ <span i18n class="video-attribute-label">Tags</span>
+ <a
+ *ngFor="let tag of getVideoTags()"
+ class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }"
+ >{{ tag }}</a>
+ </div>
+
+ <div class="video-attribute">
+ <span i18n class="video-attribute-label">Duration</span>
+ <span class="video-attribute-value">{{ video.duration | myVideoDurationFormatter }}</span>
+ </div>
+ </div>
+
+ <my-video-comments
+ class="border-top"
+ [video]="video"
+ [user]="user"
+ (timestampClicked)="handleTimestampClicked($event)"
+ ></my-video-comments>
+ </div>
+
+ <my-recommended-videos
+ [inputRecommendation]="{ uuid: video.uuid, tags: video.tags }"
+ [playlist]="playlist"
+ (gotRecommendations)="onRecommendations($event)"
+ ></my-recommended-videos>
+ </div>
+
+ <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false">
+ <div class="privacy-concerns-text">
+ <span class="mr-2">
+ <strong i18n>Friendly Reminder: </strong>
+ <ng-container i18n>
+ 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.
+ </ng-container>
+ </span>
+ <a i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube#privacy">More information</a>
+ </div>
+
+ <div i18n class="privacy-concerns-button privacy-concerns-okay" (click)="acceptedPrivacyConcern()">
+ OK
+ </div>
+ </div>
+</div>
+
+<ng-container *ngIf="video !== null">
+ <my-video-support #videoSupportModal [video]="video"></my-video-support>
+ <my-video-share #videoShareModal [video]="video" [videoCaptions]="videoCaptions" [playlist]="playlist"></my-video-share>
+</ng-container>
--- /dev/null
+@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;
+ }
+ }
+}
--- /dev/null
+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<any> } = {
+ 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)
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+export * from './videos.module'
--- /dev/null
+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'
--- /dev/null
+export * from './overview.service'
+export * from './video-overview.component'
+export * from './videos-overview.model'
--- /dev/null
+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<VideosOverview> {
+ let params = new HttpParams()
+ params = params.append('page', page + '')
+
+ return this.authHttp
+ .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
+ .pipe(
+ switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> {
+ const observables: Observable<any>[] = []
+ 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)
+ )
+ }
+
+}
--- /dev/null
+<h1 class="sr-only" i18n>Discover</h1>
+<div class="margin-content">
+
+ <div class="no-results" i18n *ngIf="notResults">No results.</div>
+
+ <div
+ myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
+ >
+ <ng-container *ngFor="let overview of overviews">
+
+ <div class="section videos" *ngFor="let object of overview.categories">
+ <h1 class="section-title">
+ <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
+ </h1>
+
+ <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+ <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+ </my-video-miniature>
+ </div>
+ </div>
+
+ <div class="section videos" *ngFor="let object of overview.tags">
+ <h2 class="section-title">
+ <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
+ </h2>
+
+ <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+ <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+ </my-video-miniature>
+ </div>
+ </div>
+
+ <div class="section channel videos" *ngFor="let object of overview.channels">
+ <div class="section-title">
+ <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
+ <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
+
+ <h2 class="section-title">{{ object.channel.displayName }}</h2>
+ </a>
+ </div>
+
+ <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+ <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+ </my-video-miniature>
+ </div>
+ </div>
+
+ </ng-container>
+
+ </div>
+
+</div>
--- /dev/null
+@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;
+}
--- /dev/null
+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<any>()
+
+ 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
+ }
+ )
+ }
+}
--- /dev/null
+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<number>
+ videos: Video[]
+ }[]
+
+ tags: {
+ tag: string
+ videos: Video[]
+ }[]
+ [key: string]: any
+}
--- /dev/null
+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()
+ }
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+import { Component } from '@angular/core'
+
+@Component({
+ template: '<router-outlet></router-outlet>'
+})
+export class VideosComponent {}
--- /dev/null
+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 { }
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 = [
{
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: '**',
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'
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')
HeaderComponent,
SearchTypeaheadComponent,
SuggestionComponent,
+ HighlightPipe,
CustomModalComponent,
WelcomeModalComponent,
SharedGlobalIconModule,
SharedInstanceModule,
- LoginModule,
- ResetPasswordModule,
- SearchModule,
-
- VideosModule,
-
MetaModule.forRoot({
provide: MetaLoader,
useFactory: (serverService: ServerService) => {
--- /dev/null
+
+import { Component } from '@angular/core'
+
+@Component({
+ selector: 'my-empty',
+ template: ''
+})
+export class EmptyComponent {
+
+}
--- /dev/null
+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) => `<span class="${highlightStyleName}">${match}</span>`
+ )
+
+ return replaced
+ } else {
+ return contentString
+ }
+ }
+}
+++ /dev/null
-export * from './login-routing.module'
-export * from './login.component'
-export * from './login.module'
+++ /dev/null
-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 {}
+++ /dev/null
-<div class="margin-content">
- <div i18n class="title-page title-page-single">
- Login
- </div>
-
- <div class="alert alert-danger" i18n *ngIf="externalAuthError">
- Sorry but there was an issue with the external login process. Please <a routerLink="/about">contact an administrator</a>.
- </div>
-
- <ng-container *ngIf="!externalAuthError && !isAuthenticatedWithExternalAuth">
- <div class="looking-for-account alert alert-info" *ngIf="signupAllowed === false" role="alert">
- <h6 class="alert-heading" i18n>
- If you are looking for an account…
- </h6>
-
- <div i18n>
- Currently this instance doesn't allow for user registration, but you can find an instance
- that gives you the possibility to sign up for an account and upload your videos there.
-
- <br />
-
- Find yours among multiple instances at <a class="alert-link" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>.
- </div>
- </div>
-
- <div *ngIf="error" class="alert alert-danger">{{ error }}
- <span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span>
- </div>
-
- <div class="login-form-and-externals">
-
- <form role="form" (ngSubmit)="login()" [formGroup]="form">
- <div class="form-group">
- <div>
- <label i18n for="username">User</label>
- <input
- type="text" id="username" i18n-placeholder placeholder="Username or email address" required tabindex="1"
- formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" #usernameInput
- >
- <a i18n *ngIf="signupAllowed === true" routerLink="/signup" class="create-an-account">
- or create an account
- </a>
- </div>
-
- <div *ngIf="formErrors.username" class="form-error">
- {{ formErrors.username }}
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="password">Password</label>
- <div>
- <input
- type="password" name="password" id="password" i18n-placeholder placeholder="Password" required tabindex="2" autocomplete="current-password"
- formControlName="password" class="form-control" [ngClass]="{ 'input-error': formErrors['password'] }"
- >
- <a i18n-title class="forgot-password-button" (click)="openForgotPasswordModal()" title="Click here to reset your password">I forgot my password</a>
- </div>
- <div *ngIf="formErrors.password" class="form-error">
- {{ formErrors.password }}
- </div>
- </div>
-
- <input type="submit" i18n-value value="Login" [disabled]="!form.valid">
- </form>
-
- <div class="external-login-blocks" *ngIf="getExternalLogins().length !== 0">
- <div class="block-title" i18n>Or sign in with</div>
-
- <div>
- <a class="external-login-block" *ngFor="let auth of getExternalLogins()" [href]="getAuthHref(auth)" role="button">
- {{ auth.authDisplayName }}
- </a>
- </div>
- </div>
- </div>
-
- </ng-container>
-</div>
-
-<ng-template #forgotPasswordModal>
- <div class="modal-header">
- <h4 i18n class="modal-title">Forgot your password</h4>
-
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideForgotPasswordModal()"></my-global-icon>
- </div>
-
- <div class="modal-body">
-
- <div *ngIf="isEmailDisabled()" class="alert alert-danger" i18n>
- We are sorry, you cannot recover your password because your instance administrator did not configure the PeerTube email system.
- </div>
-
- <div class="form-group" [hidden]="isEmailDisabled()">
- <label i18n for="forgot-password-email">Email</label>
- <input
- type="email" id="forgot-password-email" i18n-placeholder placeholder="Email address" required
- [(ngModel)]="forgotPasswordEmail" #forgotPasswordEmailInput
- >
- </div>
- </div>
-
- <div class="modal-footer inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hideForgotPasswordModal()" (key.enter)="hideForgotPasswordModal()"
- >
-
- <input
- type="submit" i18n-value value="Send me an email to reset my password" class="action-button-submit"
- (click)="askResetPassword()" [disabled]="!forgotPasswordEmailInput.validity.valid"
- >
- </div>
-</ng-template>
+++ /dev/null
-@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)
- }
- }
- }
-}
+++ /dev/null
-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
- }
-}
+++ /dev/null
-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 { }
+++ /dev/null
-export * from './reset-password-routing.module'
-export * from './reset-password.component'
-export * from './reset-password.module'
+++ /dev/null
-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 {}
+++ /dev/null
-<div class="margin-content">
- <div i18n class="title-page title-page-single">
- Reset my password
- </div>
-
- <form role="form" (ngSubmit)="resetPassword()" [formGroup]="form">
- <div class="form-group">
- <label i18n for="password">Password</label>
- <input
- type="password" name="password" id="password" i18n-placeholder placeholder="Password" required autocomplete="new-password"
- formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
- >
- <div *ngIf="formErrors.password" class="form-error">
- {{ formErrors.password }}
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="password-confirm">Confirm password</label>
- <input
- type="password" name="password-confirm" id="password-confirm" i18n-placeholder placeholder="Confirmed password" required autocomplete="new-password"
- formControlName="password-confirm" [ngClass]="{ 'input-error': formErrors['password-confirm'] }"
- >
- <div *ngIf="formErrors['password-confirm']" class="form-error">
- {{ formErrors['password-confirm'] }}
- </div>
- </div>
-
- <input type="submit" i18n-value value="Reset my password" [disabled]="!form.valid || !isConfirmedPasswordValid()">
- </form>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-input:not([type=submit]) {
- @include peertube-input-text(340px);
- display: block;
-}
-
-input[type=submit] {
- @include peertube-button;
- @include orange-button;
-}
+++ /dev/null
-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']
- }
-}
+++ /dev/null
-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 { }
+++ /dev/null
-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 ]
- }
-}
+++ /dev/null
-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<any> {
- 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)
- })
- )
- }
-}
+++ /dev/null
-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) => `<span class="${highlightStyleName}">${match}</span>`
- )
-
- return replaced
- } else {
- return contentString
- }
- }
-}
+++ /dev/null
-export * from './search-routing.module'
-export * from './search.component'
-export * from './search.module'
+++ /dev/null
-<form role="form" (ngSubmit)="formUpdated()">
-
- <div class="row">
- <div class="col-lg-4 col-md-6 col-xs-12">
- <div class="form-group">
- <div class="radio-label label-container">
- <label i18n>Sort</label>
- <button i18n class="reset-button reset-button-small" (click)="resetField('sort', '-match')" *ngIf="advancedSearch.sort !== '-match'">
- Reset
- </button>
- </div>
-
- <div class="peertube-radio-container" *ngFor="let sort of sorts">
- <input type="radio" name="sort" [id]="sort.id" [value]="sort.id" [(ngModel)]="advancedSearch.sort">
- <label [for]="sort.id" class="radio">{{ sort.label }}</label>
- </div>
- </div>
-
- <div class="form-group">
- <div class="radio-label label-container">
- <label i18n>Display sensitive content</label>
- <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
- Reset
- </button>
- </div>
-
- <div class="peertube-radio-container">
- <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
- <label i18n for="sensitiveContentYes" class="radio">Yes</label>
- </div>
-
- <div class="peertube-radio-container">
- <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
- <label i18n for="sensitiveContentNo" class="radio">No</label>
- </div>
- </div>
-
- <div class="form-group">
- <div class="radio-label label-container">
- <label i18n>Published date</label>
- <button i18n class="reset-button reset-button-small" (click)="resetLocalField('publishedDateRange')" *ngIf="publishedDateRange !== undefined">
- Reset
- </button>
- </div>
-
- <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
- <input type="radio" (change)="inputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
- <label [for]="date.id" class="radio">{{ date.label }}</label>
- </div>
- </div>
-
- <div class="form-group">
- <div class="label-container">
- <label i18n for="original-publication-after">Original publication year</label>
- <button i18n class="reset-button reset-button-small" (click)="resetOriginalPublicationYears()" *ngIf="originallyPublishedStartYear || originallyPublishedEndYear">
- Reset
- </button>
- </div>
-
- <div class="row">
- <div class="pl-0 col-sm-6">
- <input
- (change)="inputUpdated()"
- (keydown.enter)="$event.preventDefault()"
- type="text" id="original-publication-after" name="original-publication-after"
- i18n-placeholder placeholder="After..."
- [(ngModel)]="originallyPublishedStartYear"
- class="form-control"
- >
- </div>
- <div class="pr-0 col-sm-6">
- <input
- (change)="inputUpdated()"
- (keydown.enter)="$event.preventDefault()"
- type="text" id="original-publication-before" name="original-publication-before"
- i18n-placeholder placeholder="Before..."
- [(ngModel)]="originallyPublishedEndYear"
- class="form-control"
- >
- </div>
- </div>
- </div>
-
- </div>
-
- <div class="col-lg-4 col-md-6 col-xs-12">
- <div class="form-group">
- <div class="radio-label label-container">
- <label i18n>Duration</label>
- <button i18n class="reset-button reset-button-small" (click)="resetLocalField('durationRange')" *ngIf="durationRange !== undefined">
- Reset
- </button>
- </div>
-
- <div class="peertube-radio-container" *ngFor="let duration of durationRanges">
- <input type="radio" (change)="inputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
- <label [for]="duration.id" class="radio">{{ duration.label }}</label>
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="category">Category</label>
- <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined">
- Reset
- </button>
- <div class="peertube-select-container">
- <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf" class="form-control">
- <option [value]="undefined" i18n>Display all categories</option>
- <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
- </select>
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="licence">Licence</label>
- <button i18n class="reset-button reset-button-small" (click)="resetField('licenceOneOf')" *ngIf="advancedSearch.licenceOneOf !== undefined">
- Reset
- </button>
- <div class="peertube-select-container">
- <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf" class="form-control">
- <option [value]="undefined" i18n>Display all licenses</option>
- <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
- </select>
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="language">Language</label>
- <button i18n class="reset-button reset-button-small" (click)="resetField('languageOneOf')" *ngIf="advancedSearch.languageOneOf !== undefined">
- Reset
- </button>
- <div class="peertube-select-container">
- <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf" class="form-control">
- <option [value]="undefined" i18n>Display all languages</option>
- <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
- </select>
- </div>
- </div>
- </div>
-
- <div class="col-lg-4 col-md-6 col-xs-12">
- <div class="form-group">
- <label i18n for="tagsAllOf">All of these tags</label>
- <button i18n class="reset-button reset-button-small" (click)="resetField('tagsAllOf')" *ngIf="advancedSearch.tagsAllOf">
- Reset
- </button>
- <tag-input
- [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf"
- [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
- i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
- [maxItems]="5" [modelAsStrings]="true"
- ></tag-input>
- </div>
-
- <div class="form-group">
- <label i18n for="tagsOneOf">One of these tags</label>
- <button i18n class="reset-button reset-button-small" (click)="resetField('tagsOneOf')" *ngIf="advancedSearch.tagsOneOf">
- Reset
- </button>
- <tag-input
- [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf"
- [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
- i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
- [maxItems]="5" [modelAsStrings]="true"
- ></tag-input>
- </div>
-
- <div class="form-group" *ngIf="isSearchTargetEnabled()">
- <div class="radio-label label-container">
- <label i18n>Search target</label>
- </div>
-
- <div class="peertube-radio-container">
- <input type="radio" name="searchTarget" id="searchTargetLocal" value="local" [(ngModel)]="advancedSearch.searchTarget">
- <label i18n for="searchTargetLocal" class="radio">Instance</label>
- </div>
-
- <div class="peertube-radio-container">
- <input type="radio" name="searchTarget" id="searchTargetSearchIndex" value="search-index" [(ngModel)]="advancedSearch.searchTarget">
- <label i18n for="searchTargetSearchIndex" class="radio">Vidiverse</label>
- </div>
- </div>
- </div>
- </div>
-
- <div class="submit-button">
- <button i18n class="reset-button" (click)="reset()" *ngIf="advancedSearch.size()">
- Reset
- </button>
-
- <input type="submit" i18n-value value="Filter">
- </div>
-</form>
+++ /dev/null
-@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;
+++ /dev/null
-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<AdvancedSearch>()
-
- videoCategories: VideoConstant<number>[] = []
- videoLicences: VideoConstant<number>[] = []
- videoLanguages: VideoConstant<string>[] = []
-
- 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()
- }
-}
+++ /dev/null
-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 {}
+++ /dev/null
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
- <div class="results-header">
- <div class="first-line">
- <div class="results-counter" *ngIf="pagination.totalItems">
- <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} </span>
-
- <span i18n *ngIf="advancedSearch.searchTarget === 'local'">on this instance</span>
- <span i18n *ngIf="advancedSearch.searchTarget === 'search-index'">on the vidiverse</span>
-
- <span *ngIf="currentSearch" i18n>
- for <span class="search-value">{{ currentSearch }}</span>
- </span>
- </div>
-
- <div
- class="results-filter-button ml-auto" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed" role="button"
- [attr.aria-expanded]="!isSearchFilterCollapsed" aria-controls="collapseBasic"
- >
- <span class="icon icon-filter"></span>
- <ng-container i18n>
- Filters
- <span *ngIf="numberOfFilters() > 0" class="badge badge-secondary">{{ numberOfFilters() }}</span>
- </ng-container>
- </div>
- </div>
-
- <div class="results-filter collapse-transition" [ngbCollapse]="isSearchFilterCollapsed">
- <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
- </div>
- </div>
-
- <div i18n *ngIf="pagination.totalItems === 0 && results.length === 0" class="no-results">
- No results found
- </div>
-
- <ng-container *ngFor="let result of results">
- <div *ngIf="isVideoChannel(result)" class="entry video-channel">
- <a [routerLink]="getChannelUrl(result)">
- <img [src]="result.avatarUrl" alt="Avatar" />
- </a>
-
- <div class="video-channel-info">
- <a [routerLink]="getChannelUrl(result)" class="video-channel-names">
- <div class="video-channel-display-name">{{ result.displayName }}</div>
- <div class="video-channel-name">{{ result.nameWithHost }}</div>
- </a>
-
- <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div>
- </div>
-
- <my-subscribe-button *ngIf="!hideActions()" [videoChannels]="[result]"></my-subscribe-button>
- </div>
-
- <div *ngIf="isVideo(result)" class="entry video">
- <my-video-miniature
- [video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
- [displayOptions]="videoDisplayOptions" [useLazyLoadUrl]="advancedSearch.searchTarget === 'search-index'"
- (videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
- ></my-video-miniature>
- </div>
- </ng-container>
-
-</div>
+++ /dev/null
-@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;
- }
- }
- }
- }
- }
- }
-}
+++ /dev/null
-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'
- )
- }
-}
+++ /dev/null
-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 { }
+++ /dev/null
-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<ResultList<Video>> {
- 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<ResultList<VideoServerModel>>(url, { params })
- .pipe(
- switchMap(res => this.videoService.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- searchVideoChannels (parameters: {
- search: string,
- searchTarget?: SearchTargetType,
- componentPagination?: ComponentPaginationLight
- }): Observable<ResultList<VideoChannel>> {
- 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<ResultList<VideoChannelServerModel>>(url, { params })
- .pipe(
- map(res => VideoChannelService.extractVideoChannels(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-}
+++ /dev/null
-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<any> {
- 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)
- })
- )
- }
-}
--- /dev/null
+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 ]
+ }
+}
--- /dev/null
+export * from './advanced-search.model'
+export * from './search.service'
+export * from './shared-search.module'
--- /dev/null
+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<ResultList<Video>> {
+ 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<ResultList<VideoServerModel>>(url, { params })
+ .pipe(
+ switchMap(res => this.videoService.extractVideos(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ searchVideoChannels (parameters: {
+ search: string,
+ searchTarget?: SearchTargetType,
+ componentPagination?: ComponentPaginationLight
+ }): Observable<ResultList<VideoChannel>> {
+ 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<ResultList<VideoChannelServerModel>>(url, { params })
+ .pipe(
+ map(res => VideoChannelService.extractVideoChannels(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+}
--- /dev/null
+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 { }
+++ /dev/null
-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.'
- })
- }
-}
+++ /dev/null
-<ng-template #modal>
- <ng-container [formGroup]="form">
-
- <div class="modal-header">
- <h4 i18n class="modal-title">Add caption</h4>
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
- <div class="modal-body">
- <label i18n for="language">Language</label>
- <div class="peertube-select-container">
- <select id="language" formControlName="language" class="form-control">
- <option></option>
- <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
- </select>
- </div>
-
- <div *ngIf="formErrors.language" class="form-error">
- {{ formErrors.language }}
- </div>
-
- <div class="caption-file">
- <my-reactive-file
- formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file"
- [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true"
- i18n-ngbTooltip [ngbTooltip]="'(extensions: ' + videoCaptionExtensions.join(', ') + ')'"
- ></my-reactive-file>
- </div>
-
- <div *ngIf="isReplacingExistingCaption()" class="warning-replace-caption" i18n>
- This will replace an existing caption!
- </div>
- </div>
-
- <div class="modal-footer inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hide()" (key.enter)="hide()"
- >
-
- <input
- type="submit" i18n-value value="Add this caption" class="action-button-submit"
- [disabled]="!form.valid" (click)="addCaption()"
- >
- </div>
- </ng-container>
-</ng-template>
+++ /dev/null
-@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
+++ /dev/null
-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<VideoCaptionEdit>()
-
- @ViewChild('modal', { static: true }) modal: ElementRef
-
- videoCaptionLanguages: VideoConstant<string>[] = []
-
- 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()
- }
-}
+++ /dev/null
-<div class="video-edit" [formGroup]="form">
- <div ngbNav #nav="ngbNav" class="nav-tabs">
-
- <ng-container ngbNavItem>
- <a ngbNavLink i18n>Basic info</a>
-
- <ng-template ngbNavContent>
- <div class="row">
- <div class="col-video-edit">
- <div class="form-group">
- <label i18n for="name">Title</label>
- <input type="text" id="name" class="form-control" formControlName="name" />
- <div *ngIf="formErrors.name" class="form-error">
- {{ formErrors.name }}
- </div>
- </div>
-
- <div class="form-group">
- <label i18n class="label-tags">Tags</label>
-
- <my-help>
- <ng-template ptTemplate="customHtml">
- <ng-container i18n>
- Tags could be used to suggest relevant recommendations. <br />
- There is a maximum of 5 tags. <br />
- Press Enter to add a new tag.
- </ng-container>
- </ng-template>
- </my-help>
-
- <tag-input
- [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
- i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag"
- formControlName="tags" [maxItems]="5" [modelAsStrings]="true"
- ></tag-input>
- </div>
-
- <div class="form-group">
- <label i18n for="description">Description</label>
-
- <my-help helpType="markdownText">
- <ng-template ptTemplate="preHtml">
- <ng-container i18n>
- Video descriptions are truncated by default and require manual action to expand them.
- </ng-container>
- </ng-template>
- </my-help>
-
- <my-markdown-textarea [truncate]="250" formControlName="description" [markdownVideo]="true"></my-markdown-textarea>
-
- <div *ngIf="formErrors.description" class="form-error">
- {{ formErrors.description }}
- </div>
- </div>
- </div>
-
- <div class="col-video-edit">
- <div class="form-group">
- <label i18n>Channel</label>
- <div class="peertube-select-container">
- <select formControlName="channelId" class="form-control">
- <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
- </select>
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="category">Category</label>
- <div class="peertube-select-container">
- <select id="category" formControlName="category" class="form-control">
- <option></option>
- <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
- </select>
- </div>
-
- <div *ngIf="formErrors.category" class="form-error">
- {{ formErrors.category }}
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="licence">Licence</label>
- <div class="peertube-select-container">
- <select id="licence" formControlName="licence" class="form-control">
- <option></option>
- <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
- </select>
- </div>
-
- <div *ngIf="formErrors.licence" class="form-error">
- {{ formErrors.licence }}
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="language">Language</label>
- <div class="peertube-select-container">
- <select id="language" formControlName="language" class="form-control">
- <option></option>
- <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
- </select>
- </div>
-
- <div *ngIf="formErrors.language" class="form-error">
- {{ formErrors.language }}
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="privacy">Privacy</label>
- <div class="peertube-select-container">
- <select id="privacy" formControlName="privacy" class="form-control">
- <option></option>
- <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
- <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
- </select>
- </div>
-
- <div *ngIf="formErrors.privacy" class="form-error">
- {{ formErrors.privacy }}
- </div>
- </div>
-
- <div *ngIf="schedulePublicationEnabled" class="form-group">
- <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
- <p-calendar
- id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
- [locale]="calendarLocale" [minDate]="minScheduledDate" [showTime]="true" [hideOnDateTimeSelect]="true"
- >
- </p-calendar>
-
- <div *ngIf="formErrors.schedulePublicationAt" class="form-error">
- {{ formErrors.schedulePublicationAt }}
- </div>
- </div>
-
- <my-peertube-checkbox inputName="nsfw" formControlName="nsfw" helpPlacement="bottom-right">
- <ng-template ptTemplate="label">
- <ng-container i18n>This video contains mature or explicit content</ng-container>
- </ng-template>
-
- <ng-template ptTemplate="help">
- <ng-container i18n>Some instances do not list videos containing mature or explicit content by default.</ng-container>
- </ng-template>
- </my-peertube-checkbox>
-
- <my-peertube-checkbox *ngIf="waitTranscodingEnabled" inputName="waitTranscoding" formControlName="waitTranscoding" helpPlacement="bottom-right">
- <ng-template ptTemplate="label">
- <ng-container i18n>Wait transcoding before publishing the video</ng-container>
- </ng-template>
-
- <ng-template ptTemplate="help">
- <ng-container i18n>If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends.</ng-container>
- </ng-template>
- </my-peertube-checkbox>
-
- </div>
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem>
- <a ngbNavLink i18n>Captions</a>
-
- <ng-template ngbNavContent>
- <div class="captions">
-
- <div class="captions-header">
- <a (click)="openAddCaptionModal()" class="create-caption">
- <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
- <ng-container i18n>Add another caption</ng-container>
- </a>
- </div>
-
- <div class="form-group" *ngFor="let videoCaption of videoCaptions">
-
- <div class="caption-entry">
- <ng-container *ngIf="!videoCaption.action">
- <a
- i18n-title title="See the subtitle file" class="caption-entry-label" target="_blank" rel="noopener noreferrer"
- [href]="videoCaption.captionPath"
- >{{ videoCaption.language.label }}</a>
-
- <div i18n class="caption-entry-state">Already uploaded ✔</div>
-
- <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span>
- </ng-container>
-
- <ng-container *ngIf="videoCaption.action === 'CREATE'">
- <span class="caption-entry-label">{{ videoCaption.language.label }}</span>
-
- <div i18n class="caption-entry-state caption-entry-state-create">Will be created on update</div>
-
- <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel create</span>
- </ng-container>
-
- <ng-container *ngIf="videoCaption.action === 'REMOVE'">
- <span class="caption-entry-label">{{ videoCaption.language.label }}</span>
-
- <div i18n class="caption-entry-state caption-entry-state-delete">Will be deleted on update</div>
-
- <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Cancel deletion</span>
- </ng-container>
- </div>
- </div>
-
- <div i18n class="no-caption" *ngIf="videoCaptions?.length === 0">
- No captions for now.
- </div>
-
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem>
- <a ngbNavLink i18n>Advanced settings</a>
-
- <ng-template ngbNavContent>
- <div class="row advanced-settings">
- <div class="col-md-12 col-xl-8">
-
- <div class="form-group">
- <label i18n for="previewfile">Video preview</label>
-
- <my-preview-upload
- i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"
- previewWidth="360px" previewHeight="200px"
- ></my-preview-upload>
- </div>
-
- <div class="form-group">
- <label i18n for="support">Support</label>
- <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help>
- <my-markdown-textarea
- id="support" formControlName="support" markdownType="enhanced"
- [classes]="{ 'input-error': formErrors['support'] }"
- ></my-markdown-textarea>
- <div *ngIf="formErrors.support" class="form-error">
- {{ formErrors.support }}
- </div>
- </div>
- </div>
-
- <div class="col-md-12 col-xl-4">
- <div class="form-group originally-published-at">
- <label i18n for="originallyPublishedAt">Original publication date</label>
- <my-help i18n-preHtml preHtml="This is the date when the content was originally published (e.g. the release date for a film)"></my-help>
- <p-calendar
- id="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat"
- [locale]="calendarLocale" [showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange"
- >
- </p-calendar>
-
- <div *ngIf="formErrors.originallyPublishedAt" class="form-error">
- {{ formErrors.originallyPublishedAt }}
- </div>
- </div>
-
- <my-peertube-checkbox
- inputName="commentsEnabled" formControlName="commentsEnabled"
- i18n-labelText labelText="Enable video comments"
- ></my-peertube-checkbox>
-
- <my-peertube-checkbox
- inputName="downloadEnabled" formControlName="downloadEnabled"
- i18n-labelText labelText="Enable download"
- ></my-peertube-checkbox>
- </div>
- </div>
- </ng-template>
- </ng-container>
-
- </div>
-
- <div [ngbNavOutlet]="nav"></div>
-</div>
-
-<my-video-caption-add-modal
- #videoCaptionAddModal [existingCaptions]="existingCaptions" [serverConfig]="serverConfig" (captionAdded)="onCaptionAdded($event)"
-></my-video-caption-add-modal>
+++ /dev/null
-// 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);
- }
- }
- }
-}
+++ /dev/null
-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<VideoPrivacy>[] = []
- videoCategories: VideoConstant<number>[] = []
- videoLicences: VideoConstant<number>[] = []
- videoLanguages: VideoConstant<string>[] = []
-
- 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 || '' })
- }
-}
+++ /dev/null
-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 { }
+++ /dev/null
-import { Directive, Output, EventEmitter, HostBinding, HostListener } from '@angular/core'
-
-@Directive({
- selector: '[dragDrop]'
-})
-export class DragDropDirective {
- @Output() fileDropped = new EventEmitter<FileList>()
-
- @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)
- }
-}
+++ /dev/null
-<div *ngIf="!hasImportedVideo" class="upload-video-container" dragDrop (fileDropped)="setTorrentFile($event)">
- <div class="first-step-block">
- <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
-
- <div class="button-file form-control" [ngbTooltip]="'(extensions: .torrent)'">
- <span i18n>Select the torrent to import</span>
- <input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" />
- </div>
-
- <div class="torrent-or-magnet" i18n-data-content data-content="OR"></div>
-
- <div class="form-group form-group-magnet-uri">
- <label i18n for="magnetUri">Paste magnet URI</label>
- <my-help>
- <ng-template ptTemplate="customHtml">
- <ng-container i18n>
- 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.
- </ng-container>
- </ng-template>
- </my-help>
-
- <input type="text" id="magnetUri" [(ngModel)]="magnetUri" class="form-control" />
- </div>
-
- <div class="form-group">
- <label i18n for="first-step-channel">Channel</label>
- <div class="peertube-select-container">
- <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
- <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
- </select>
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="first-step-privacy">Privacy</label>
- <div class="peertube-select-container">
- <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
- <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
- </select>
- </div>
- </div>
-
- <input
- type="button" i18n-value value="Import"
- [disabled]="!isMagnetUrlValid() || isImportingVideo" (click)="importVideo()"
- />
- </div>
-</div>
-
-<div *ngIf="error" class="alert alert-danger">
- <div i18n>Sorry, but something went wrong</div>
- {{ error }}
-</div>
-
-<div *ngIf="hasImportedVideo && !error" class="alert alert-info" i18n>
- Congratulations, the video will be imported with BitTorrent! You can already add information about this video.
-</div>
-
-<!-- Hidden because we want to load the component -->
-<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
- <my-video-edit
- [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
- [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
- ></my-video-edit>
-
- <div class="submit-container">
- <div class="submit-button"
- (click)="updateSecondStep()"
- [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }"
- >
- <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
- <input type="button" i18n-value value="Update" />
- </div>
- </div>
-</form>
+++ /dev/null
-@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;
- }
-}
-
-
+++ /dev/null
-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<string>()
- @Output() firstStepError = new EventEmitter<void>()
- @ViewChild('torrentfileInput') torrentfileInput: ElementRef<HTMLInputElement>
-
- 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())
- }
-}
+++ /dev/null
-<div *ngIf="!hasImportedVideo" class="upload-video-container">
- <div class="first-step-block">
- <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
-
- <div class="form-group">
- <label i18n for="targetUrl">URL</label>
-
- <my-help>
- <ng-template ptTemplate="customHtml">
- <ng-container i18n>
- You can import any URL <a href='https://rg3.github.io/youtube-dl/supportedsites.html' target='_blank' rel='noopener noreferrer'>supported by youtube-dl</a>
- 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.
- </ng-container>
- </ng-template>
- </my-help>
-
- <input type="text" id="targetUrl" [(ngModel)]="targetUrl" class="form-control" />
- </div>
-
- <div class="form-group">
- <label i18n for="first-step-channel">Channel</label>
- <div class="peertube-select-container">
- <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
- <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
- </select>
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="first-step-privacy">Privacy</label>
- <div class="peertube-select-container">
- <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
- <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
- </select>
- </div>
- </div>
-
- <input
- type="button" i18n-value value="Import"
- [disabled]="!isTargetUrlValid() || isImportingVideo" (click)="importVideo()"
- />
- </div>
-</div>
-
-
-<div *ngIf="error" class="alert alert-danger">
- <div i18n>Sorry, but something went wrong</div>
- {{ error }}
-</div>
-
-<div *ngIf="!error && hasImportedVideo" class="alert alert-info" i18n>
- Congratulations, the video behind {{ targetUrl }} will be imported! You can already add information about this video.
-</div>
-
-<!-- Hidden because we want to load the component -->
-<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
- <my-video-edit
- [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
- [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
- ></my-video-edit>
-
- <div class="submit-container">
- <div class="submit-button"
- (click)="updateSecondStep()"
- [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }"
- >
- <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
- <input type="button" i18n-value value="Update" />
- </div>
- </div>
-</form>
+++ /dev/null
-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<string>()
- @Output() firstStepError = new EventEmitter<void>()
-
- 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
- })
- })
- }
- }
-}
+++ /dev/null
-@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);
- }
-}
+++ /dev/null
-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<VideoPrivacy>[] = []
- videoCaptions: VideoCaptionEdit[] = []
-
- firstStepPrivacyId = 0
- firstStepChannelId = 0
-
- abstract firstStepDone: EventEmitter<string>
- abstract firstStepError: EventEmitter<void>
- 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
- })
- )
- }
-}
+++ /dev/null
-<div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)">
- <div class="first-step-block">
- <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
-
- <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'">
- <span i18n>Select the file to upload</span>
- <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus />
- </div>
-
- <div class="form-group form-group-channel">
- <label i18n for="first-step-channel">Channel</label>
- <div class="peertube-select-container">
- <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
- <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
- </select>
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="first-step-privacy">Privacy</label>
- <div class="peertube-select-container">
- <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
- <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
- <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
- </select>
- </div>
- </div>
-
- <ng-container *ngIf="isUploadingAudioFile">
- <div class="form-group audio-preview">
- <label i18n for="previewfileUpload">Video background image</label>
-
- <div i18n class="audio-image-info">
- Image that will be merged with your audio file.
- <br />
- The chosen image will be definitive and cannot be modified.
- </div>
-
- <my-preview-upload
- i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="previewfileUpload"
- previewWidth="360px" previewHeight="200px"
- ></my-preview-upload>
- </div>
-
- <div class="form-group upload-audio-button">
- <my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button>
- </div>
- </ng-container>
- </div>
-</div>
-
-<div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel">
- <div class="progress" i18n-title title="Total video quota">
- <div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100">
- <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span>
- <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
- </div>
- </div>
- <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" />
-</div>
-
-<div *ngIf="error" class="alert alert-danger">
- <div i18n>Sorry, but something went wrong</div>
- {{ error }}
-</div>
-
-<div *ngIf="videoUploaded && !error" class="alert alert-info" i18n>
- Congratulations! Your video is now available in your private library.
-</div>
-
-<!-- Hidden because we want to load the component -->
-<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form" class="mb-3">
- <my-video-edit
- [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
- [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
- [waitTranscodingEnabled]="waitTranscodingEnabled"
- ></my-video-edit>
-
- <div class="submit-container">
- <div i18n *ngIf="videoUploaded === false" class="message-submit">Publish will be available when upload is finished</div>
-
- <div class="submit-button"
- (click)="updateSecondStep()"
- [ngClass]="{ disabled: isPublishingButtonDisabled() }"
- >
- <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
- <input [disabled]="isPublishingButtonDisabled()" type="button" i18n-value value="Publish" />
- </div>
- </div>
-</form>
+++ /dev/null
-@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;
- }
-}
+++ /dev/null
-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<string>()
- @Output() firstStepError = new EventEmitter<void>()
- @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
-
- // 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))
- }
-}
+++ /dev/null
-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 {}
+++ /dev/null
-<div class="margin-content">
- <div class="alert alert-warning" *ngIf="isRootUser()" i18n>
- We recommend you to not use the <strong>root</strong> user to publish your videos, since it's the super-admin account of your instance.
- <br />
- Instead, <a routerLink="/admin/users">create a dedicated account</a> to upload your videos.
- </div>
-
- <div class="title-page title-page-single" *ngIf="isInSecondStep()">
- <ng-container *ngIf="secondStepType === 'import-url' || secondStepType === 'import-torrent'" i18n>Import {{ videoName }}</ng-container>
- <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container>
- </div>
-
- <div ngbNav #nav="ngbNav" class="nav-tabs video-add-nav" [ngClass]="{ 'hide-nav': secondStepType !== undefined }">
- <ng-container ngbNavItem>
- <a ngbNavLink>
- <span i18n>Upload a file</span>
- </a>
-
- <ng-template ngbNavContent>
- <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)" (firstStepError)="onError()"></my-video-upload>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem *ngIf="isVideoImportHttpEnabled()">
- <a ngbNavLink>
- <span i18n>Import with URL</span>
- </a>
-
- <ng-template ngbNavContent>
- <my-video-import-url #videoImportUrl (firstStepDone)="onFirstStepDone('import-url', $event)" (firstStepError)="onError()"></my-video-import-url>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem *ngIf="isVideoImportTorrentEnabled()">
- <a ngbNavLink>
- <span i18n>Import with torrent</span>
- </a>
-
- <ng-template ngbNavContent>
- <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent>
- </ng-template>
- </ng-container>
- </div>
-
- <div [ngbNavOutlet]="nav"></div>
-</div>
+++ /dev/null
-@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();
- }
-}
+++ /dev/null
-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'
- }
-}
+++ /dev/null
-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 { }
+++ /dev/null
-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 {}
+++ /dev/null
-<div class="margin-content">
- <div class="title-page title-page-single">
- <span class="mr-1" i18n>Update</span>
- <a [routerLink]="[ '/videos/watch', video.uuid ]">{{ video?.name }}</a>
- </div>
-
- <form novalidate [formGroup]="form">
-
- <my-video-edit
- [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
- [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
- [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
- ></my-video-edit>
-
- <div class="submit-container">
- <div class="submit-button" (click)="update()" [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }">
- <my-global-icon iconName="validate" aria-hidden="true"></my-global-icon>
- <input type="button" i18n-value value="Update" />
- </div>
- </div>
- </form>
-</div>
+++ /dev/null
-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
- })
- })
- }
- }
-}
+++ /dev/null
-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 { }
+++ /dev/null
-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<any> {
- 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 }))
- )
- }
-}
+++ /dev/null
-<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
- <div class="avatar-and-textarea">
- <img [src]="getAvatarUrl()" alt="Avatar" />
-
- <div class="form-group">
- <textarea i18n-placeholder placeholder="Add comment..." myAutoResize
- [readonly]="(user === null) ? true : false"
- (click)="openVisitorModal($event)"
- formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }"
- (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()" #textarea>
-
- </textarea>
- <div *ngIf="formErrors.text" class="form-error">
- {{ formErrors.text }}
- </div>
- </div>
- </div>
-
- <div class="comment-buttons">
- <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" type="button" i18n>
- Cancel
- </button>
- <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n>
- Reply
- </button>
- </div>
-</form>
-
-<ng-template #visitorModal let-modal>
- <div class="modal-header">
- <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4>
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideVisitorModal()"></my-global-icon>
- </div>
- <div class="modal-body">
- <span i18n>
- You can comment using an account on any ActivityPub-compatible instance.
- On most platforms, you can find the video by typing its URL in the search bar and then comment it
- from within the software's interface.
- </span>
- <span i18n>
- If you have an account on Mastodon or Pleroma, you can open it directly in their interface:
- </span>
- <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe>
- </div>
- <div class="modal-footer inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hideVisitorModal()" (key.enter)="hideVisitorModal()"
- >
-
- <input
- type="submit" i18n-value value="Login to comment" class="action-button-submit"
- (click)="gotoLogin()"
- >
- </div>
-</ng-template>
+++ /dev/null
-@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;
- }
-}
+++ /dev/null
-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<VideoComment>()
- @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<VideoComment>
-
- 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)
- }
-}
+++ /dev/null
-import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
-import { VideoComment } from './video-comment.model'
-
-export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
- comment: VideoComment
- children: VideoCommentThreadTree[]
-}
+++ /dev/null
-<div class="root-comment">
- <div class="left">
- <a *ngIf="!comment.isDeleted" [href]="comment.account.url" target="_blank" rel="noopener noreferrer">
- <img
- class="comment-avatar"
- [src]="comment.accountAvatarUrl"
- (error)="switchToDefaultAvatar($event)"
- alt="Avatar"
- />
- </a>
-
- <div class="vertical-border"></div>
- </div>
-
- <div class="right" [ngClass]="{ 'mb-3': firstInThread }">
- <span *ngIf="comment.isDeleted" class="comment-avatar"></span>
-
- <div class="comment">
- <ng-container *ngIf="!comment.isDeleted">
- <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div>
-
- <div class="comment-account-date">
- <div class="comment-account">
- <a
- [routerLink]="[ '/accounts', comment.by ]"
- class="comment-account-name" [ngClass]="{ 'video-author': video.account.id === comment.account.id }"
- >
- {{ comment.account.displayName }}
- </a>
-
- <a [href]="comment.account.url" target="_blank" rel="noopener noreferrer" class="comment-account-fid ml-1">{{ comment.by }}</a>
- </div>
- <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
- class="comment-date" [title]="comment.createdAt">{{ comment.createdAt | myFromNow }}</a>
- </div>
- <div
- class="comment-html"
- [innerHTML]="sanitizedCommentHTML"
- (timestampClicked)="handleTimestampClicked($event)"
- timestampRouteTransformer
- ></div>
-
- <div class="comment-actions">
- <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
- <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
-
- <my-user-moderation-dropdown
- buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
- ></my-user-moderation-dropdown>
- </div>
- </ng-container>
-
- <ng-container *ngIf="comment.isDeleted">
- <div class="comment-account-date">
- <span class="comment-account" i18n>Deleted</span>
- <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
- class="comment-date">{{ comment.createdAt | myFromNow }}</a>
- </div>
-
- <div *ngIf="comment.isDeleted" class="comment-html comment-html-deleted">
- <i i18n>This comment has been deleted</i>
- </div>
- </ng-container>
-
- <my-video-comment-add
- *ngIf="!comment.isDeleted && isUserLoggedIn() && inReplyToCommentId === comment.id"
- [user]="user"
- [video]="video"
- [parentComment]="comment"
- [parentComments]="newParentComments"
- [focusOnInit]="true"
- (commentCreated)="onCommentReplyCreated($event)"
- (cancel)="onResetReply()"
- ></my-video-comment-add>
-
- <div *ngIf="commentTree" class="children">
- <div *ngFor="let commentChild of commentTree.children">
- <my-video-comment
- [comment]="commentChild.comment"
- [video]="video"
- [inReplyToCommentId]="inReplyToCommentId"
- [commentTree]="commentChild"
- [parentComments]="newParentComments"
- (wantedToReply)="onWantToReply($event)"
- (wantedToDelete)="onWantToDelete($event)"
- (resetReply)="onResetReply()"
- (timestampClicked)="handleTimestampClicked($event)"
- ></my-video-comment>
- </div>
- </div>
-
- <ng-content></ng-content>
- </div>
- </div>
-</div>
+++ /dev/null
-@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;
- }
- }
- }
- }
-}
+++ /dev/null
-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<VideoComment>()
- @Output() wantedToReply = new EventEmitter<VideoComment>()
- @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
- @Output() resetReply = new EventEmitter()
- @Output() timestampClicked = new EventEmitter<number>()
-
- 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
- }
- }
-}
+++ /dev/null
-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
- }
- }
-}
+++ /dev/null
-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<ResultList<VideoComment>> {
- 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<ResultList<VideoComment>>(url, { params })
- .pipe(
- map(result => this.extractVideoComments(result)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getVideoThreadComments (parameters: {
- videoId: number | string,
- threadId: number
- }): Observable<VideoCommentThreadTree> {
- const { videoId, threadId } = parameters
- const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
-
- return this.authHttp
- .get<VideoCommentThreadTreeServerModel>(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<VideoCommentServerModel>) {
- 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
- }
-}
+++ /dev/null
-<div>
- <div class="title-block">
- <h2 class="title-page title-page-single">
- <ng-container *ngIf="componentPagination.totalItems > 0; then hasComments; else noComments"></ng-container>
- <ng-template #hasComments>
- <ng-container i18n *ngIf="componentPagination.totalItems === 1; else manyComments">1 Comment</ng-container>
- <ng-template i18n #manyComments>{{ componentPagination.totalItems }} Comments</ng-template>
- </ng-template>
- <ng-template i18n #noComments>Comments</ng-template>
- </h2>
-
- <my-feed [syndicationItems]="syndicationItems"></my-feed>
-
- <div ngbDropdown class="d-inline-block ml-4">
- <button class="btn btn-sm btn-outline-secondary" id="dropdown-sort-comments" ngbDropdownToggle i18n>
- SORT BY
- </button>
- <div ngbDropdownMenu aria-labelledby="dropdown-sort-comments">
- <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button>
- <button (click)="handleSortChange('-totalReplies')" ngbDropdownItem i18n>Most replies first</button>
- </div>
- </div>
- </div>
-
- <ng-template [ngIf]="video.commentsEnabled === true">
- <my-video-comment-add
- [video]="video"
- [user]="user"
- (commentCreated)="onCommentThreadCreated($event)"
- ></my-video-comment-add>
-
- <div *ngIf="componentPagination.totalItems === 0 && comments.length === 0" i18n>No comments.</div>
-
- <div
- class="comment-threads"
- myInfiniteScroller
- [autoInit]="true"
- (nearOfBottom)="onNearOfBottom()"
- [dataObservable]="onDataSubject.asObservable()"
- >
- <div>
- <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
- <my-video-comment
- *ngIf="highlightedThread"
- [comment]="highlightedThread"
- [video]="video"
- [inReplyToCommentId]="inReplyToCommentId"
- [commentTree]="threadComments[highlightedThread.id]"
- [highlightedComment]="true"
- [firstInThread]="true"
- (wantedToReply)="onWantedToReply($event)"
- (wantedToDelete)="onWantedToDelete($event)"
- (threadCreated)="onThreadCreated($event)"
- (resetReply)="onResetReply()"
- (timestampClicked)="handleTimestampClicked($event)"
- ></my-video-comment>
- </div>
-
- <div *ngFor="let comment of comments; index as i">
- <my-video-comment
- *ngIf="!highlightedThread || comment.id !== highlightedThread.id"
- [comment]="comment"
- [video]="video"
- [inReplyToCommentId]="inReplyToCommentId"
- [commentTree]="threadComments[comment.id]"
- [firstInThread]="i + 1 !== comments.length"
- (wantedToReply)="onWantedToReply($event)"
- (wantedToDelete)="onWantedToDelete($event)"
- (threadCreated)="onThreadCreated($event)"
- (resetReply)="onResetReply()"
- (timestampClicked)="handleTimestampClicked($event)"
- >
- <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies mb-2">
- <span class="glyphicon glyphicon-menu-down"></span>
-
- <ng-container *ngIf="comment.totalRepliesFromVideoAuthor > 0; then hasAuthorComments; else noAuthorComments"></ng-container>
- <ng-template #hasAuthorComments>
- <ng-container *ngIf="comment.totalReplies !== comment.totalRepliesFromVideoAuthor; else onlyAuthorComments" i18n>
- View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }} and others
- </ng-container>
- <ng-template i18n #onlyAuthorComments>
- View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }}
- </ng-template>
- </ng-template>
- <ng-template i18n #noAuthorComments>View {{ comment.totalReplies }} replies</ng-template>
-
- <my-small-loader class="comment-thread-loading ml-1" [loading]="threadLoading[comment.id]"></my-small-loader>
- </div>
- </my-video-comment>
-
- </div>
- </div>
- </ng-template>
-
- <div *ngIf="video.commentsEnabled === false" i18n>
- Comments are disabled.
- </div>
-</div>
+++ /dev/null
-@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;
- }
-}
+++ /dev/null
-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<number>()
-
- 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<any[]>()
-
- 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)
- }
-}
+++ /dev/null
-<ng-template #modal let-hide="close">
- <div class="modal-header">
- <h4 i18n class="modal-title">Share</h4>
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
-
- <div class="modal-body">
- <div class="playlist" *ngIf="hasPlaylist()">
- <div class="title-page title-page-single" i18n>Share the playlist</div>
-
- <my-input-readonly-copy [value]="getPlaylistUrl()"></my-input-readonly-copy>
-
- <div class="filters">
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="includeVideoInPlaylist" [(ngModel)]="includeVideoInPlaylist"
- i18n-labelText labelText="Share the playlist at this video position"
- ></my-peertube-checkbox>
- </div>
-
- </div>
- </div>
-
-
- <div class="video">
- <div class="title-page title-page-single" *ngIf="hasPlaylist()" i18n>Share the video</div>
-
- <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeId">
-
- <ng-container ngbNavItem="url">
- <a ngbNavLink i18n>URL</a>
-
- <ng-template ngbNavContent>
- <div class="nav-content">
- <my-input-readonly-copy [value]="getVideoUrl()"></my-input-readonly-copy>
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem="qrcode">
- <a ngbNavLink i18n>QR-Code</a>
-
- <ng-template ngbNavContent>
- <div class="nav-content">
- <qrcode [qrdata]="getVideoUrl()" [size]="256" level="Q"></qrcode>
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem="embed">
- <a ngbNavLink i18n>Embed</a>
-
- <ng-template ngbNavContent>
- <div class="nav-content">
- <my-input-readonly-copy [value]="getVideoIframeCode()"></my-input-readonly-copy>
-
- <div i18n *ngIf="notSecure()" class="alert alert-warning">
- The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS websites).
- </div>
- </div>
- </ng-template>
- </ng-container>
-
- </div>
-
- <div [ngbNavOutlet]="nav"></div>
-
- <div class="filters">
- <div>
- <div class="form-group start-at">
- <my-peertube-checkbox
- inputName="startAt" [(ngModel)]="customizations.startAtCheckbox"
- i18n-labelText labelText="Start at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="customizations.startAt"
- [maxTimestamp]="video.duration"
- [disabled]="!customizations.startAtCheckbox"
- [(ngModel)]="customizations.startAt"
- >
- </my-timestamp-input>
- </div>
-
- <div *ngIf="videoCaptions.length !== 0" class="form-group video-caption-block">
- <my-peertube-checkbox
- inputName="subtitleCheckbox" [(ngModel)]="customizations.subtitleCheckbox"
- i18n-labelText labelText="Auto select subtitle"
- ></my-peertube-checkbox>
-
- <div class="peertube-select-container" [ngClass]="{ disabled: !customizations.subtitleCheckbox }">
- <select [(ngModel)]="customizations.subtitle" [disabled]="!customizations.subtitleCheckbox">
- <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
- </select>
- </div>
- </div>
- </div>
-
- <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed">
- <div>
- <div class="form-group stop-at">
- <my-peertube-checkbox
- inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
- i18n-labelText labelText="Stop at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="customizations.stopAt"
- [maxTimestamp]="video.duration"
- [disabled]="!customizations.stopAtCheckbox"
- [(ngModel)]="customizations.stopAt"
- >
- </my-timestamp-input>
- </div>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="autoplay" [(ngModel)]="customizations.autoplay"
- i18n-labelText labelText="Autoplay"
- ></my-peertube-checkbox>
- </div>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="muted" [(ngModel)]="customizations.muted"
- i18n-labelText labelText="Muted"
- ></my-peertube-checkbox>
- </div>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="loop" [(ngModel)]="customizations.loop"
- i18n-labelText labelText="Loop"
- ></my-peertube-checkbox>
- </div>
- </div>
-
- <ng-container *ngIf="isInEmbedTab()">
- <div class="form-group">
- <my-peertube-checkbox
- inputName="title" [(ngModel)]="customizations.title"
- i18n-labelText labelText="Display video title"
- ></my-peertube-checkbox>
- </div>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="warningTitle" [(ngModel)]="customizations.warningTitle"
- i18n-labelText labelText="Display privacy warning"
- ></my-peertube-checkbox>
- </div>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="controls" [(ngModel)]="customizations.controls"
- i18n-labelText labelText="Display player controls"
- ></my-peertube-checkbox>
- </div>
- </ng-container>
- </div>
-
- <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button"
- [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic">
-
- <ng-container *ngIf="isAdvancedCustomizationCollapsed">
- <span class="glyphicon glyphicon-menu-down"></span>
-
- <ng-container i18n>
- More customization
- </ng-container>
- </ng-container>
-
- <ng-container *ngIf="!isAdvancedCustomizationCollapsed">
- <span class="glyphicon glyphicon-menu-up"></span>
-
- <ng-container i18n>
- Less customization
- </ng-container>
- </ng-container>
- </div>
- </div>
- </div>
- </div>
-
-</ng-template>
+++ /dev/null
-@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;
- }
- }
-}
+++ /dev/null
-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
- }
- }
-}
+++ /dev/null
-<ng-template #modal let-hide="close">
- <div class="modal-header">
- <h4 i18n class="modal-title">Support {{ video.account.displayName }}</h4>
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
- <div class="modal-body" [innerHTML]="videoHTMLSupport"></div>
-
- <div class="modal-footer inputs">
- <input
- type="button" role="button" i18n-value value="Maybe later" class="action-button action-button-cancel"
- (click)="hide()" (key.enter)="hide()"
- >
- </div>
-</ng-template>
+++ /dev/null
-.action-button-cancel {
- margin-right: 0 !important;
-}
+++ /dev/null
-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)
- }
-}
+++ /dev/null
-import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
-
-@Directive({
- selector: '[timestampRouteTransformer]'
-})
-export class TimestampRouteTransformerDirective {
- @Output() timestampClicked = new EventEmitter<number>()
-
- @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()
- }
-}
+++ /dev/null
-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 })
- }
-}
+++ /dev/null
-<div *ngIf="playlist && video" class="playlist" myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()">
- <div class="playlist-info">
- <div class="playlist-display-name">
- {{ playlist.displayName }}
-
- <span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span>
- <span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span>
- <span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span>
- </div>
-
- <div class="playlist-by-index">
- <div class="playlist-by">{{ playlist.ownerBy }}</div>
- <div class="playlist-index">
- <span>{{ currentPlaylistPosition }}</span><span>{{ playlistPagination.totalItems }}</span>
- </div>
- </div>
-
- <div class="playlist-controls">
- <my-global-icon
- iconName="videos"
- [class.active]="autoPlayNextVideoPlaylist"
- (click)="switchAutoPlayNextVideoPlaylist()"
- [ngbTooltip]="autoPlayNextVideoPlaylistSwitchText"
- placement="bottom auto"
- container="body"
- ></my-global-icon>
-
- <my-global-icon
- iconName="repeat"
- [class.active]="loopPlaylist"
- (click)="switchLoopPlaylist()"
- [ngbTooltip]="loopPlaylistSwitchText"
- placement="bottom auto"
- container="body"
- ></my-global-icon>
- </div>
- </div>
-
- <div *ngFor="let playlistElement of playlistElements">
- <my-video-playlist-element-miniature
- [playlistElement]="playlistElement" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
- [playing]="currentPlaylistPosition === playlistElement.position" [accountLink]="false" [position]="playlistElement.position"
- [touchScreenEditButton]="true"
- ></my-video-playlist-element-miniature>
- </div>
-</div>
+++ /dev/null
-@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;
- }
- }
- }
-}
-
+++ /dev/null
-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')
- }
-}
+++ /dev/null
-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 {}
+++ /dev/null
-<div class="root" [ngClass]="{ 'theater-enabled': theaterEnabled }">
- <!-- We need the video container for videojs so we just hide it -->
- <div id="video-wrapper">
- <div *ngIf="remoteServerDown" class="remote-server-down">
- Sorry, but this video is not available because the remote instance is not responding.
- <br />
- Please try again later.
- </div>
-
- <div id="videojs-wrapper"></div>
-
- <my-video-watch-playlist
- #videoWatchPlaylist
- [video]="video" [playlist]="playlist" class="playlist"
- ></my-video-watch-playlist>
- </div>
-
- <div class="row">
- <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToImport()">
- The video is being imported, it will be available when the import is finished.
- </div>
-
- <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToTranscode()">
- The video is being transcoded, it may not work properly.
- </div>
-
- <div i18n class="col-md-12 alert alert-info" *ngIf="hasVideoScheduledPublication()">
- This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
- </div>
-
- <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted">
- <div class="blocked-label" i18n>This video is blocked.</div>
- {{ video.blockedReason }}
- </div>
- </div>
-
- <!-- Video information -->
- <div *ngIf="video" class="margin-content video-bottom">
- <div class="video-info">
- <div class="video-info-first-row">
- <div>
- <div class="d-block d-md-none"> <!-- only shown on medium devices, has its counterpart for larger viewports below -->
- <h1 class="video-info-name">{{ video.name }}</h1>
-
- <div i18n class="video-info-date-views">
- Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span>
- </div>
- </div>
-
- <div class="d-flex justify-content-between flex-direction-column">
- <div class="d-none d-md-block">
- <h1 class="video-info-name">{{ video.name }}</h1>
- </div>
-
- <div class="video-info-first-row-bottom">
- <div i18n class="d-none d-md-block video-info-date-views">
- Published <my-date-toggle [date]="video.publishedAt"></my-date-toggle> <span class="views"> • {{ video.views | myNumberFormatter }} views</span>
- </div>
-
- <div class="video-actions-rates">
- <div class="video-actions fullWidth justify-content-end">
- <button
- [ngbPopover]="getRatePopoverText()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" (keyup.enter)="setLike()"
- class="action-button action-button-like" [attr.aria-pressed]="userRating === 'like'" [attr.aria-label]="tooltipLike"
- [ngbTooltip]="tooltipLike"
- placement="bottom auto"
- >
- <my-global-icon iconName="like"></my-global-icon>
- <span *ngIf="video.likes" class="count">{{ video.likes }}</span>
- </button>
-
- <button
- [ngbPopover]="getRatePopoverText()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" (keyup.enter)="setDislike()"
- class="action-button action-button-dislike" [attr.aria-pressed]="userRating === 'dislike'" [attr.aria-label]="tooltipDislike"
- [ngbTooltip]="tooltipDislike"
- placement="bottom auto"
- >
- <my-global-icon iconName="dislike"></my-global-icon>
- <span *ngIf="video.dislikes" class="count">{{ video.dislikes }}</span>
- </button>
-
- <button *ngIf="video.support" (click)="showSupportModal()" (keyup.enter)="showSupportModal()" class="action-button action-button-support" [attr.aria-label]="tooltipSupport"
- [ngbTooltip]="tooltipSupport"
- placement="bottom auto"
- >
- <my-global-icon iconName="support" aria-hidden="true"></my-global-icon>
- <span class="icon-text" i18n>SUPPORT</span>
- </button>
-
- <button (click)="showShareModal()" (keyup.enter)="showShareModal()" class="action-button">
- <my-global-icon iconName="share" aria-hidden="true"></my-global-icon>
- <span class="icon-text" i18n>SHARE</span>
- </button>
-
- <div
- class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
- *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)"
- [ngbTooltip]="tooltipSaveToPlaylist"
- placement="bottom auto"
- >
- <button class="action-button action-button-save" ngbDropdownToggle>
- <my-global-icon iconName="playlist-add" aria-hidden="true"></my-global-icon>
- <span class="icon-text" i18n>SAVE</span>
- </button>
-
- <div ngbDropdownMenu>
- <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist>
- </div>
- </div>
-
- <my-video-actions-dropdown
- placement="bottom auto" buttonDirection="horizontal" [buttonStyled]="true" [video]="video" [videoCaptions]="videoCaptions"
- (videoRemoved)="onVideoRemoved()" (modalOpened)="onModalOpened()"
- ></my-video-actions-dropdown>
- </div>
-
- <div class="video-info-likes-dislikes-bar-outer-container">
- <div
- class="video-info-likes-dislikes-bar-inner-container"
- *ngIf="video.likes !== 0 || video.dislikes !== 0"
- [ngbTooltip]="likesBarTooltipText"
- placement="bottom"
- >
- <div
- class="video-info-likes-dislikes-bar"
- >
- <div class="likes-bar" [ngClass]="{ 'liked': userRating !== 'none' }" [ngStyle]="{ 'width.%': video.likesPercent }"></div>
- </div>
- </div>
- </div>
- </div>
-
- <div
- class="video-info-likes-dislikes-bar"
- *ngIf="video.likes !== 0 || video.dislikes !== 0"
- [ngbTooltip]="likesBarTooltipText"
- placement="bottom"
- >
- <div class="likes-bar" [ngStyle]="{ 'width.%': video.likesPercent }"></div>
- </div>
- </div>
- </div>
-
-
- <div class="pt-3 border-top video-info-channel d-flex">
- <div class="video-info-channel-left d-flex">
- <avatar-channel [video]="video"></avatar-channel>
-
- <div class="video-info-channel-left-links ml-1">
- <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Channel page">
- {{ video.channel.displayName }}
- </a>
- <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Account page">
- <span i18n>By {{ video.byAccount }}</span>
- </a>
- </div>
- </div>
-
- <my-subscribe-button #subscribeButton [videoChannels]="[video.channel]" size="small"></my-subscribe-button>
- </div>
- </div>
-
- </div>
-
- <div class="video-info-description">
- <div
- class="video-info-description-html"
- [innerHTML]="videoHTMLDescription"
- (timestampClicked)="handleTimestampClicked($event)"
- timestampRouteTransformer
- ></div>
-
- <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()">
- <ng-container i18n>Show more</ng-container>
- <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span>
- <my-small-loader class="description-loading" [loading]="descriptionLoading"></my-small-loader>
- </div>
-
- <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more">
- <ng-container i18n>Show less</ng-container>
- <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span>
- </div>
- </div>
-
- <div class="video-attributes mb-3">
- <div class="video-attribute">
- <span i18n class="video-attribute-label">Privacy</span>
- <span class="video-attribute-value">{{ video.privacy.label }}</span>
- </div>
-
- <div *ngIf="video.isLocal === false" class="video-attribute">
- <span i18n class="video-attribute-label">Origin instance</span>
- <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="video.originInstanceUrl">{{ video.originInstanceHost }}</a>
- </div>
-
- <div *ngIf="!!video.originallyPublishedAt" class="video-attribute">
- <span i18n class="video-attribute-label">Originally published</span>
- <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span>
- </div>
-
- <div class="video-attribute">
- <span i18n class="video-attribute-label">Category</span>
- <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span>
- <a
- *ngIf="video.category.id" class="video-attribute-value"
- [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }"
- >{{ video.category.label }}</a>
- </div>
-
- <div class="video-attribute">
- <span i18n class="video-attribute-label">Licence</span>
- <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span>
- <a
- *ngIf="video.licence.id" class="video-attribute-value"
- [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }"
- >{{ video.licence.label }}</a>
- </div>
-
- <div class="video-attribute">
- <span i18n class="video-attribute-label">Language</span>
- <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span>
- <a
- *ngIf="video.language.id" class="video-attribute-value"
- [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }"
- >{{ video.language.label }}</a>
- </div>
-
- <div class="video-attribute video-attribute-tags">
- <span i18n class="video-attribute-label">Tags</span>
- <a
- *ngFor="let tag of getVideoTags()"
- class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }"
- >{{ tag }}</a>
- </div>
-
- <div class="video-attribute">
- <span i18n class="video-attribute-label">Duration</span>
- <span class="video-attribute-value">{{ video.duration | myVideoDurationFormatter }}</span>
- </div>
- </div>
-
- <my-video-comments
- class="border-top"
- [video]="video"
- [user]="user"
- (timestampClicked)="handleTimestampClicked($event)"
- ></my-video-comments>
- </div>
-
- <my-recommended-videos
- [inputRecommendation]="{ uuid: video.uuid, tags: video.tags }"
- [playlist]="playlist"
- (gotRecommendations)="onRecommendations($event)"
- ></my-recommended-videos>
- </div>
-
- <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false">
- <div class="privacy-concerns-text">
- <span class="mr-2">
- <strong i18n>Friendly Reminder: </strong>
- <ng-container i18n>
- 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.
- </ng-container>
- </span>
- <a i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube#privacy">More information</a>
- </div>
-
- <div i18n class="privacy-concerns-button privacy-concerns-okay" (click)="acceptedPrivacyConcern()">
- OK
- </div>
- </div>
-</div>
-
-<ng-container *ngIf="video !== null">
- <my-video-support #videoSupportModal [video]="video"></my-video-support>
- <my-video-share #videoShareModal [video]="video" [videoCaptions]="videoCaptions" [playlist]="playlist"></my-video-share>
-</ng-container>
+++ /dev/null
-@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;
- }
- }
-}
+++ /dev/null
-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<any> } = {
- 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)
- }
-}
+++ /dev/null
-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 { }
+++ /dev/null
-export * from './videos.module'
+++ /dev/null
-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<Video[]> {
- 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<Video[]> {
- 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)
- })
- )
- }
-}
+++ /dev/null
-export interface RecommendationInfo {
- uuid: string
- tags?: string[]
-}
+++ /dev/null
-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 {
-}
+++ /dev/null
-import { Observable } from 'rxjs'
-import { Video } from '@app/shared/shared-main'
-import { RecommendationInfo } from './recommendation-info.model'
-
-export interface RecommendationService {
- getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
-}
+++ /dev/null
-<div class="other-videos">
- <ng-container *ngIf="hasVideos$ | async">
- <div class="title-page-container">
- <h2 i18n class="title-page title-page-single">
- Other videos
- </h2>
- <div *ngIf="!playlist" class="title-page-autoplay"
- [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto"
- >
- <span i18n>AUTOPLAY</span>
- <p-inputSwitch class="small" [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></p-inputSwitch>
- </div>
- </div>
-
- <ng-container *ngFor="let video of (videos$ | async); let i = index; let length = count">
- <my-video-miniature
- [displayOptions]="displayOptions" [video]="video" [user]="userMiniature"
- (videoBlocked)="onVideoRemoved()" (videoRemoved)="onVideoRemoved()">
- </my-video-miniature>
-
- <hr *ngIf="!playlist && i == 0 && length > 1" />
- </ng-container>
- </ng-container>
-</div>
+++ /dev/null
-.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;
-}
+++ /dev/null
-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<Video[]>()
-
- autoPlayNextVideo: boolean
- autoPlayNextVideoTooltip: string
-
- displayOptions: MiniatureDisplayOptions = {
- date: true,
- views: true,
- by: true,
- avatar: true
- }
-
- userMiniature: User
-
- readonly hasVideos$: Observable<boolean>
- readonly videos$: Observable<Video[]>
-
- 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)
- )
- }
- }
-}
+++ /dev/null
-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<Video[]>
- public readonly hasRecommendations$: Observable<boolean>
- private readonly requestsForLoad$$ = new ReplaySubject<RecommendationInfo>(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)
- }
-}
+++ /dev/null
-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'
+++ /dev/null
-export * from './overview.service'
-export * from './video-overview.component'
-export * from './videos-overview.model'
+++ /dev/null
-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<VideosOverview> {
- let params = new HttpParams()
- params = params.append('page', page + '')
-
- return this.authHttp
- .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
- .pipe(
- switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> {
- const observables: Observable<any>[] = []
- 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)
- )
- }
-
-}
+++ /dev/null
-<h1 class="sr-only" i18n>Discover</h1>
-<div class="margin-content">
-
- <div class="no-results" i18n *ngIf="notResults">No results.</div>
-
- <div
- myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
- >
- <ng-container *ngFor="let overview of overviews">
-
- <div class="section videos" *ngFor="let object of overview.categories">
- <h1 class="section-title">
- <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
- </h1>
-
- <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
- <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
- </my-video-miniature>
- </div>
- </div>
-
- <div class="section videos" *ngFor="let object of overview.tags">
- <h2 class="section-title">
- <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
- </h2>
-
- <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
- <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
- </my-video-miniature>
- </div>
- </div>
-
- <div class="section channel videos" *ngFor="let object of overview.channels">
- <div class="section-title">
- <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
- <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
-
- <h2 class="section-title">{{ object.channel.displayName }}</h2>
- </a>
- </div>
-
- <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
- <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
- </my-video-miniature>
- </div>
- </div>
-
- </ng-container>
-
- </div>
-
-</div>
+++ /dev/null
-@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;
-}
+++ /dev/null
-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<any>()
-
- 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
- }
- )
- }
-}
+++ /dev/null
-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<number>
- videos: Video[]
- }[]
-
- tags: {
- tag: string
- videos: Video[]
- }[]
- [key: string]: any
-}
+++ /dev/null
-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()
- }
-}
+++ /dev/null
-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)
- }
-}
+++ /dev/null
-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)
- }
-}
+++ /dev/null
-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)
- }
-}
+++ /dev/null
-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
- }
-}
+++ /dev/null
-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 {}
+++ /dev/null
-import { Component } from '@angular/core'
-
-@Component({
- template: '<router-outlet></router-outlet>'
-})
-export class VideosComponent {}
+++ /dev/null
-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 { }
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'