Lazy load all routes
authorChocobozzz <me@florianbigard.com>
Tue, 23 Jun 2020 12:49:20 +0000 (14:49 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 23 Jun 2020 14:00:49 +0000 (16:00 +0200)
218 files changed:
client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts
client/src/app/+login/login-routing.module.ts [new file with mode: 0644]
client/src/app/+login/login.component.html [new file with mode: 0644]
client/src/app/+login/login.component.scss [new file with mode: 0644]
client/src/app/+login/login.component.ts [new file with mode: 0644]
client/src/app/+login/login.module.ts [new file with mode: 0644]
client/src/app/+reset-password/reset-password-routing.module.ts [new file with mode: 0644]
client/src/app/+reset-password/reset-password.component.html [new file with mode: 0644]
client/src/app/+reset-password/reset-password.component.scss [new file with mode: 0644]
client/src/app/+reset-password/reset-password.component.ts [new file with mode: 0644]
client/src/app/+reset-password/reset-password.module.ts [new file with mode: 0644]
client/src/app/+search/channel-lazy-load.resolver.ts [new file with mode: 0644]
client/src/app/+search/search-filters.component.html [new file with mode: 0644]
client/src/app/+search/search-filters.component.scss [new file with mode: 0644]
client/src/app/+search/search-filters.component.ts [new file with mode: 0644]
client/src/app/+search/search-routing.module.ts [new file with mode: 0644]
client/src/app/+search/search.component.html [new file with mode: 0644]
client/src/app/+search/search.component.scss [new file with mode: 0644]
client/src/app/+search/search.component.ts [new file with mode: 0644]
client/src/app/+search/search.module.ts [new file with mode: 0644]
client/src/app/+search/video-lazy-load.resolver.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/i18n-primeng-calendar.service.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/video-edit.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/video-edit.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/video-edit.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/shared/video-edit.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-send.scss [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-send.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add-routing.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-add.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-update-routing.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-update.component.html [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-update.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-update.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-edit/video-update.resolver.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment-add.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment.model.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comment.service.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comments.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comments.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/comment/video-comments.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/modal/video-share.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/modal/video-share.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/modal/video-share.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/modal/video-support.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/modal/video-support.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/modal/video-support.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch-playlist.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch-playlist.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch-playlist.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch-routing.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch.component.html [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch.component.scss [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch.component.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/video-watch.module.ts [new file with mode: 0644]
client/src/app/+videos/index.ts [new file with mode: 0644]
client/src/app/+videos/video-list/index.ts [new file with mode: 0644]
client/src/app/+videos/video-list/overview/index.ts [new file with mode: 0644]
client/src/app/+videos/video-list/overview/overview.service.ts [new file with mode: 0644]
client/src/app/+videos/video-list/overview/video-overview.component.html [new file with mode: 0644]
client/src/app/+videos/video-list/overview/video-overview.component.scss [new file with mode: 0644]
client/src/app/+videos/video-list/overview/video-overview.component.ts [new file with mode: 0644]
client/src/app/+videos/video-list/overview/videos-overview.model.ts [new file with mode: 0644]
client/src/app/+videos/video-list/video-local.component.ts [new file with mode: 0644]
client/src/app/+videos/video-list/video-most-liked.component.ts [new file with mode: 0644]
client/src/app/+videos/video-list/video-recently-added.component.ts [new file with mode: 0644]
client/src/app/+videos/video-list/video-trending.component.ts [new file with mode: 0644]
client/src/app/+videos/video-list/video-user-subscriptions.component.ts [new file with mode: 0644]
client/src/app/+videos/videos-routing.module.ts [new file with mode: 0644]
client/src/app/+videos/videos.component.ts [new file with mode: 0644]
client/src/app/+videos/videos.module.ts [new file with mode: 0644]
client/src/app/app-routing.module.ts
client/src/app/app.module.ts
client/src/app/empty.component.ts [new file with mode: 0644]
client/src/app/header/highlight.pipe.ts [new file with mode: 0644]
client/src/app/login/index.ts [deleted file]
client/src/app/login/login-routing.module.ts [deleted file]
client/src/app/login/login.component.html [deleted file]
client/src/app/login/login.component.scss [deleted file]
client/src/app/login/login.component.ts [deleted file]
client/src/app/login/login.module.ts [deleted file]
client/src/app/reset-password/index.ts [deleted file]
client/src/app/reset-password/reset-password-routing.module.ts [deleted file]
client/src/app/reset-password/reset-password.component.html [deleted file]
client/src/app/reset-password/reset-password.component.scss [deleted file]
client/src/app/reset-password/reset-password.component.ts [deleted file]
client/src/app/reset-password/reset-password.module.ts [deleted file]
client/src/app/search/advanced-search.model.ts [deleted file]
client/src/app/search/channel-lazy-load.resolver.ts [deleted file]
client/src/app/search/highlight.pipe.ts [deleted file]
client/src/app/search/index.ts [deleted file]
client/src/app/search/search-filters.component.html [deleted file]
client/src/app/search/search-filters.component.scss [deleted file]
client/src/app/search/search-filters.component.ts [deleted file]
client/src/app/search/search-routing.module.ts [deleted file]
client/src/app/search/search.component.html [deleted file]
client/src/app/search/search.component.scss [deleted file]
client/src/app/search/search.component.ts [deleted file]
client/src/app/search/search.module.ts [deleted file]
client/src/app/search/search.service.ts [deleted file]
client/src/app/search/video-lazy-load.resolver.ts [deleted file]
client/src/app/shared/shared-search/advanced-search.model.ts [new file with mode: 0644]
client/src/app/shared/shared-search/index.ts [new file with mode: 0644]
client/src/app/shared/shared-search/search.service.ts [new file with mode: 0644]
client/src/app/shared/shared-search/shared-search.module.ts [new file with mode: 0644]
client/src/app/videos/+video-edit/shared/i18n-primeng-calendar.service.ts [deleted file]
client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html [deleted file]
client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss [deleted file]
client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts [deleted file]
client/src/app/videos/+video-edit/shared/video-edit.component.html [deleted file]
client/src/app/videos/+video-edit/shared/video-edit.component.scss [deleted file]
client/src/app/videos/+video-edit/shared/video-edit.component.ts [deleted file]
client/src/app/videos/+video-edit/shared/video-edit.module.ts [deleted file]
client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-send.scss [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-send.ts [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-upload.component.html [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss [deleted file]
client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts [deleted file]
client/src/app/videos/+video-edit/video-add-routing.module.ts [deleted file]
client/src/app/videos/+video-edit/video-add.component.html [deleted file]
client/src/app/videos/+video-edit/video-add.component.scss [deleted file]
client/src/app/videos/+video-edit/video-add.component.ts [deleted file]
client/src/app/videos/+video-edit/video-add.module.ts [deleted file]
client/src/app/videos/+video-edit/video-update-routing.module.ts [deleted file]
client/src/app/videos/+video-edit/video-update.component.html [deleted file]
client/src/app/videos/+video-edit/video-update.component.ts [deleted file]
client/src/app/videos/+video-edit/video-update.module.ts [deleted file]
client/src/app/videos/+video-edit/video-update.resolver.ts [deleted file]
client/src/app/videos/+video-watch/comment/video-comment-add.component.html [deleted file]
client/src/app/videos/+video-watch/comment/video-comment-add.component.scss [deleted file]
client/src/app/videos/+video-watch/comment/video-comment-add.component.ts [deleted file]
client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts [deleted file]
client/src/app/videos/+video-watch/comment/video-comment.component.html [deleted file]
client/src/app/videos/+video-watch/comment/video-comment.component.scss [deleted file]
client/src/app/videos/+video-watch/comment/video-comment.component.ts [deleted file]
client/src/app/videos/+video-watch/comment/video-comment.model.ts [deleted file]
client/src/app/videos/+video-watch/comment/video-comment.service.ts [deleted file]
client/src/app/videos/+video-watch/comment/video-comments.component.html [deleted file]
client/src/app/videos/+video-watch/comment/video-comments.component.scss [deleted file]
client/src/app/videos/+video-watch/comment/video-comments.component.ts [deleted file]
client/src/app/videos/+video-watch/modal/video-share.component.html [deleted file]
client/src/app/videos/+video-watch/modal/video-share.component.scss [deleted file]
client/src/app/videos/+video-watch/modal/video-share.component.ts [deleted file]
client/src/app/videos/+video-watch/modal/video-support.component.html [deleted file]
client/src/app/videos/+video-watch/modal/video-support.component.scss [deleted file]
client/src/app/videos/+video-watch/modal/video-support.component.ts [deleted file]
client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts [deleted file]
client/src/app/videos/+video-watch/video-duration-formatter.pipe.ts [deleted file]
client/src/app/videos/+video-watch/video-watch-playlist.component.html [deleted file]
client/src/app/videos/+video-watch/video-watch-playlist.component.scss [deleted file]
client/src/app/videos/+video-watch/video-watch-playlist.component.ts [deleted file]
client/src/app/videos/+video-watch/video-watch-routing.module.ts [deleted file]
client/src/app/videos/+video-watch/video-watch.component.html [deleted file]
client/src/app/videos/+video-watch/video-watch.component.scss [deleted file]
client/src/app/videos/+video-watch/video-watch.component.ts [deleted file]
client/src/app/videos/+video-watch/video-watch.module.ts [deleted file]
client/src/app/videos/index.ts [deleted file]
client/src/app/videos/recommendations/recent-videos-recommendation.service.ts [deleted file]
client/src/app/videos/recommendations/recommendation-info.model.ts [deleted file]
client/src/app/videos/recommendations/recommendations.module.ts [deleted file]
client/src/app/videos/recommendations/recommendations.service.ts [deleted file]
client/src/app/videos/recommendations/recommended-videos.component.html [deleted file]
client/src/app/videos/recommendations/recommended-videos.component.scss [deleted file]
client/src/app/videos/recommendations/recommended-videos.component.ts [deleted file]
client/src/app/videos/recommendations/recommended-videos.store.ts [deleted file]
client/src/app/videos/video-list/index.ts [deleted file]
client/src/app/videos/video-list/overview/index.ts [deleted file]
client/src/app/videos/video-list/overview/overview.service.ts [deleted file]
client/src/app/videos/video-list/overview/video-overview.component.html [deleted file]
client/src/app/videos/video-list/overview/video-overview.component.scss [deleted file]
client/src/app/videos/video-list/overview/video-overview.component.ts [deleted file]
client/src/app/videos/video-list/overview/videos-overview.model.ts [deleted file]
client/src/app/videos/video-list/video-local.component.ts [deleted file]
client/src/app/videos/video-list/video-most-liked.component.ts [deleted file]
client/src/app/videos/video-list/video-recently-added.component.ts [deleted file]
client/src/app/videos/video-list/video-trending.component.ts [deleted file]
client/src/app/videos/video-list/video-user-subscriptions.component.ts [deleted file]
client/src/app/videos/videos-routing.module.ts [deleted file]
client/src/app/videos/videos.component.ts [deleted file]
client/src/app/videos/videos.module.ts [deleted file]
shared/models/videos/abuse/index.ts

index 9aa70288dd030b7efa5f6b86b570677d155b997c..5db2887fa8bd7ef48ee83d5400f8e900d8922fbf 100644 (file)
@@ -3,6 +3,7 @@ import { Actor } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model'
 import { ProcessedVideoAbuse } from './video-abuse-list.component'
+import { durationToString } from '@app/helpers'
 
 @Component({
   selector: 'my-video-abuse-details',
diff --git a/client/src/app/+login/login-routing.module.ts b/client/src/app/+login/login-routing.module.ts
new file mode 100644 (file)
index 0000000..aad55ea
--- /dev/null
@@ -0,0 +1,27 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MetaGuard } from '@ngx-meta/core'
+import { LoginComponent } from './login.component'
+import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
+
+const loginRoutes: Routes = [
+  {
+    path: '',
+    component: LoginComponent,
+    canActivate: [ MetaGuard ],
+    data: {
+      meta: {
+        title: 'Login'
+      }
+    },
+    resolve: {
+      serverConfig: ServerConfigResolver
+    }
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(loginRoutes) ],
+  exports: [ RouterModule ]
+})
+export class LoginRoutingModule {}
diff --git a/client/src/app/+login/login.component.html b/client/src/app/+login/login.component.html
new file mode 100644 (file)
index 0000000..599b203
--- /dev/null
@@ -0,0 +1,114 @@
+<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>
diff --git a/client/src/app/+login/login.component.scss b/client/src/app/+login/login.component.scss
new file mode 100644 (file)
index 0000000..fde6cc1
--- /dev/null
@@ -0,0 +1,66 @@
+@import '_variables';
+@import '_mixins';
+
+label {
+  display: block;
+}
+
+input:not([type=submit]) {
+  @include peertube-input-text(340px);
+  display: inline-block;
+  margin-right: 5px;
+
+}
+
+input[type=submit] {
+  @include peertube-button;
+  @include orange-button;
+}
+
+.create-an-account, .forgot-password-button {
+  color: pvar(--mainForegroundColor);
+  cursor: pointer;
+  transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1);
+
+  &:hover {
+    text-decoration: none !important;
+    opacity: .7 !important;
+  }
+}
+
+.login-form-and-externals {
+  display: flex;
+  flex-wrap: wrap;
+  font-size: 15px;
+
+  form {
+    margin: 0 50px 20px 0;
+  }
+
+  .external-login-blocks {
+    min-width: 200px;
+
+    .block-title {
+      font-weight: $font-semibold;
+    }
+
+    .external-login-block {
+      @include disable-default-a-behaviour;
+
+      cursor: pointer;
+      border: 1px solid #d1d7e0;
+      border-radius: 5px;
+      color: pvar(--mainForegroundColor);
+      margin: 10px 10px 0 0;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      min-height: 35px;
+      min-width: 100px;
+
+      &:hover {
+        background-color: rgba(209, 215, 224, 0.5)
+      }
+    }
+  }
+}
diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts
new file mode 100644 (file)
index 0000000..cbc51ee
--- /dev/null
@@ -0,0 +1,147 @@
+import { environment } from 'src/environments/environment'
+import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, Notifier, RedirectService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { FormReactive, FormValidatorService, LoginValidatorsService } from '@app/shared/shared-forms'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
+
+@Component({
+  selector: 'my-login',
+  templateUrl: './login.component.html',
+  styleUrls: [ './login.component.scss' ]
+})
+
+export class LoginComponent extends FormReactive implements OnInit, AfterViewInit {
+  @ViewChild('usernameInput', { static: false }) usernameInput: ElementRef
+  @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
+
+  error: string = null
+  forgotPasswordEmail = ''
+
+  isAuthenticatedWithExternalAuth = false
+  externalAuthError = false
+  externalLogins: string[] = []
+
+  private openedForgotPasswordModal: NgbModalRef
+  private serverConfig: ServerConfig
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private route: ActivatedRoute,
+    private modalService: NgbModal,
+    private loginValidatorsService: LoginValidatorsService,
+    private authService: AuthService,
+    private userService: UserService,
+    private redirectService: RedirectService,
+    private notifier: Notifier,
+    private hooks: HooksService,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  get signupAllowed () {
+    return this.serverConfig.signup.allowed === true
+  }
+
+  isEmailDisabled () {
+    return this.serverConfig.email.enabled === false
+  }
+
+  ngOnInit () {
+    const snapshot = this.route.snapshot
+
+    this.serverConfig = snapshot.data.serverConfig
+
+    if (snapshot.queryParams.externalAuthToken) {
+      this.loadExternalAuthToken(snapshot.queryParams.username, snapshot.queryParams.externalAuthToken)
+      return
+    }
+
+    if (snapshot.queryParams.externalAuthError) {
+      this.externalAuthError = true
+      return
+    }
+
+    this.buildForm({
+      username: this.loginValidatorsService.LOGIN_USERNAME,
+      password: this.loginValidatorsService.LOGIN_PASSWORD
+    })
+  }
+
+  ngAfterViewInit () {
+    if (this.usernameInput) {
+      this.usernameInput.nativeElement.focus()
+    }
+
+    this.hooks.runAction('action:login.init', 'login')
+  }
+
+  getExternalLogins () {
+    return this.serverConfig.plugin.registeredExternalAuths
+  }
+
+  getAuthHref (auth: RegisteredExternalAuthConfig) {
+    return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
+  }
+
+  login () {
+    this.error = null
+
+    const { username, password } = this.form.value
+
+    this.authService.login(username, password)
+      .subscribe(
+        () => this.redirectService.redirectToPreviousRoute(),
+
+        err => this.handleError(err)
+      )
+  }
+
+  askResetPassword () {
+    this.userService.askResetPassword(this.forgotPasswordEmail)
+      .subscribe(
+        () => {
+          const message = this.i18n(
+            'An email with the reset password instructions will be sent to {{email}}. The link will expire within 1 hour.',
+            { email: this.forgotPasswordEmail }
+          )
+          this.notifier.success(message)
+          this.hideForgotPasswordModal()
+        },
+
+        err => this.notifier.error(err.message)
+      )
+  }
+
+  openForgotPasswordModal () {
+    this.openedForgotPasswordModal = this.modalService.open(this.forgotPasswordModal)
+  }
+
+  hideForgotPasswordModal () {
+    this.openedForgotPasswordModal.close()
+  }
+
+  private loadExternalAuthToken (username: string, token: string) {
+    this.isAuthenticatedWithExternalAuth = true
+
+    this.authService.login(username, null, token)
+    .subscribe(
+      () => this.redirectService.redirectToPreviousRoute(),
+
+      err => {
+        this.handleError(err)
+        this.isAuthenticatedWithExternalAuth = false
+      }
+    )
+  }
+
+  private handleError (err: any) {
+    if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.')
+    else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.')
+    else this.error = err.message
+  }
+}
diff --git a/client/src/app/+login/login.module.ts b/client/src/app/+login/login.module.ts
new file mode 100644 (file)
index 0000000..c419024
--- /dev/null
@@ -0,0 +1,28 @@
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { LoginRoutingModule } from './login-routing.module'
+import { LoginComponent } from './login.component'
+
+@NgModule({
+  imports: [
+    LoginRoutingModule,
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule
+  ],
+
+  declarations: [
+    LoginComponent
+  ],
+
+  exports: [
+    LoginComponent
+  ],
+
+  providers: [
+  ]
+})
+export class LoginModule { }
diff --git a/client/src/app/+reset-password/reset-password-routing.module.ts b/client/src/app/+reset-password/reset-password-routing.module.ts
new file mode 100644 (file)
index 0000000..31bc087
--- /dev/null
@@ -0,0 +1,23 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MetaGuard } from '@ngx-meta/core'
+import { ResetPasswordComponent } from './reset-password.component'
+
+const resetPasswordRoutes: Routes = [
+  {
+    path: '',
+    component: ResetPasswordComponent,
+    canActivate: [ MetaGuard ],
+    data: {
+      meta: {
+        title: 'Reset password'
+      }
+    }
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(resetPasswordRoutes) ],
+  exports: [ RouterModule ]
+})
+export class ResetPasswordRoutingModule {}
diff --git a/client/src/app/+reset-password/reset-password.component.html b/client/src/app/+reset-password/reset-password.component.html
new file mode 100644 (file)
index 0000000..af30af4
--- /dev/null
@@ -0,0 +1,31 @@
+<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>
diff --git a/client/src/app/+reset-password/reset-password.component.scss b/client/src/app/+reset-password/reset-password.component.scss
new file mode 100644 (file)
index 0000000..efec6b7
--- /dev/null
@@ -0,0 +1,12 @@
+@import '_variables';
+@import '_mixins';
+
+input:not([type=submit]) {
+  @include peertube-input-text(340px);
+  display: block;
+}
+
+input[type=submit] {
+  @include peertube-button;
+  @include orange-button;
+}
diff --git a/client/src/app/+reset-password/reset-password.component.ts b/client/src/app/+reset-password/reset-password.component.ts
new file mode 100644 (file)
index 0000000..8d50e98
--- /dev/null
@@ -0,0 +1,61 @@
+import { Component, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, ResetPasswordValidatorsService, UserValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+  selector: 'my-login',
+  templateUrl: './reset-password.component.html',
+  styleUrls: [ './reset-password.component.scss' ]
+})
+
+export class ResetPasswordComponent extends FormReactive implements OnInit {
+  private userId: number
+  private verificationString: string
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private resetPasswordValidatorsService: ResetPasswordValidatorsService,
+    private userValidatorsService: UserValidatorsService,
+    private userService: UserService,
+    private notifier: Notifier,
+    private router: Router,
+    private route: ActivatedRoute,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      password: this.userValidatorsService.USER_PASSWORD,
+      'password-confirm': this.resetPasswordValidatorsService.RESET_PASSWORD_CONFIRM
+    })
+
+    this.userId = this.route.snapshot.queryParams['userId']
+    this.verificationString = this.route.snapshot.queryParams['verificationString']
+
+    if (!this.userId || !this.verificationString) {
+      this.notifier.error(this.i18n('Unable to find user id or verification string.'))
+      this.router.navigate([ '/' ])
+    }
+  }
+
+  resetPassword () {
+    this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password)
+      .subscribe(
+        () => {
+          this.notifier.success(this.i18n('Your password has been successfully reset!'))
+          this.router.navigate([ '/login' ])
+        },
+
+        err => this.notifier.error(err.message)
+      )
+  }
+
+  isConfirmedPasswordValid () {
+    const values = this.form.value
+    return values.password === values['password-confirm']
+  }
+}
diff --git a/client/src/app/+reset-password/reset-password.module.ts b/client/src/app/+reset-password/reset-password.module.ts
new file mode 100644 (file)
index 0000000..c77f1c4
--- /dev/null
@@ -0,0 +1,26 @@
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { ResetPasswordRoutingModule } from './reset-password-routing.module'
+import { ResetPasswordComponent } from './reset-password.component'
+
+@NgModule({
+  imports: [
+    ResetPasswordRoutingModule,
+
+    SharedMainModule,
+    SharedFormModule
+  ],
+
+  declarations: [
+    ResetPasswordComponent
+  ],
+
+  exports: [
+    ResetPasswordComponent
+  ],
+
+  providers: [
+  ]
+})
+export class ResetPasswordModule { }
diff --git a/client/src/app/+search/channel-lazy-load.resolver.ts b/client/src/app/+search/channel-lazy-load.resolver.ts
new file mode 100644 (file)
index 0000000..17a2128
--- /dev/null
@@ -0,0 +1,43 @@
+import { map } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
+import { SearchService } from '@app/shared/shared-search'
+
+@Injectable()
+export class ChannelLazyLoadResolver implements Resolve<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)
+        })
+      )
+  }
+}
diff --git a/client/src/app/+search/search-filters.component.html b/client/src/app/+search/search-filters.component.html
new file mode 100644 (file)
index 0000000..e20aef8
--- /dev/null
@@ -0,0 +1,193 @@
+<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>
diff --git a/client/src/app/+search/search-filters.component.scss b/client/src/app/+search/search-filters.component.scss
new file mode 100644 (file)
index 0000000..a88a1c0
--- /dev/null
@@ -0,0 +1,69 @@
+@import '_variables';
+@import '_mixins';
+
+form {
+  margin-top: 40px;
+}
+
+.radio-label {
+  font-size: 15px;
+  font-weight: $font-bold;
+}
+
+.peertube-radio-container {
+  @include peertube-radio-container;
+
+  display: inline-block;
+  margin-right: 30px;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(auto);
+
+  margin-bottom: 1rem;
+}
+
+.form-group {
+  margin-bottom: 25px;
+}
+
+input[type=text] {
+  @include peertube-input-text(100%);
+  display: block;
+}
+
+input[type=submit] {
+  @include peertube-button-link;
+  @include orange-button;
+}
+
+.submit-button {
+  text-align: right;
+}
+
+.reset-button {
+  @include peertube-button;
+
+  font-weight: $font-semibold;
+  display: inline-block;
+  padding: 0 10px 0 10px;
+  white-space: nowrap;
+  background: transparent;
+
+  margin-right: 1rem;
+}
+
+.reset-button-small {
+  font-size: 80%;
+  height: unset;
+  line-height: unset;
+  margin: unset;
+  margin-bottom: 0.5rem;
+}
+
+.label-container {
+  display: flex;
+  white-space: nowrap;
+}
+
+@include ng2-tags;
diff --git a/client/src/app/+search/search-filters.component.ts b/client/src/app/+search/search-filters.component.ts
new file mode 100644 (file)
index 0000000..fc1db32
--- /dev/null
@@ -0,0 +1,269 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ValidatorFn } from '@angular/forms'
+import { ServerService } from '@app/core'
+import { VideoValidatorsService } from '@app/shared/shared-forms'
+import { AdvancedSearch } from '@app/shared/shared-search'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoConstant } from '@shared/models'
+
+@Component({
+  selector: 'my-search-filters',
+  styleUrls: [ './search-filters.component.scss' ],
+  templateUrl: './search-filters.component.html'
+})
+export class SearchFiltersComponent implements OnInit {
+  @Input() advancedSearch: AdvancedSearch = new AdvancedSearch()
+
+  @Output() filtered = new EventEmitter<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()
+  }
+}
diff --git a/client/src/app/+search/search-routing.module.ts b/client/src/app/+search/search-routing.module.ts
new file mode 100644 (file)
index 0000000..14a0d0a
--- /dev/null
@@ -0,0 +1,41 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MetaGuard } from '@ngx-meta/core'
+import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
+import { SearchComponent } from './search.component'
+import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
+
+const searchRoutes: Routes = [
+  {
+    path: '',
+    component: SearchComponent,
+    canActivate: [ MetaGuard ],
+    data: {
+      meta: {
+        title: 'Search'
+      }
+    }
+  },
+  {
+    path: 'lazy-load-video',
+    component: SearchComponent,
+    canActivate: [ MetaGuard ],
+    resolve: {
+      data: VideoLazyLoadResolver
+    }
+  },
+  {
+    path: 'lazy-load-channel',
+    component: SearchComponent,
+    canActivate: [ MetaGuard ],
+    resolve: {
+      data: ChannelLazyLoadResolver
+    }
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(searchRoutes) ],
+  exports: [ RouterModule ]
+})
+export class SearchRoutingModule {}
diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html
new file mode 100644 (file)
index 0000000..9bff024
--- /dev/null
@@ -0,0 +1,63 @@
+<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>
diff --git a/client/src/app/+search/search.component.scss b/client/src/app/+search/search.component.scss
new file mode 100644 (file)
index 0000000..6e59adb
--- /dev/null
@@ -0,0 +1,191 @@
+@import '_variables';
+@import '_mixins';
+
+.search-result {
+  padding: 40px;
+
+  .results-header {
+    font-size: 16px;
+    padding-bottom: 20px;
+    margin-bottom: 30px;
+    border-bottom: 1px solid #DADADA;
+
+    .first-line {
+      display: flex;
+      flex-direction: row;
+
+      .results-counter {
+        flex-grow: 1;
+
+        .search-value {
+          font-weight: $font-semibold;
+        }
+      }
+
+      .results-filter-button {
+        cursor: pointer;
+
+        .icon.icon-filter {
+          @include icon(20px);
+
+          position: relative;
+          top: -1px;
+          margin-right: 5px;
+          background-image: url('../../assets/images/search/filter.svg');
+        }
+      }
+    }
+  }
+
+  .entry {
+    display: flex;
+    min-height: 130px;
+    padding-bottom: 20px;
+    margin-bottom: 20px;
+
+    &.video-channel {
+      img {
+        $image-size: 130px;
+        $margin-size: ($video-thumbnail-width - $image-size) / 2; // So we have the same width than the video miniature
+
+        @include avatar($image-size);
+
+        margin: 0 ($margin-size + 10) 0 $margin-size;
+      }
+
+      .video-channel-info {
+        flex-grow: 1;
+        width: fit-content;
+
+        .video-channel-names {
+          @include disable-default-a-behaviour;
+
+          display: flex;
+          align-items: baseline;
+          color: pvar(--mainForegroundColor);
+          width: fit-content;
+
+          .video-channel-display-name {
+            font-weight: $font-semibold;
+            font-size: 18px;
+          }
+
+          .video-channel-name {
+            font-size: 14px;
+            color: $grey-actor-name;
+            margin-left: 5px;
+          }
+        }
+      }
+    }
+  }
+}
+
+@media screen and (min-width: $small-view) and (max-width: breakpoint(xl)) {
+  .video-channel-info .video-channel-names {
+    flex-direction: column !important;
+
+    .video-channel-name {
+      @include ellipsis; // Ellipsis and max-width on channel-name to not break screen
+
+      max-width: 250px;
+      margin-left: 0 !important;
+    }
+  }
+
+  :host-context(.main-col:not(.expanded)) {
+    // Override the min-width: 500px to not break screen
+    ::ng-deep .video-miniature-information {
+      min-width: 300px !important;
+    }
+  }
+}
+
+@media screen and (min-width: $small-view) and (max-width: breakpoint(lg)) {
+  :host-context(.main-col:not(.expanded)) {
+    .video-channel-info .video-channel-names {
+      .video-channel-name {
+        max-width: 160px;
+      }
+    }
+
+    // Override the min-width: 500px to not break screen
+    ::ng-deep .video-miniature-information {
+      min-width: $video-thumbnail-width !important;
+    }
+  }
+
+  :host-context(.expanded) {
+    // Override the min-width: 500px to not break screen
+    ::ng-deep .video-miniature-information {
+      min-width: 300px !important;
+    }
+  }
+}
+
+@media screen and (max-width: $small-view) {
+  .search-result {
+    .entry.video-channel,
+    .entry.video {
+      flex-direction: column;
+      height: auto;
+      justify-content: center;
+      align-items: center;
+      text-align: center;
+
+      img {
+        margin: 0;
+      }
+
+      img {
+        margin: 0;
+      }
+
+      .video-channel-info .video-channel-names {
+        align-items: center;
+        flex-direction: column !important;
+
+        .video-channel-name {
+          margin-left: 0 !important;
+        }
+      }
+
+      my-subscribe-button {
+        margin-top: 5px;
+      }
+    }
+  }
+}
+
+@media screen and (max-width: $mobile-view) {
+  .search-result {
+    padding: 20px 10px;
+
+    .results-header {
+      font-size: 15px !important;
+    }
+
+    .entry {
+      &.video {
+        .video-info-name,
+        .video-info-account {
+          margin: auto;
+        }
+
+        my-video-thumbnail {
+          margin-right: 0 !important;
+
+          ::ng-deep .video-thumbnail {
+            width: 100%;
+            height: auto;
+
+            img {
+              width: 100%;
+              height: auto;
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/client/src/app/+search/search.component.ts b/client/src/app/+search/search.component.ts
new file mode 100644 (file)
index 0000000..1ed5493
--- /dev/null
@@ -0,0 +1,259 @@
+import { forkJoin, of, Subscription } from 'rxjs'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { Video, VideoChannel } from '@app/shared/shared-main'
+import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
+import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
+import { MetaService } from '@ngx-meta/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { SearchTargetType, ServerConfig } from '@shared/models'
+
+@Component({
+  selector: 'my-search',
+  styleUrls: [ './search.component.scss' ],
+  templateUrl: './search.component.html'
+})
+export class SearchComponent implements OnInit, OnDestroy {
+  results: (Video | VideoChannel)[] = []
+
+  pagination: ComponentPagination = {
+    currentPage: 1,
+    itemsPerPage: 10, // Only for videos, use another variable for channels
+    totalItems: null
+  }
+  advancedSearch: AdvancedSearch = new AdvancedSearch()
+  isSearchFilterCollapsed = true
+  currentSearch: string
+
+  videoDisplayOptions: MiniatureDisplayOptions = {
+    date: true,
+    views: true,
+    by: true,
+    avatar: false,
+    privacyLabel: false,
+    privacyText: false,
+    state: false,
+    blacklistInfo: false
+  }
+
+  errorMessage: string
+  serverConfig: ServerConfig
+
+  userMiniature: User
+
+  private subActivatedRoute: Subscription
+  private isInitialLoad = false // set to false to show the search filters on first arrival
+  private firstSearch = true
+
+  private channelsPerPage = 2
+
+  private lastSearchTarget: SearchTargetType
+
+  constructor (
+    private i18n: I18n,
+    private route: ActivatedRoute,
+    private router: Router,
+    private metaService: MetaService,
+    private notifier: Notifier,
+    private searchService: SearchService,
+    private authService: AuthService,
+    private userService: UserService,
+    private hooks: HooksService,
+    private serverService: ServerService
+  ) { }
+
+  ngOnInit () {
+    this.serverService.getConfig()
+      .subscribe(config => this.serverConfig = config)
+
+    this.subActivatedRoute = this.route.queryParams.subscribe(
+      async queryParams => {
+        const querySearch = queryParams['search']
+        const searchTarget = queryParams['searchTarget']
+
+        // Search updated, reset filters
+        if (this.currentSearch !== querySearch || searchTarget !== this.advancedSearch.searchTarget) {
+          this.resetPagination()
+          this.advancedSearch.reset()
+
+          this.currentSearch = querySearch || undefined
+          this.updateTitle()
+        }
+
+        this.advancedSearch = new AdvancedSearch(queryParams)
+        if (!this.advancedSearch.searchTarget) {
+          this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget()
+        }
+
+        // Don't hide filters if we have some of them AND the user just came on the webpage
+        this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
+        this.isInitialLoad = false
+
+        this.search()
+      },
+
+      err => this.notifier.error(err.text)
+    )
+
+    this.userService.getAnonymousOrLoggedUser()
+      .subscribe(user => this.userMiniature = user)
+
+    this.hooks.runAction('action:search.init', 'search')
+  }
+
+  ngOnDestroy () {
+    if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
+  }
+
+  isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
+    return d instanceof VideoChannel
+  }
+
+  isVideo (v: VideoChannel | Video): v is Video {
+    return v instanceof Video
+  }
+
+  isUserLoggedIn () {
+    return this.authService.isLoggedIn()
+  }
+
+  search () {
+    forkJoin([
+      this.getVideosObs(),
+      this.getVideoChannelObs()
+    ]).subscribe(
+      ([videosResult, videoChannelsResult]) => {
+        this.results = this.results
+          .concat(videoChannelsResult.data)
+          .concat(videosResult.data)
+
+        this.pagination.totalItems = videosResult.total + videoChannelsResult.total
+        this.lastSearchTarget = this.advancedSearch.searchTarget
+
+        // Focus on channels if there are no enough videos
+        if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
+          this.resetPagination()
+          this.firstSearch = false
+
+          this.channelsPerPage = 10
+          this.search()
+        }
+
+        this.firstSearch = false
+      },
+
+      err => {
+        if (this.advancedSearch.searchTarget !== 'search-index') {
+          this.notifier.error(err.message)
+          return
+        }
+
+        this.notifier.error(
+          this.i18n('Search index is unavailable. Retrying with instance results instead.'),
+          this.i18n('Search error')
+        )
+        this.advancedSearch.searchTarget = 'local'
+        this.search()
+      }
+    )
+  }
+
+  onNearOfBottom () {
+    // Last page
+    if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
+
+    this.pagination.currentPage += 1
+    this.search()
+  }
+
+  onFiltered () {
+    this.resetPagination()
+
+    this.updateUrlFromAdvancedSearch()
+  }
+
+  numberOfFilters () {
+    return this.advancedSearch.size()
+  }
+
+  // Add VideoChannel for typings, but the template already checks "video" argument is a video
+  removeVideoFromArray (video: Video | VideoChannel) {
+    this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
+  }
+
+  getChannelUrl (channel: VideoChannel) {
+    if (this.advancedSearch.searchTarget === 'search-index' && channel.url) {
+      const remoteUriConfig = this.serverConfig.search.remoteUri
+
+      // Redirect on the external instance if not allowed to fetch remote data
+      const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
+      const fromPath = window.location.pathname + window.location.search
+
+      return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ]
+    }
+
+    return [ '/video-channels', channel.nameWithHost ]
+  }
+
+  hideActions () {
+    return this.lastSearchTarget === 'search-index'
+  }
+
+  private resetPagination () {
+    this.pagination.currentPage = 1
+    this.pagination.totalItems = null
+    this.channelsPerPage = 2
+
+    this.results = []
+  }
+
+  private updateTitle () {
+    const suffix = this.currentSearch ? ' ' + this.currentSearch : ''
+    this.metaService.setTitle(this.i18n('Search') + suffix)
+  }
+
+  private updateUrlFromAdvancedSearch () {
+    const search = this.currentSearch || undefined
+
+    this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search })
+    })
+  }
+
+  private getVideosObs () {
+    const params = {
+      search: this.currentSearch,
+      componentPagination: this.pagination,
+      advancedSearch: this.advancedSearch
+    }
+
+    return this.hooks.wrapObsFun(
+      this.searchService.searchVideos.bind(this.searchService),
+      params,
+      'search',
+      'filter:api.search.videos.list.params',
+      'filter:api.search.videos.list.result'
+    )
+  }
+
+  private getVideoChannelObs () {
+    if (!this.currentSearch) return of({ data: [], total: 0 })
+
+    const params = {
+      search: this.currentSearch,
+      componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
+      searchTarget: this.advancedSearch.searchTarget
+    }
+
+    return this.hooks.wrapObsFun(
+      this.searchService.searchVideoChannels.bind(this.searchService),
+      params,
+      'search',
+      'filter:api.search.video-channels.list.params',
+      'filter:api.search.video-channels.list.result'
+    )
+  }
+}
diff --git a/client/src/app/+search/search.module.ts b/client/src/app/+search/search.module.ts
new file mode 100644 (file)
index 0000000..ee4f07a
--- /dev/null
@@ -0,0 +1,44 @@
+import { TagInputModule } from 'ngx-chips'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedSearchModule } from '@app/shared/shared-search'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SearchService } from '../shared/shared-search/search.service'
+import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
+import { SearchFiltersComponent } from './search-filters.component'
+import { SearchRoutingModule } from './search-routing.module'
+import { SearchComponent } from './search.component'
+import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
+
+@NgModule({
+  imports: [
+    TagInputModule,
+
+    SearchRoutingModule,
+
+    SharedMainModule,
+    SharedSearchModule,
+    SharedFormModule,
+    SharedUserSubscriptionModule,
+    SharedVideoMiniatureModule
+  ],
+
+  declarations: [
+    SearchComponent,
+    SearchFiltersComponent
+  ],
+
+  exports: [
+    TagInputModule,
+    SearchComponent
+  ],
+
+  providers: [
+    SearchService,
+    VideoLazyLoadResolver,
+    ChannelLazyLoadResolver
+  ]
+})
+export class SearchModule { }
diff --git a/client/src/app/+search/video-lazy-load.resolver.ts b/client/src/app/+search/video-lazy-load.resolver.ts
new file mode 100644 (file)
index 0000000..e8b2b8c
--- /dev/null
@@ -0,0 +1,43 @@
+import { map } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
+import { SearchService } from '@app/shared/shared-search'
+
+@Injectable()
+export class VideoLazyLoadResolver implements Resolve<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)
+        })
+      )
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/shared/i18n-primeng-calendar.service.ts b/client/src/app/+videos/+video-edit/shared/i18n-primeng-calendar.service.ts
new file mode 100644 (file)
index 0000000..b05852f
--- /dev/null
@@ -0,0 +1,94 @@
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Injectable } from '@angular/core'
+
+@Injectable()
+export class I18nPrimengCalendarService {
+  private readonly calendarLocale: any = {}
+
+  constructor (private i18n: I18n) {
+    this.calendarLocale = {
+      firstDayOfWeek: 0,
+      dayNames: [
+        this.i18n('Sunday'),
+        this.i18n('Monday'),
+        this.i18n('Tuesday'),
+        this.i18n('Wednesday'),
+        this.i18n('Thursday'),
+        this.i18n('Friday'),
+        this.i18n('Saturday')
+      ],
+
+      dayNamesShort: [
+        this.i18n({ value: 'Sun', description: 'Day name short' }),
+        this.i18n({ value: 'Mon', description: 'Day name short' }),
+        this.i18n({ value: 'Tue', description: 'Day name short' }),
+        this.i18n({ value: 'Wed', description: 'Day name short' }),
+        this.i18n({ value: 'Thu', description: 'Day name short' }),
+        this.i18n({ value: 'Fri', description: 'Day name short' }),
+        this.i18n({ value: 'Sat', description: 'Day name short' })
+      ],
+
+      dayNamesMin: [
+        this.i18n({ value: 'Su', description: 'Day name min' }),
+        this.i18n({ value: 'Mo', description: 'Day name min' }),
+        this.i18n({ value: 'Tu', description: 'Day name min' }),
+        this.i18n({ value: 'We', description: 'Day name min' }),
+        this.i18n({ value: 'Th', description: 'Day name min' }),
+        this.i18n({ value: 'Fr', description: 'Day name min' }),
+        this.i18n({ value: 'Sa', description: 'Day name min' })
+      ],
+
+      monthNames: [
+        this.i18n('January'),
+        this.i18n('February'),
+        this.i18n('March'),
+        this.i18n('April'),
+        this.i18n('May'),
+        this.i18n('June'),
+        this.i18n('July'),
+        this.i18n('August'),
+        this.i18n('September'),
+        this.i18n('October'),
+        this.i18n('November'),
+        this.i18n('December')
+      ],
+
+      monthNamesShort: [
+        this.i18n({ value: 'Jan', description: 'Month name short' }),
+        this.i18n({ value: 'Feb', description: 'Month name short' }),
+        this.i18n({ value: 'Mar', description: 'Month name short' }),
+        this.i18n({ value: 'Apr', description: 'Month name short' }),
+        this.i18n({ value: 'May', description: 'Month name short' }),
+        this.i18n({ value: 'Jun', description: 'Month name short' }),
+        this.i18n({ value: 'Jul', description: 'Month name short' }),
+        this.i18n({ value: 'Aug', description: 'Month name short' }),
+        this.i18n({ value: 'Sep', description: 'Month name short' }),
+        this.i18n({ value: 'Oct', description: 'Month name short' }),
+        this.i18n({ value: 'Nov', description: 'Month name short' }),
+        this.i18n({ value: 'Dec', description: 'Month name short' })
+      ],
+
+      today: this.i18n('Today'),
+
+      clear: this.i18n('Clear')
+    }
+  }
+
+  getCalendarLocale () {
+    return this.calendarLocale
+  }
+
+  getTimezone () {
+    const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1]
+    const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+
+    return `${timezone} - ${gmt}`
+  }
+
+  getDateFormat () {
+    return this.i18n({
+      value: 'yy-mm-dd ',
+      description: 'Date format in this locale.'
+    })
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html
new file mode 100644 (file)
index 0000000..6a9e31b
--- /dev/null
@@ -0,0 +1,47 @@
+<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>
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss
new file mode 100644 (file)
index 0000000..b257a16
--- /dev/null
@@ -0,0 +1,20 @@
+@import '_variables';
+@import '_mixins';
+
+.peertube-select-container {
+  @include peertube-select-container(auto);
+}
+
+.caption-file {
+  margin-top: 20px;
+  width: max-content;
+
+  ::ng-deep .root {
+    width: max-content;
+  }
+}
+
+.warning-replace-caption {
+  color: red;
+  margin-top: 10px;
+}
\ No newline at end of file
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
new file mode 100644 (file)
index 0000000..a90d04c
--- /dev/null
@@ -0,0 +1,85 @@
+import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { ServerService } from '@app/core'
+import { FormReactive, FormValidatorService, VideoCaptionsValidatorsService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit } from '@app/shared/shared-main'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
+import { ServerConfig, VideoConstant } from '@shared/models'
+
+@Component({
+  selector: 'my-video-caption-add-modal',
+  styleUrls: [ './video-caption-add-modal.component.scss' ],
+  templateUrl: './video-caption-add-modal.component.html'
+})
+
+export class VideoCaptionAddModalComponent extends FormReactive implements OnInit {
+  @Input() existingCaptions: string[]
+  @Input() serverConfig: ServerConfig
+
+  @Output() captionAdded = new EventEmitter<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()
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
new file mode 100644 (file)
index 0000000..c11a60d
--- /dev/null
@@ -0,0 +1,280 @@
+<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 &#10004;</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>
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss
new file mode 100644 (file)
index 0000000..69b9072
--- /dev/null
@@ -0,0 +1,197 @@
+// Bootstrap grid utilities require functions, variables and mixins
+@import 'node_modules/bootstrap/scss/functions';
+@import 'node_modules/bootstrap/scss/variables';
+@import 'node_modules/bootstrap/scss/mixins';
+@import 'node_modules/bootstrap/scss/grid';
+
+@import 'variables';
+@import 'mixins';
+
+label {
+  font-weight: $font-regular;
+  font-size: 100%;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(auto);
+}
+
+.title-page a {
+  color: pvar(--mainForegroundColor);
+
+  &:hover {
+    text-decoration: none;
+    opacity: .8;
+  }
+}
+
+my-peertube-checkbox {
+  display: block;
+  margin-bottom: 1rem;
+}
+
+.nav-tabs {
+  margin-bottom: 15px;
+}
+
+.video-edit {
+  height: 100%;
+  min-height: 300px;
+
+  .form-group {
+    margin-bottom: 25px;
+  }
+
+  input {
+    @include peertube-input-text(100%);
+    display: block;
+  }
+
+  .label-tags + span {
+    font-size: 15px;
+  }
+
+  .advanced-settings .form-group {
+    margin-bottom: 20px;
+  }
+}
+
+.captions {
+
+  .captions-header {
+    text-align: right;
+    margin-bottom: 1rem;
+
+    .create-caption {
+      @include create-button;
+    }
+  }
+
+  .caption-entry {
+    display: flex;
+    height: 40px;
+    align-items: center;
+
+    a.caption-entry-label {
+      @include disable-default-a-behaviour;
+
+      flex-grow: 1;
+      color: #000;
+
+      &:hover {
+        opacity: 0.8;
+      }
+    }
+
+    .caption-entry-label {
+      font-size: 15px;
+      font-weight: bold;
+
+      margin-right: 20px;
+      width: 150px;
+    }
+
+    .caption-entry-state {
+      width: 200px;
+
+      &.caption-entry-state-create {
+        color: #39CC0B;
+      }
+
+      &.caption-entry-state-delete {
+        color: #FF0000;
+      }
+    }
+
+    .caption-entry-delete {
+      @include peertube-button;
+      @include grey-button;
+    }
+  }
+
+  .no-caption {
+    text-align: center;
+    font-size: 15px;
+  }
+}
+
+.submit-container {
+  text-align: right;
+
+  .message-submit {
+    display: inline-block;
+    margin-right: 25px;
+
+    color: pvar(--greyForegroundColor);
+    font-size: 15px;
+  }
+
+  .submit-button {
+    @include peertube-button;
+    @include orange-button;
+    @include button-with-icon(20px, 1px);
+
+    display: inline-block;
+
+    input {
+      cursor: inherit;
+      background-color: inherit;
+      border: none;
+      padding: 0;
+      outline: 0;
+      color: inherit;
+      font-weight: $font-semibold;
+    }
+  }
+}
+
+p-calendar {
+  display: block;
+
+  ::ng-deep {
+    input,
+    .ui-calendar {
+      width: 100%;
+    }
+
+    input {
+      @include peertube-input-text(100%);
+      color: #000;
+    }
+  }
+}
+
+@include ng2-tags;
+
+// columns for the video
+.col-video-edit {
+  @include make-col-ready();
+
+  @include media-breakpoint-up(md) {
+    @include make-col(7);
+
+    & + .col-video-edit {
+      @include make-col(5);
+    }
+  }
+
+  @include media-breakpoint-up(xl) {
+    @include make-col(8);
+
+    & + .col-video-edit {
+      @include make-col(4);
+    }
+  }
+}
+
+:host-context(.expanded) {
+  .col-video-edit {
+    @include media-breakpoint-up(md) {
+      @include make-col(8);
+
+      & + .col-video-edit {
+        @include make-col(4);
+      }
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
new file mode 100644 (file)
index 0000000..239e453
--- /dev/null
@@ -0,0 +1,274 @@
+import { map } from 'rxjs/operators'
+import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
+import { ServerService } from '@app/core'
+import { removeElementFromArray } from '@app/helpers'
+import { FormReactiveValidationMessages, FormValidatorService, VideoValidatorsService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
+import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
+import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
+
+@Component({
+  selector: 'my-video-edit',
+  styleUrls: [ './video-edit.component.scss' ],
+  templateUrl: './video-edit.component.html'
+})
+export class VideoEditComponent implements OnInit, OnDestroy {
+  @Input() form: FormGroup
+  @Input() formErrors: { [ id: string ]: string } = {}
+  @Input() validationMessages: FormReactiveValidationMessages = {}
+  @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
+  @Input() schedulePublicationPossible = true
+  @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
+  @Input() waitTranscodingEnabled = true
+
+  @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
+
+  // So that it can be accessed in the template
+  readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
+
+  videoPrivacies: VideoConstant<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 || '' })
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.module.ts b/client/src/app/+videos/+video-edit/shared/video-edit.module.ts
new file mode 100644 (file)
index 0000000..96061a3
--- /dev/null
@@ -0,0 +1,38 @@
+import { TagInputModule } from 'ngx-chips'
+import { CalendarModule } from 'primeng/calendar'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
+import { VideoEditComponent } from './video-edit.component'
+
+@NgModule({
+  imports: [
+    TagInputModule,
+    CalendarModule,
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule
+  ],
+
+  declarations: [
+    VideoEditComponent,
+    VideoCaptionAddModalComponent
+  ],
+
+  exports: [
+    TagInputModule,
+    CalendarModule,
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedGlobalIconModule,
+
+    VideoEditComponent
+  ],
+
+  providers: []
+})
+export class VideoEditModule { }
diff --git a/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts b/client/src/app/+videos/+video-edit/video-add-components/drag-drop.directive.ts
new file mode 100644 (file)
index 0000000..7b1a38c
--- /dev/null
@@ -0,0 +1,30 @@
+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)
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html
new file mode 100644 (file)
index 0000000..7287f79
--- /dev/null
@@ -0,0 +1,76 @@
+<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>
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.scss
new file mode 100644 (file)
index 0000000..1fef749
--- /dev/null
@@ -0,0 +1,18 @@
+@import 'variables';
+@import 'mixins';
+
+.first-step-block {
+  .torrent-or-magnet {
+    @include divider($color: pvar(--inputPlaceholderColor), $background: pvar(--submenuColor));
+    
+    &[data-content] {
+      margin: 1.5rem 0;
+    }
+  }
+
+  .form-group-magnet-uri {
+    margin-bottom: 40px;
+  }
+}
+
+
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
new file mode 100644 (file)
index 0000000..538a187
--- /dev/null
@@ -0,0 +1,147 @@
+import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
+import { scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
+import { VideoSend } from './video-send'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy, VideoUpdate } from '@shared/models'
+
+@Component({
+  selector: 'my-video-import-torrent',
+  templateUrl: './video-import-torrent.component.html',
+  styleUrls: [
+    '../shared/video-edit.component.scss',
+    './video-import-torrent.component.scss',
+    './video-send.scss'
+  ]
+})
+export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate {
+  @Output() firstStepDone = new EventEmitter<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())
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html
new file mode 100644 (file)
index 0000000..1910da4
--- /dev/null
@@ -0,0 +1,72 @@
+<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>
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
new file mode 100644 (file)
index 0000000..6508eef
--- /dev/null
@@ -0,0 +1,178 @@
+import { map, switchMap } from 'rxjs/operators'
+import { Component, EventEmitter, OnInit, Output } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
+import { getAbsoluteAPIUrl, scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
+import { VideoSend } from './video-send'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy, VideoUpdate } from '@shared/models'
+
+@Component({
+  selector: 'my-video-import-url',
+  templateUrl: './video-import-url.component.html',
+  styleUrls: [
+    '../shared/video-edit.component.scss',
+    './video-send.scss'
+  ]
+})
+export class VideoImportUrlComponent extends VideoSend implements OnInit, CanComponentDeactivate {
+  @Output() firstStepDone = new EventEmitter<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
+          })
+        })
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.scss b/client/src/app/+videos/+video-edit/video-add-components/video-send.scss
new file mode 100644 (file)
index 0000000..ebe14c5
--- /dev/null
@@ -0,0 +1,46 @@
+@import 'variables';
+@import 'mixins';
+
+$width-size: 190px;
+
+.alert.alert-danger {
+  text-align: center;
+
+  & > div {
+    font-weight: $font-semibold;
+  }
+}
+
+.first-step-block {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  .upload-icon {
+    width: 90px;
+    margin-bottom: 25px;
+
+    @include apply-svg-color(#C6C6C6);
+  }
+
+  .peertube-select-container {
+    @include peertube-select-container($width-size);
+  }
+
+  input[type=text] {
+    @include peertube-input-text($width-size);
+    display: block;
+  }
+
+  input[type=button] {
+    @include peertube-button;
+    @include orange-button;
+
+    width: $width-size;
+    margin-top: 30px;
+  }
+
+  .button-file {
+    @include peertube-button-file(max-content);
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-send.ts b/client/src/app/+videos/+video-edit/video-add-components/video-send.ts
new file mode 100644 (file)
index 0000000..9447932
--- /dev/null
@@ -0,0 +1,71 @@
+import { catchError, switchMap, tap } from 'rxjs/operators'
+import { EventEmitter, OnInit } from '@angular/core'
+import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
+import { populateAsyncUserVideoChannels } from '@app/helpers'
+import { FormReactive } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
+
+export abstract class VideoSend extends FormReactive implements OnInit {
+  userVideoChannels: { id: number, label: string, support: string }[] = []
+  videoPrivacies: VideoConstant<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
+          })
+        )
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
new file mode 100644 (file)
index 0000000..dad88a6
--- /dev/null
@@ -0,0 +1,90 @@
+<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>
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss
new file mode 100644 (file)
index 0000000..a4f87b0
--- /dev/null
@@ -0,0 +1,49 @@
+@import 'variables';
+@import 'mixins';
+
+.first-step-block {
+  .form-group-channel {
+    margin-bottom: 20px;
+    margin-top: 35px;
+  }
+
+  .audio-image-info {
+    margin-bottom: 10px;
+  }
+
+  .audio-preview {
+    margin: 30px 0;
+  }
+}
+
+.upload-progress-cancel {
+  display: flex;
+  margin-top: 25px;
+  margin-bottom: 40px;
+
+  .progress {
+    @include progressbar;
+    flex-grow: 1;
+    height: 30px;
+    font-size: 15px;
+    background-color: rgba(11, 204, 41, 0.16);
+
+    .progress-bar {
+      background-color: $green;
+      line-height: 30px;
+      text-align: left;
+      font-weight: $font-bold;
+
+      span {
+        margin-left: 18px;
+      }
+    }
+  }
+
+  input {
+    @include peertube-button;
+    @include grey-button;
+
+    margin-left: 10px;
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
new file mode 100644 (file)
index 0000000..e46ce65
--- /dev/null
@@ -0,0 +1,306 @@
+import { BytesPipe } from 'ngx-pipes'
+import { Subscription } from 'rxjs'
+import { HttpEventType, HttpResponse } from '@angular/common/http'
+import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService, UserService } from '@app/core'
+import { scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy } from '@shared/models'
+import { VideoSend } from './video-send'
+
+@Component({
+  selector: 'my-video-upload',
+  templateUrl: './video-upload.component.html',
+  styleUrls: [
+    '../shared/video-edit.component.scss',
+    './video-upload.component.scss',
+    './video-send.scss'
+  ]
+})
+export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate {
+  @Output() firstStepDone = new EventEmitter<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))
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add-routing.module.ts b/client/src/app/+videos/+video-edit/video-add-routing.module.ts
new file mode 100644 (file)
index 0000000..9ff66be
--- /dev/null
@@ -0,0 +1,20 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { CanDeactivateGuard, LoginGuard } from '@app/core'
+import { MetaGuard } from '@ngx-meta/core'
+import { VideoAddComponent } from './video-add.component'
+
+const videoAddRoutes: Routes = [
+  {
+    path: '',
+    component: VideoAddComponent,
+    canActivate: [ MetaGuard, LoginGuard ],
+    canDeactivate: [ CanDeactivateGuard ]
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(videoAddRoutes) ],
+  exports: [ RouterModule ]
+})
+export class VideoAddRoutingModule {}
diff --git a/client/src/app/+videos/+video-edit/video-add.component.html b/client/src/app/+videos/+video-edit/video-add.component.html
new file mode 100644 (file)
index 0000000..79bfc6e
--- /dev/null
@@ -0,0 +1,46 @@
+<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>
diff --git a/client/src/app/+videos/+video-edit/video-add.component.scss b/client/src/app/+videos/+video-edit/video-add.component.scss
new file mode 100644 (file)
index 0000000..0ad57d8
--- /dev/null
@@ -0,0 +1,89 @@
+@import '_variables';
+@import '_mixins';
+
+$border-width: 3px;
+$border-type: solid;
+$border-color: #EAEAEA;
+$nav-link-height: 40px;
+
+.margin-content {
+  padding-top: 50px;
+}
+
+.alert {
+  font-size: 15px;
+}
+
+::ng-deep .video-add-nav {
+  border-bottom: $border-width $border-type $border-color;
+  margin: 50px 0 0 0 !important;
+
+  &.hide-nav {
+    display: none !important;
+  }
+
+  a.nav-link {
+    @include disable-default-a-behaviour;
+
+    margin-bottom: -$border-width;
+    height: $nav-link-height !important;
+    padding: 0 30px !important;
+    font-size: 15px;
+
+    &.active {
+      border: $border-width $border-type $border-color;
+      border-bottom: none;
+      background-color: pvar(--submenuColor) !important;
+
+      span {
+        border-bottom: 2px solid pvar(--mainColor);
+        font-weight: $font-bold;
+      }
+    }
+  }
+}
+
+::ng-deep .upload-video-container {
+  border: $border-width $border-type $border-color;
+  border-top: transparent;
+
+  background-color: pvar(--submenuColor);
+  border-bottom-left-radius: 3px;
+  border-bottom-right-radius: 3px;
+  width: 100%;
+  min-height: 440px;
+  padding-bottom: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  &.dragover {
+    border: 3px dashed pvar(--mainColor);
+  }
+}
+
+@mixin nav-scroll {
+  ::ng-deep .video-add-nav {
+    height: #{$nav-link-height + $border-width * 2};
+    overflow-x: auto;
+    white-space: nowrap;
+    flex-wrap: unset;
+
+    /* Hide active tab style to not have a moving tab effect */
+    a.nav-link.active {
+      border: none;
+      background-color: pvar(--mainBackgroundColor) !important;
+    }
+  }
+}
+
+/* Make .video-add-nav tabs scrollable on small devices */
+@media screen and (max-width: $small-view) {
+  @include nav-scroll();
+}
+
+@media screen and (max-width: #{$small-view + $menu-width}) {
+  :host-context(.main-col:not(.expanded)) {
+    @include nav-scroll();
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add.component.ts b/client/src/app/+videos/+video-edit/video-add.component.ts
new file mode 100644 (file)
index 0000000..5bd7688
--- /dev/null
@@ -0,0 +1,77 @@
+import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
+import { AuthService, CanComponentDeactivate, ServerService } from '@app/core'
+import { ServerConfig } from '@shared/models'
+import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
+import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
+import { VideoUploadComponent } from './video-add-components/video-upload.component'
+
+@Component({
+  selector: 'my-videos-add',
+  templateUrl: './video-add.component.html',
+  styleUrls: [ './video-add.component.scss' ]
+})
+export class VideoAddComponent implements OnInit, CanComponentDeactivate {
+  @ViewChild('videoUpload') videoUpload: VideoUploadComponent
+  @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent
+  @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent
+
+  secondStepType: 'upload' | 'import-url' | 'import-torrent'
+  videoName: string
+  serverConfig: ServerConfig
+
+  constructor (
+    private auth: AuthService,
+    private serverService: ServerService
+  ) {}
+
+  ngOnInit () {
+    this.serverConfig = this.serverService.getTmpConfig()
+
+    this.serverService.getConfig()
+      .subscribe(config => this.serverConfig = config)
+  }
+
+  onFirstStepDone (type: 'upload' | 'import-url' | 'import-torrent', videoName: string) {
+    this.secondStepType = type
+    this.videoName = videoName
+  }
+
+  onError () {
+    this.videoName = undefined
+    this.secondStepType = undefined
+  }
+
+  @HostListener('window:beforeunload', [ '$event' ])
+  onUnload (event: any) {
+    const { text, canDeactivate } = this.canDeactivate()
+
+    if (canDeactivate) return
+
+    event.returnValue = text
+    return text
+  }
+
+  canDeactivate (): { canDeactivate: boolean, text?: string} {
+    if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate()
+    if (this.secondStepType === 'import-url') return this.videoImportUrl.canDeactivate()
+    if (this.secondStepType === 'import-torrent') return this.videoImportTorrent.canDeactivate()
+
+    return { canDeactivate: true }
+  }
+
+  isVideoImportHttpEnabled () {
+    return this.serverConfig.import.videos.http.enabled
+  }
+
+  isVideoImportTorrentEnabled () {
+    return this.serverConfig.import.videos.torrent.enabled
+  }
+
+  isInSecondStep () {
+    return !!this.secondStepType
+  }
+
+  isRootUser () {
+    return this.auth.getUser().username === 'root'
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-add.module.ts b/client/src/app/+videos/+video-edit/video-add.module.ts
new file mode 100644 (file)
index 0000000..477c1cf
--- /dev/null
@@ -0,0 +1,32 @@
+import { NgModule } from '@angular/core'
+import { CanDeactivateGuard } from '@app/core'
+import { VideoEditModule } from './shared/video-edit.module'
+import { DragDropDirective } from './video-add-components/drag-drop.directive'
+import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
+import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
+import { VideoUploadComponent } from './video-add-components/video-upload.component'
+import { VideoAddRoutingModule } from './video-add-routing.module'
+import { VideoAddComponent } from './video-add.component'
+
+@NgModule({
+  imports: [
+    VideoAddRoutingModule,
+
+    VideoEditModule
+  ],
+
+  declarations: [
+    VideoAddComponent,
+    VideoUploadComponent,
+    VideoImportUrlComponent,
+    VideoImportTorrentComponent,
+    DragDropDirective
+  ],
+
+  exports: [ ],
+
+  providers: [
+    CanDeactivateGuard
+  ]
+})
+export class VideoAddModule { }
diff --git a/client/src/app/+videos/+video-edit/video-update-routing.module.ts b/client/src/app/+videos/+video-edit/video-update-routing.module.ts
new file mode 100644 (file)
index 0000000..a04351b
--- /dev/null
@@ -0,0 +1,24 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { CanDeactivateGuard, LoginGuard } from '@app/core'
+import { MetaGuard } from '@ngx-meta/core'
+import { VideoUpdateComponent } from './video-update.component'
+import { VideoUpdateResolver } from './video-update.resolver'
+
+const videoUpdateRoutes: Routes = [
+  {
+    path: '',
+    component: VideoUpdateComponent,
+    canActivate: [ MetaGuard, LoginGuard ],
+    canDeactivate: [ CanDeactivateGuard ],
+    resolve: {
+      videoData: VideoUpdateResolver
+    }
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(videoUpdateRoutes) ],
+  exports: [ RouterModule ]
+})
+export class VideoUpdateRoutingModule {}
diff --git a/client/src/app/+videos/+video-edit/video-update.component.html b/client/src/app/+videos/+video-edit/video-update.component.html
new file mode 100644 (file)
index 0000000..fbc642d
--- /dev/null
@@ -0,0 +1,22 @@
+<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>
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
new file mode 100644 (file)
index 0000000..7bd6eb5
--- /dev/null
@@ -0,0 +1,155 @@
+import { map, switchMap } from 'rxjs/operators'
+import { Component, HostListener, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { Notifier } from '@app/core'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy } from '@shared/models'
+
+@Component({
+  selector: 'my-videos-update',
+  styleUrls: [ './shared/video-edit.component.scss' ],
+  templateUrl: './video-update.component.html'
+})
+export class VideoUpdateComponent extends FormReactive implements OnInit {
+  video: VideoEdit
+
+  isUpdatingVideo = false
+  userVideoChannels: { id: number, label: string, support: string }[] = []
+  schedulePublicationPossible = false
+  videoCaptions: VideoCaptionEdit[] = []
+  waitTranscodingEnabled = true
+
+  private updateDone = false
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private route: ActivatedRoute,
+    private router: Router,
+    private notifier: Notifier,
+    private videoService: VideoService,
+    private loadingBar: LoadingBarService,
+    private videoCaptionService: VideoCaptionService,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({})
+
+    this.route.data
+        .pipe(map(data => data.videoData))
+        .subscribe(({ video, videoChannels, videoCaptions }) => {
+          this.video = new VideoEdit(video)
+          this.userVideoChannels = videoChannels
+          this.videoCaptions = videoCaptions
+
+          this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
+
+          const videoFiles = (video as VideoDetails).getFiles()
+          if (videoFiles.length > 1) { // Already transcoded
+            this.waitTranscodingEnabled = false
+          }
+
+          // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout
+          setTimeout(() => this.hydrateFormFromVideo())
+        },
+
+        err => {
+          console.error(err)
+          this.notifier.error(err.message)
+        }
+      )
+  }
+
+  @HostListener('window:beforeunload', [ '$event' ])
+  onUnload (event: any) {
+    const { text, canDeactivate } = this.canDeactivate()
+
+    if (canDeactivate) return
+
+    event.returnValue = text
+    return text
+  }
+
+  canDeactivate (): { canDeactivate: boolean, text?: string } {
+    if (this.updateDone === true) return { canDeactivate: true }
+
+    const text = this.i18n('You have unsaved changes! If you leave, your changes will be lost.')
+
+    for (const caption of this.videoCaptions) {
+      if (caption.action) return { canDeactivate: false, text }
+    }
+
+    return { canDeactivate: this.formChanged === false, text }
+  }
+
+  checkForm () {
+    this.forceCheck()
+
+    return this.form.valid
+  }
+
+  update () {
+    if (this.checkForm() === false
+      || this.isUpdatingVideo === true) {
+      return
+    }
+
+    this.video.patch(this.form.value)
+
+    this.loadingBar.start()
+    this.isUpdatingVideo = true
+
+    // Update the video
+    this.videoService.updateVideo(this.video)
+        .pipe(
+          // Then update captions
+          switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
+        )
+        .subscribe(
+          () => {
+            this.updateDone = true
+            this.isUpdatingVideo = false
+            this.loadingBar.complete()
+            this.notifier.success(this.i18n('Video updated.'))
+            this.router.navigate([ '/videos/watch', this.video.uuid ])
+          },
+
+          err => {
+            this.loadingBar.complete()
+            this.isUpdatingVideo = false
+            this.notifier.error(err.message)
+            console.error(err)
+          }
+        )
+  }
+
+  private hydrateFormFromVideo () {
+    this.form.patchValue(this.video.toFormPatch())
+
+    const objects = [
+      {
+        url: 'thumbnailUrl',
+        name: 'thumbnailfile'
+      },
+      {
+        url: 'previewUrl',
+        name: 'previewfile'
+      }
+    ]
+
+    for (const obj of objects) {
+      fetch(this.video[obj.url])
+        .then(response => response.blob())
+        .then(data => {
+          this.form.patchValue({
+            [ obj.name ]: data
+          })
+        })
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-edit/video-update.module.ts b/client/src/app/+videos/+video-edit/video-update.module.ts
new file mode 100644 (file)
index 0000000..99cd8be
--- /dev/null
@@ -0,0 +1,26 @@
+import { NgModule } from '@angular/core'
+import { CanDeactivateGuard } from '@app/core'
+import { VideoEditModule } from './shared/video-edit.module'
+import { VideoUpdateRoutingModule } from './video-update-routing.module'
+import { VideoUpdateComponent } from './video-update.component'
+import { VideoUpdateResolver } from './video-update.resolver'
+
+@NgModule({
+  imports: [
+    VideoUpdateRoutingModule,
+
+    VideoEditModule
+  ],
+
+  declarations: [
+    VideoUpdateComponent
+  ],
+
+  exports: [ ],
+
+  providers: [
+    VideoUpdateResolver,
+    CanDeactivateGuard
+  ]
+})
+export class VideoUpdateModule { }
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
new file mode 100644 (file)
index 0000000..30bcf4d
--- /dev/null
@@ -0,0 +1,44 @@
+import { forkJoin } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
+import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main'
+
+@Injectable()
+export class VideoUpdateResolver implements Resolve<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 }))
+               )
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html
new file mode 100644 (file)
index 0000000..9b43d91
--- /dev/null
@@ -0,0 +1,56 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss
new file mode 100644 (file)
index 0000000..b3725ab
--- /dev/null
@@ -0,0 +1,82 @@
+@import '_variables';
+@import '_mixins';
+
+form {
+  margin-bottom: 30px;
+}
+
+.avatar-and-textarea {
+  display: flex;
+  margin-bottom: 10px;
+
+  img {
+    @include avatar(25px);
+
+    vertical-align: top;
+    margin-right: 10px;
+  }
+
+  .form-group {
+    flex-grow: 1;
+    margin: 0;
+
+    textarea {
+      @include peertube-textarea(100%, 60px);
+
+      &:focus::placeholder {
+        opacity: 0;
+      }
+    }
+  }
+}
+
+.comment-buttons {
+  display: flex;
+  justify-content: flex-end;
+
+  button {
+    @include peertube-button;
+    @include disable-outline;
+    @include disable-default-a-behaviour;
+
+    &:not(:last-child) {
+      margin-right: .5rem;
+    }
+
+    &:last-child {
+      @include orange-button;
+    }
+  }
+
+  .cancel-button {
+    @include tertiary-button;
+
+    font-weight: $font-semibold;
+    display: inline-block;
+    padding: 0 10px 0 10px;
+    white-space: nowrap;
+    background: transparent;
+  }
+}
+
+@media screen and (max-width: 600px) {
+  textarea, .comment-buttons button {
+    font-size: 14px !important;
+  }
+
+  textarea {
+    padding: 5px !important;
+  }
+}
+
+.modal-body {
+  .btn {
+    @include peertube-button;
+    @include orange-button;
+  }
+
+  span {
+    float: left;
+    margin-bottom: 20px;
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
new file mode 100644 (file)
index 0000000..79505c7
--- /dev/null
@@ -0,0 +1,149 @@
+import { Observable } from 'rxjs'
+import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { Router } from '@angular/router'
+import { Notifier, User } from '@app/core'
+import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
+import { Video } from '@app/shared/shared-main'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { VideoCommentCreate } from '@shared/models'
+import { VideoComment } from './video-comment.model'
+import { VideoCommentService } from './video-comment.service'
+
+@Component({
+  selector: 'my-video-comment-add',
+  templateUrl: './video-comment-add.component.html',
+  styleUrls: ['./video-comment-add.component.scss']
+})
+export class VideoCommentAddComponent extends FormReactive implements OnInit {
+  @Input() user: User
+  @Input() video: Video
+  @Input() parentComment: VideoComment
+  @Input() parentComments: VideoComment[]
+  @Input() focusOnInit = false
+
+  @Output() commentCreated = new EventEmitter<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)
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts
new file mode 100644 (file)
index 0000000..7c2aaea
--- /dev/null
@@ -0,0 +1,7 @@
+import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
+import { VideoComment } from './video-comment.model'
+
+export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
+  comment: VideoComment
+  children: VideoCommentThreadTree[]
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/comment/video-comment.component.html
new file mode 100644 (file)
index 0000000..002de57
--- /dev/null
@@ -0,0 +1,95 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss
new file mode 100644 (file)
index 0000000..e7ef795
--- /dev/null
@@ -0,0 +1,189 @@
+@import '_variables';
+@import '_mixins';
+
+.root-comment {
+  font-size: 15px;
+  display: flex;
+
+  .left {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    margin-right: 10px;
+
+    .vertical-border {
+      width: 2px;
+      height: 100%;
+      background-color: rgba(0, 0, 0, 0.05);
+      margin: 10px calc(1rem + 1px);
+    }
+  }
+
+  .right {
+    width: 100%;
+  }
+
+  .comment-avatar {
+    @include avatar(36px);
+  }
+
+  .comment {
+    flex-grow: 1;
+    // Fix word-wrap with flex
+    min-width: 1px;
+
+    .highlighted-comment {
+      display: inline-block;
+      background-color: #F5F5F5;
+      color: #3d3d3d;
+      padding: 0 5px;
+      font-size: 13px;
+      margin-bottom: 5px;
+      font-weight: $font-semibold;
+      border-radius: 3px;
+    }
+
+    .comment-account-date {
+      display: flex;
+      margin-bottom: 4px;
+
+      .video-author {
+        height: 20px;
+        background-color: #888888;
+        border-radius: 12px;
+        margin-bottom: 2px;
+        max-width: 100%;
+        box-sizing: border-box;
+        flex-direction: row;
+        align-items: center;
+        display: inline-flex;
+        padding-right: 6px;
+        padding-left: 6px;
+        color: white !important;
+      }
+
+      .comment-account {
+        word-break: break-all;
+        font-weight: 600;
+        font-size: 90%;
+
+        a {
+          @include disable-default-a-behaviour;
+
+          color: pvar(--mainForegroundColor);
+        }
+
+        .comment-account-fid {
+          opacity: .6;
+        }
+      }
+
+      .comment-date {
+        font-size: 90%;
+        color: pvar(--greyForegroundColor);
+        margin-left: 5px;
+        text-decoration: none;
+      }
+    }
+
+    .comment-html {
+      @include peertube-word-wrap;
+
+      // Mentions
+      ::ng-deep a {
+
+        &:not(.linkified-url) {
+          @include disable-default-a-behaviour;
+
+          color: pvar(--mainForegroundColor);
+
+          font-weight: $font-semibold;
+        }
+
+      }
+
+      // Paragraphs
+      ::ng-deep p {
+        margin-bottom: .3rem;
+      }
+
+      &.comment-html-deleted {
+        color: pvar(--greyForegroundColor);
+        margin-bottom: 1rem;
+      }
+    }
+
+    .comment-actions {
+      margin-bottom: 10px;
+      display: flex;
+
+      ::ng-deep .dropdown-toggle,
+      .comment-action-reply,
+      .comment-action-delete {
+        color: pvar(--greyForegroundColor);
+        cursor: pointer;
+        margin-right: 10px;
+
+        &:hover {
+          color: pvar(--mainForegroundColor);
+        }
+      }
+
+      ::ng-deep .action-button {
+        background-color: transparent;
+        padding: 0;
+        font-weight: unset;
+      }
+    }
+
+    my-video-comment-add {
+      ::ng-deep form {
+        margin-top: 1rem;
+        margin-bottom: 0;
+      }
+    }
+  }
+
+  .children {
+    // Reduce avatars size for replies
+    .comment-avatar {
+      @include avatar(25px);
+    }
+
+    .left {
+      margin-right: 6px;
+    }
+  }
+}
+
+@media screen and (max-width: 1200px) {
+  .children {
+    margin-left: -10px;
+  }
+}
+
+@media screen and (max-width: 600px) {
+  .root-comment {
+    .children {
+      margin-left: -20px;
+
+      .left {
+        align-items: flex-start;
+
+        .vertical-border {
+          margin-left: 2px;
+        }
+      }
+    }
+
+    .comment {
+      .comment-account-date {
+        flex-direction: column;
+
+        .comment-date {
+          margin-left: 0;
+        }
+      }
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
new file mode 100644 (file)
index 0000000..27846c1
--- /dev/null
@@ -0,0 +1,131 @@
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
+import { MarkdownService, Notifier, UserService } from '@app/core'
+import { AuthService } from '@app/core/auth'
+import { Account, Actor, Video } from '@app/shared/shared-main'
+import { User, UserRight } from '@shared/models'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
+import { VideoComment } from './video-comment.model'
+
+@Component({
+  selector: 'my-video-comment',
+  templateUrl: './video-comment.component.html',
+  styleUrls: ['./video-comment.component.scss']
+})
+export class VideoCommentComponent implements OnInit, OnChanges {
+  @Input() video: Video
+  @Input() comment: VideoComment
+  @Input() parentComments: VideoComment[] = []
+  @Input() commentTree: VideoCommentThreadTree
+  @Input() inReplyToCommentId: number
+  @Input() highlightedComment = false
+  @Input() firstInThread = false
+
+  @Output() wantedToDelete = new EventEmitter<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
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts
new file mode 100644 (file)
index 0000000..e854431
--- /dev/null
@@ -0,0 +1,48 @@
+import { getAbsoluteAPIUrl } from '@app/helpers'
+import { Actor } from '@app/shared/shared-main'
+import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models'
+
+export class VideoComment implements VideoCommentServerModel {
+  id: number
+  url: string
+  text: string
+  threadId: number
+  inReplyToCommentId: number
+  videoId: number
+  createdAt: Date | string
+  updatedAt: Date | string
+  deletedAt: Date | string
+  isDeleted: boolean
+  account: AccountInterface
+  totalRepliesFromVideoAuthor: number
+  totalReplies: number
+  by: string
+  accountAvatarUrl: string
+
+  isLocal: boolean
+
+  constructor (hash: VideoCommentServerModel) {
+    this.id = hash.id
+    this.url = hash.url
+    this.text = hash.text
+    this.threadId = hash.threadId
+    this.inReplyToCommentId = hash.inReplyToCommentId
+    this.videoId = hash.videoId
+    this.createdAt = new Date(hash.createdAt.toString())
+    this.updatedAt = new Date(hash.updatedAt.toString())
+    this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null
+    this.isDeleted = hash.isDeleted
+    this.account = hash.account
+    this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor
+    this.totalReplies = hash.totalReplies
+
+    if (this.account) {
+      this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
+      this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
+
+      const absoluteAPIUrl = getAbsoluteAPIUrl()
+      const thisHost = new URL(absoluteAPIUrl).host
+      this.isLocal = this.account.host.trim() === thisHost
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.service.ts b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts
new file mode 100644 (file)
index 0000000..a73fb9c
--- /dev/null
@@ -0,0 +1,149 @@
+import { Observable } from 'rxjs'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
+import { objectLineFeedToHtml } from '@app/helpers'
+import {
+  FeedFormat,
+  ResultList,
+  VideoComment as VideoCommentServerModel,
+  VideoCommentCreate,
+  VideoCommentThreadTree as VideoCommentThreadTreeServerModel
+} from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
+import { VideoComment } from './video-comment.model'
+
+@Injectable()
+export class VideoCommentService {
+  private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
+  private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor,
+    private restService: RestService
+  ) {}
+
+  addCommentThread (videoId: number | string, comment: VideoCommentCreate) {
+    const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
+    const normalizedComment = objectLineFeedToHtml(comment, 'text')
+
+    return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
+               .pipe(
+                  map(data => this.extractVideoComment(data.comment)),
+                  catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) {
+    const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
+    const normalizedComment = objectLineFeedToHtml(comment, 'text')
+
+    return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
+               .pipe(
+                 map(data => this.extractVideoComment(data.comment)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  getVideoCommentThreads (parameters: {
+    videoId: number | string,
+    componentPagination: ComponentPaginationLight,
+    sort: string
+  }): Observable<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
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/comment/video-comments.component.html
new file mode 100644 (file)
index 0000000..dd1d435
--- /dev/null
@@ -0,0 +1,98 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.scss b/client/src/app/+videos/+video-watch/comment/video-comments.component.scss
new file mode 100644 (file)
index 0000000..df42fae
--- /dev/null
@@ -0,0 +1,53 @@
+@import '_variables';
+@import '_mixins';
+
+#highlighted-comment {
+  margin-bottom: 25px;
+}
+
+.view-replies {
+  font-weight: $font-semibold;
+  font-size: 15px;
+  cursor: pointer;
+}
+
+.glyphicon, .comment-thread-loading {
+  margin-right: 5px;
+  display: inline-block;
+  font-size: 13px;
+}
+
+.title-block {
+  .title-page {
+    margin-right: 0;
+  }
+
+  my-feed {
+    display: inline-block;
+    margin-left: 5px;
+    opacity: 0;
+    transition: ease-in .2s opacity;
+  }
+  &:hover my-feed {
+    opacity: 1;
+  }
+}
+
+#dropdown-sort-comments {
+  font-weight: 600;
+  text-transform: uppercase;
+  border: none;
+  transform: translateY(-7%);
+}
+
+@media screen and (max-width: 600px) {
+  .view-replies {
+    margin-left: 46px;
+  }
+}
+
+@media screen and (max-width: 450px) {
+  .view-replies {
+    font-size: 14px;
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
new file mode 100644 (file)
index 0000000..df0018e
--- /dev/null
@@ -0,0 +1,232 @@
+import { Subject, Subscription } from 'rxjs'
+import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { Syndication, VideoDetails } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
+import { VideoComment } from './video-comment.model'
+import { VideoCommentService } from './video-comment.service'
+
+@Component({
+  selector: 'my-video-comments',
+  templateUrl: './video-comments.component.html',
+  styleUrls: ['./video-comments.component.scss']
+})
+export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
+  @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
+  @Input() video: VideoDetails
+  @Input() user: User
+
+  @Output() timestampClicked = new EventEmitter<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)
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.html b/client/src/app/+videos/+video-watch/modal/video-share.component.html
new file mode 100644 (file)
index 0000000..5e6a2d5
--- /dev/null
@@ -0,0 +1,187 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.scss b/client/src/app/+videos/+video-watch/modal/video-share.component.scss
new file mode 100644 (file)
index 0000000..091d4dc
--- /dev/null
@@ -0,0 +1,79 @@
+@import '_mixins';
+@import '_variables';
+
+my-input-readonly-copy {
+  width: 100%;
+}
+
+.title-page.title-page-single {
+  margin-top: 0;
+}
+
+.playlist {
+  margin-bottom: 50px;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(200px);
+}
+
+.qr-code-group {
+  text-align: center;
+}
+
+.nav-content {
+  margin-top: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-direction: column;
+}
+
+.alert {
+  margin-top: 20px;
+}
+
+.filters {
+  margin-top: 30px;
+
+  .advanced-filters-button {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin-top: 20px;
+    font-size: 16px;
+    font-weight: $font-semibold;
+    cursor: pointer;
+
+    .glyphicon {
+      margin-right: 5px;
+    }
+  }
+
+  .form-group {
+    margin-bottom: 0;
+    height: 34px;
+    display: flex;
+    align-items: center;
+  }
+
+  .video-caption-block {
+    display: flex;
+    align-items: center;
+
+    .peertube-select-container {
+      margin-left: 10px;
+    }
+  }
+
+  .start-at,
+  .stop-at {
+    width: 300px;
+    display: flex;
+    align-items: center;
+
+    my-timestamp-input {
+      margin-left: 10px;
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/modal/video-share.component.ts b/client/src/app/+videos/+video-watch/modal/video-share.component.ts
new file mode 100644 (file)
index 0000000..b42b775
--- /dev/null
@@ -0,0 +1,126 @@
+import { Component, ElementRef, Input, ViewChild } from '@angular/core'
+import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { VideoCaption } from '@shared/models'
+import { VideoDetails } from '@app/shared/shared-main'
+import { VideoPlaylist } from '@app/shared/shared-video-playlist'
+
+type Customizations = {
+  startAtCheckbox: boolean
+  startAt: number
+
+  stopAtCheckbox: boolean
+  stopAt: number
+
+  subtitleCheckbox: boolean
+  subtitle: string
+
+  loop: boolean
+  autoplay: boolean
+  muted: boolean
+  title: boolean
+  warningTitle: boolean
+  controls: boolean
+}
+
+@Component({
+  selector: 'my-video-share',
+  templateUrl: './video-share.component.html',
+  styleUrls: [ './video-share.component.scss' ]
+})
+export class VideoShareComponent {
+  @ViewChild('modal', { static: true }) modal: ElementRef
+
+  @Input() video: VideoDetails = null
+  @Input() videoCaptions: VideoCaption[] = []
+  @Input() playlist: VideoPlaylist = null
+
+  activeId: 'url' | 'qrcode' | 'embed' = 'url'
+  customizations: Customizations
+  isAdvancedCustomizationCollapsed = true
+  includeVideoInPlaylist = false
+
+  constructor (private modalService: NgbModal) { }
+
+  show (currentVideoTimestamp?: number) {
+    let subtitle: string
+    if (this.videoCaptions.length !== 0) {
+      subtitle = this.videoCaptions[0].language.id
+    }
+
+    this.customizations = {
+      startAtCheckbox: false,
+      startAt: currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0,
+
+      stopAtCheckbox: false,
+      stopAt: this.video.duration,
+
+      subtitleCheckbox: false,
+      subtitle,
+
+      loop: false,
+      autoplay: false,
+      muted: false,
+
+      // Embed options
+      title: true,
+      warningTitle: true,
+      controls: true
+    }
+
+    this.modalService.open(this.modal, { centered: true })
+  }
+
+  getVideoIframeCode () {
+    const options = this.getOptions(this.video.embedUrl)
+
+    const embedUrl = buildVideoLink(options)
+    return buildVideoEmbed(embedUrl)
+  }
+
+  getVideoUrl () {
+    const baseUrl = window.location.origin + '/videos/watch/' + this.video.uuid
+    const options = this.getOptions(baseUrl)
+
+    return buildVideoLink(options)
+  }
+
+  getPlaylistUrl () {
+    const base = window.location.origin + '/videos/watch/playlist/' + this.playlist.uuid
+
+    if (!this.includeVideoInPlaylist) return base
+
+    return base + '?videoId=' + this.video.uuid
+  }
+
+  notSecure () {
+    return window.location.protocol === 'http:'
+  }
+
+  isInEmbedTab () {
+    return this.activeId === 'embed'
+  }
+
+  hasPlaylist () {
+    return !!this.playlist
+  }
+
+  private getOptions (baseUrl?: string) {
+    return {
+      baseUrl,
+
+      startTime: this.customizations.startAtCheckbox ? this.customizations.startAt : undefined,
+      stopTime: this.customizations.stopAtCheckbox ? this.customizations.stopAt : undefined,
+
+      subtitle: this.customizations.subtitleCheckbox ? this.customizations.subtitle : undefined,
+
+      loop: this.customizations.loop,
+      autoplay: this.customizations.autoplay,
+      muted: this.customizations.muted,
+
+      title: this.customizations.title,
+      warningTitle: this.customizations.warningTitle,
+      controls: this.customizations.controls
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/modal/video-support.component.html b/client/src/app/+videos/+video-watch/modal/video-support.component.html
new file mode 100644 (file)
index 0000000..935656d
--- /dev/null
@@ -0,0 +1,15 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/modal/video-support.component.scss b/client/src/app/+videos/+video-watch/modal/video-support.component.scss
new file mode 100644 (file)
index 0000000..184e090
--- /dev/null
@@ -0,0 +1,3 @@
+.action-button-cancel {
+  margin-right: 0 !important;
+}
diff --git a/client/src/app/+videos/+video-watch/modal/video-support.component.ts b/client/src/app/+videos/+video-watch/modal/video-support.component.ts
new file mode 100644 (file)
index 0000000..48d5f29
--- /dev/null
@@ -0,0 +1,29 @@
+import { Component, Input, ViewChild } from '@angular/core'
+import { MarkdownService } from '@app/core'
+import { VideoDetails } from '@app/shared/shared-main'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+
+@Component({
+  selector: 'my-video-support',
+  templateUrl: './video-support.component.html',
+  styleUrls: [ './video-support.component.scss' ]
+})
+export class VideoSupportComponent {
+  @Input() video: VideoDetails = null
+
+  @ViewChild('modal', { static: true }) modal: NgbModal
+
+  videoHTMLSupport = ''
+
+  constructor (
+    private markdownService: MarkdownService,
+    private modalService: NgbModal
+  ) { }
+
+  show () {
+    this.modalService.open(this.modal, { centered: true })
+
+    this.markdownService.enhancedMarkdownToHTML(this.video.support)
+      .then(r => this.videoHTMLSupport = r)
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts b/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts
new file mode 100644 (file)
index 0000000..29fa268
--- /dev/null
@@ -0,0 +1,81 @@
+import { Observable, of } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ServerService, UserService } from '@app/core'
+import { Video, VideoService } from '@app/shared/shared-main'
+import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
+import { ServerConfig } from '@shared/models'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendationService } from './recommendations.service'
+
+/**
+ * Provides "recommendations" by providing the most recently uploaded videos.
+ */
+@Injectable()
+export class RecentVideosRecommendationService implements RecommendationService {
+  readonly pageSize = 5
+
+  private config: ServerConfig
+
+  constructor (
+    private videos: VideoService,
+    private searchService: SearchService,
+    private userService: UserService,
+    private serverService: ServerService
+  ) {
+    this.config = this.serverService.getTmpConfig()
+
+    this.serverService.getConfig()
+     .subscribe(config => this.config = config)
+  }
+
+  getRecommendations (recommendation: RecommendationInfo): Observable<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)
+        })
+      )
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts b/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts
new file mode 100644 (file)
index 0000000..0233563
--- /dev/null
@@ -0,0 +1,4 @@
+export interface RecommendationInfo {
+  uuid: string
+  tags?: string[]
+}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts
new file mode 100644 (file)
index 0000000..259afb1
--- /dev/null
@@ -0,0 +1,34 @@
+import { InputSwitchModule } from 'primeng/inputswitch'
+import { CommonModule } from '@angular/common'
+import { NgModule } from '@angular/core'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedSearchModule } from '@app/shared/shared-search'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
+import { RecommendedVideosComponent } from './recommended-videos.component'
+import { RecommendedVideosStore } from './recommended-videos.store'
+
+@NgModule({
+  imports: [
+    CommonModule,
+    InputSwitchModule,
+
+    SharedMainModule,
+    SharedSearchModule,
+    SharedVideoPlaylistModule,
+    SharedVideoMiniatureModule
+  ],
+  declarations: [
+    RecommendedVideosComponent
+  ],
+  exports: [
+    RecommendedVideosComponent
+  ],
+  providers: [
+    RecommendedVideosStore,
+    RecentVideosRecommendationService
+  ]
+})
+export class RecommendationsModule {
+}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts
new file mode 100644 (file)
index 0000000..1d79d35
--- /dev/null
@@ -0,0 +1,7 @@
+import { Observable } from 'rxjs'
+import { Video } from '@app/shared/shared-main'
+import { RecommendationInfo } from './recommendation-info.model'
+
+export interface RecommendationService {
+  getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
+}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html
new file mode 100644 (file)
index 0000000..0467cab
--- /dev/null
@@ -0,0 +1,24 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss
new file mode 100644 (file)
index 0000000..b278c96
--- /dev/null
@@ -0,0 +1,31 @@
+.title-page-container {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 25px;
+  flex-wrap: wrap-reverse;
+
+  .title-page.active, .title-page.title-page-single {
+    margin-bottom: unset;
+    margin-right: .5rem !important;
+  }
+}
+
+.title-page-autoplay {
+  display: flex;
+  width: max-content;
+  height: max-content;
+  align-items: center;
+  margin-left: auto;
+
+  span {
+    margin-right: 0.3rem;
+    text-transform: uppercase;
+    font-size: 85%;
+    font-weight: 600;
+  }
+}
+
+hr {
+  margin-top: 0;
+}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts
new file mode 100644 (file)
index 0000000..0169753
--- /dev/null
@@ -0,0 +1,91 @@
+import { Observable } from 'rxjs'
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
+import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
+import { VideoPlaylist } from '@app/shared/shared-video-playlist'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendedVideosStore } from './recommended-videos.store'
+
+@Component({
+  selector: 'my-recommended-videos',
+  templateUrl: './recommended-videos.component.html',
+  styleUrls: [ './recommended-videos.component.scss' ]
+})
+export class RecommendedVideosComponent implements OnInit, OnChanges {
+  @Input() inputRecommendation: RecommendationInfo
+  @Input() playlist: VideoPlaylist
+  @Output() gotRecommendations = new EventEmitter<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)
+      )
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts
new file mode 100644 (file)
index 0000000..8c3fb64
--- /dev/null
@@ -0,0 +1,37 @@
+import { Observable, ReplaySubject } from 'rxjs'
+import { map, shareReplay, switchMap, take } from 'rxjs/operators'
+import { Inject, Injectable } from '@angular/core'
+import { Video } from '@app/shared/shared-main'
+import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendationService } from './recommendations.service'
+
+/**
+ * This store is intended to provide data for the RecommendedVideosComponent.
+ */
+@Injectable()
+export class RecommendedVideosStore {
+  public readonly recommendations$: Observable<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)
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts b/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts
new file mode 100644 (file)
index 0000000..45e0236
--- /dev/null
@@ -0,0 +1,39 @@
+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()
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts b/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts
new file mode 100644 (file)
index 0000000..4b67674
--- /dev/null
@@ -0,0 +1,28 @@
+import { Pipe, PipeTransform } from '@angular/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Pipe({
+  name: 'myVideoDurationFormatter'
+})
+export class VideoDurationPipe implements PipeTransform {
+
+  constructor (private i18n: I18n) {
+
+  }
+
+  transform (value: number): string {
+    const hours = Math.floor(value / 3600)
+    const minutes = Math.floor((value % 3600) / 60)
+    const seconds = value % 60
+
+    if (hours > 0) {
+      return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds })
+    }
+
+    if (minutes > 0) {
+      return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds })
+    }
+
+    return this.i18n('{{seconds}} sec', { seconds })
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.html b/client/src/app/+videos/+video-watch/video-watch-playlist.component.html
new file mode 100644 (file)
index 0000000..246ef83
--- /dev/null
@@ -0,0 +1,46 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss b/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss
new file mode 100644 (file)
index 0000000..0b0a2a8
--- /dev/null
@@ -0,0 +1,83 @@
+@import '_variables';
+@import '_mixins';
+@import '_bootstrap-variables';
+@import '_miniature';
+
+.playlist {
+  min-width: 200px;
+  max-width: 470px;
+  height: 66vh;
+  background-color: pvar(--mainBackgroundColor);
+  overflow-y: auto;
+  border-bottom: 1px solid $separator-border-color;
+
+  .playlist-info {
+    padding: 5px 30px;
+    background-color: #e4e4e4;
+
+    .playlist-display-name {
+      font-size: 18px;
+      font-weight: $font-semibold;
+      margin-bottom: 5px;
+    }
+
+    .playlist-by-index {
+      color: pvar(--greyForegroundColor);
+      display: flex;
+
+      .playlist-by {
+        margin-right: 5px;
+      }
+
+      .playlist-index span:first-child::after {
+        content: '/';
+        margin: 0 3px;
+      }
+    }
+
+    .playlist-controls {
+      display: flex;
+      margin: 10px 0;
+
+      my-global-icon:not(:last-child) {
+        margin-right: .5rem;
+      }
+
+      my-global-icon {
+        &:not(.active) {
+          opacity: .5
+        }
+
+        ::ng-deep {
+          cursor: pointer;
+        }
+      }
+    }
+  }
+
+  my-video-playlist-element-miniature {
+    ::ng-deep {
+      .video {
+        .position {
+          margin-right: 0;
+        }
+
+        .video-info {
+          .video-info-name {
+            font-size: 15px;
+          }
+        }
+      }
+
+      my-video-thumbnail {
+        @include thumbnail-size-component(90px, 50px);
+      }
+
+      .fake-thumbnail {
+        width: 90px;
+        height: 50px;
+      }
+    }
+  }
+}
+
diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts
new file mode 100644 (file)
index 0000000..2c21be6
--- /dev/null
@@ -0,0 +1,201 @@
+import { Component, Input } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core'
+import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage'
+import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
+
+@Component({
+  selector: 'my-video-watch-playlist',
+  templateUrl: './video-watch-playlist.component.html',
+  styleUrls: [ './video-watch-playlist.component.scss' ]
+})
+export class VideoWatchPlaylistComponent {
+  static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist'
+  static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist'
+
+  @Input() video: VideoDetails
+  @Input() playlist: VideoPlaylist
+
+  playlistElements: VideoPlaylistElement[] = []
+  playlistPagination: ComponentPagination = {
+    currentPage: 1,
+    itemsPerPage: 30,
+    totalItems: null
+  }
+
+  autoPlayNextVideoPlaylist: boolean
+  autoPlayNextVideoPlaylistSwitchText = ''
+  loopPlaylist: boolean
+  loopPlaylistSwitchText = ''
+  noPlaylistVideos = false
+  currentPlaylistPosition = 1
+
+  constructor (
+    private userService: UserService,
+    private auth: AuthService,
+    private notifier: Notifier,
+    private i18n: I18n,
+    private videoPlaylist: VideoPlaylistService,
+    private localStorageService: LocalStorageService,
+    private sessionStorageService: SessionStorageService,
+    private router: Router
+  ) {
+    // defaults to true
+    this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn()
+      ? this.auth.getUser().autoPlayNextVideoPlaylist
+      : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false'
+    this.setAutoPlayNextVideoPlaylistSwitchText()
+
+    // defaults to false
+    this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true'
+    this.setLoopPlaylistSwitchText()
+  }
+
+  onPlaylistVideosNearOfBottom () {
+    // Last page
+    if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return
+
+    this.playlistPagination.currentPage += 1
+    this.loadPlaylistElements(this.playlist,false)
+  }
+
+  onElementRemoved (playlistElement: VideoPlaylistElement) {
+    this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id)
+
+    this.playlistPagination.totalItems--
+  }
+
+  isPlaylistOwned () {
+    return this.playlist.isLocal === true &&
+      this.auth.isLoggedIn() &&
+      this.playlist.ownerAccount.name === this.auth.getUser().username
+  }
+
+  isUnlistedPlaylist () {
+    return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED
+  }
+
+  isPrivatePlaylist () {
+    return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
+  }
+
+  isPublicPlaylist () {
+    return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC
+  }
+
+  loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) {
+    this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination)
+        .subscribe(({ total, data }) => {
+          this.playlistElements = this.playlistElements.concat(data)
+          this.playlistPagination.totalItems = total
+
+          const firstAvailableVideos = this.playlistElements.find(e => !!e.video)
+          if (!firstAvailableVideos) {
+            this.noPlaylistVideos = true
+            return
+          }
+
+          this.updatePlaylistIndex(this.video)
+
+          if (redirectToFirst) {
+            const extras = {
+              queryParams: {
+                start: firstAvailableVideos.startTimestamp,
+                stop: firstAvailableVideos.stopTimestamp,
+                videoId: firstAvailableVideos.video.uuid
+              },
+              replaceUrl: true
+            }
+            this.router.navigate([], extras)
+          }
+        })
+  }
+
+  updatePlaylistIndex (video: VideoDetails) {
+    if (this.playlistElements.length === 0 || !video) return
+
+    for (const playlistElement of this.playlistElements) {
+      if (playlistElement.video && playlistElement.video.id === video.id) {
+        this.currentPlaylistPosition = playlistElement.position
+        return
+      }
+    }
+
+    // Load more videos to find our video
+    this.onPlaylistVideosNearOfBottom()
+  }
+
+  findNextPlaylistVideo (position = this.currentPlaylistPosition): VideoPlaylistElement {
+    if (this.currentPlaylistPosition >= this.playlistPagination.totalItems) {
+      // we have reached the end of the playlist: either loop or stop
+      if (this.loopPlaylist) {
+        this.currentPlaylistPosition = position = 0
+      } else {
+        return
+      }
+    }
+
+    const next = this.playlistElements.find(e => e.position === position)
+
+    if (!next || !next.video) {
+      return this.findNextPlaylistVideo(position + 1)
+    }
+
+    return next
+  }
+
+  navigateToNextPlaylistVideo () {
+    const next = this.findNextPlaylistVideo(this.currentPlaylistPosition + 1)
+    if (!next) return
+    const start = next.startTimestamp
+    const stop = next.stopTimestamp
+    this.router.navigate([],{ queryParams: { videoId: next.video.uuid, start, stop } })
+  }
+
+  switchAutoPlayNextVideoPlaylist () {
+    this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist
+    this.setAutoPlayNextVideoPlaylistSwitchText()
+
+    peertubeLocalStorage.setItem(
+      VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST,
+      this.autoPlayNextVideoPlaylist.toString()
+    )
+
+    if (this.auth.isLoggedIn()) {
+      const details = {
+        autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist
+      }
+
+      this.userService.updateMyProfile(details).subscribe(
+        () => {
+          this.auth.refreshUserInformation()
+        },
+        err => this.notifier.error(err.message)
+      )
+    }
+  }
+
+  switchLoopPlaylist () {
+    this.loopPlaylist = !this.loopPlaylist
+    this.setLoopPlaylistSwitchText()
+
+    peertubeSessionStorage.setItem(
+      VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST,
+      this.loopPlaylist.toString()
+    )
+  }
+
+  private setAutoPlayNextVideoPlaylistSwitchText () {
+    this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist
+      ? this.i18n('Stop autoplaying next video')
+      : this.i18n('Autoplay next video')
+  }
+
+  private setLoopPlaylistSwitchText () {
+    this.loopPlaylistSwitchText = this.loopPlaylist
+      ? this.i18n('Stop looping playlist videos')
+      : this.i18n('Loop playlist videos')
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/video-watch-routing.module.ts b/client/src/app/+videos/+video-watch/video-watch-routing.module.ts
new file mode 100644 (file)
index 0000000..d8fecb8
--- /dev/null
@@ -0,0 +1,27 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MetaGuard } from '@ngx-meta/core'
+import { VideoWatchComponent } from './video-watch.component'
+
+const videoWatchRoutes: Routes = [
+  {
+    path: 'playlist/:playlistId',
+    component: VideoWatchComponent,
+    canActivate: [ MetaGuard ]
+  },
+  {
+    path: ':videoId/comments/:commentId',
+    redirectTo: ':videoId'
+  },
+  {
+    path: ':videoId',
+    component: VideoWatchComponent,
+    canActivate: [ MetaGuard ]
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(videoWatchRoutes) ],
+  exports: [ RouterModule ]
+})
+export class VideoWatchRoutingModule {}
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html
new file mode 100644 (file)
index 0000000..0447268
--- /dev/null
@@ -0,0 +1,277 @@
+<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>
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.scss b/client/src/app/+videos/+video-watch/video-watch.component.scss
new file mode 100644 (file)
index 0000000..2e08398
--- /dev/null
@@ -0,0 +1,607 @@
+@import '_variables';
+@import '_mixins';
+@import '_bootstrap-variables';
+@import '_miniature';
+
+$player-factor: 1.7; // 16/9
+$video-info-margin-left: 44px;
+
+@function getPlayerHeight($width){
+  @return calc(#{$width} / #{$player-factor})
+}
+
+@function getPlayerWidth($height){
+  @return calc(#{$height} * #{$player-factor})
+}
+
+@mixin playlist-below-player {
+  width: 100% !important;
+  height: auto !important;
+  max-height: 300px !important;
+  max-width: initial;
+  border-bottom: 1px solid $separator-border-color !important;
+}
+
+.root {
+  &.theater-enabled #video-wrapper {
+    flex-direction: column;
+    justify-content: center;
+
+    #videojs-wrapper {
+      width: 100%;
+    }
+
+    ::ng-deep .video-js {
+      $height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
+
+      height: $height;
+      width: 100%;
+      max-width: initial;
+    }
+
+    my-video-watch-playlist ::ng-deep .playlist {
+      @include playlist-below-player;
+    }
+  }
+}
+
+.blocked-label {
+  font-weight: $font-semibold;
+}
+
+#video-wrapper {
+  background-color: #000;
+  display: flex;
+  justify-content: center;
+
+  #videojs-wrapper {
+    display: flex;
+    justify-content: center;
+    flex-grow: 1;
+  }
+
+  .remote-server-down {
+    color: #fff;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    text-align: center;
+    justify-content: center;
+    background-color: #141313;
+    width: 100%;
+    font-size: 24px;
+    height: 500px;
+
+    @media screen and (max-width: 1000px) {
+      font-size: 20px;
+    }
+
+    @media screen and (max-width: 600px) {
+      font-size: 16px;
+    }
+  }
+
+  ::ng-deep .video-js {
+    width: 100%;
+    max-width: getPlayerWidth(66vh);
+    height: 66vh;
+
+    // VideoJS create an inner video player
+    video {
+      outline: 0;
+      position: relative !important;
+    }
+  }
+
+  @media screen and (max-width: 600px) {
+    .remote-server-down,
+    ::ng-deep .video-js {
+      width: 100vw;
+      height: getPlayerHeight(100vw)
+    }
+  }
+}
+
+.alert {
+  text-align: center;
+  border-radius: 0;
+}
+
+.flex-direction-column {
+  flex-direction: column;
+}
+
+#video-not-found {
+  height: 300px;
+  line-height: 300px;
+  margin-top: 50px;
+  text-align: center;
+  font-weight: $font-semibold;
+  font-size: 15px;
+}
+
+.video-bottom {
+  display: flex;
+  margin-top: 1.5rem;
+
+  .video-info {
+    flex-grow: 1;
+    // Set min width for flex item
+    min-width: 1px;
+    max-width: 100%;
+
+    .video-info-first-row {
+      display: flex;
+
+      & > div:first-child {
+        flex-grow: 1;
+      }
+
+      .video-info-name {
+        margin-right: 30px;
+        min-height: 40px; // Align with the action buttons
+        font-size: 27px;
+        font-weight: $font-semibold;
+        flex-grow: 1;
+      }
+
+      .video-info-first-row-bottom {
+        display: flex;
+        flex-wrap: wrap;
+        align-items: center;
+        width: 100%;
+      }
+
+      .video-info-date-views {
+        align-self: start;
+        margin-bottom: 10px;
+        margin-right: 10px;
+        font-size: 1em;
+      }
+
+      .video-info-channel {
+        font-weight: $font-semibold;
+        font-size: 15px;
+
+        a {
+          @include disable-default-a-behaviour;
+
+          color: pvar(--mainForegroundColor);
+
+          &:hover {
+            opacity: 0.8;
+          }
+
+          img {
+            @include avatar(18px);
+
+            margin: -2px 5px 0 0;
+          }
+        }
+
+        .video-info-channel-left {
+          flex-grow: 1;
+
+          .video-info-channel-left-links {
+            display: flex;
+            flex-direction: column;
+            position: relative;
+            line-height: 1.37;
+
+            a:nth-of-type(2) {
+              font-weight: 500;
+              font-size: 90%;
+            }
+          }
+        }
+
+        my-subscribe-button {
+          margin-left: 5px;
+        }
+      }
+
+      my-feed {
+        margin-left: 5px;
+        margin-top: 1px;
+      }
+
+      .video-actions-rates {
+        margin: 0 0 10px 0;
+        align-items: start;
+        width: max-content;
+        margin-left: auto;
+
+        .video-actions {
+          height: 40px; // Align with the title
+          display: flex;
+          align-items: center;
+
+          .action-button:not(:first-child),
+          .action-dropdown,
+          my-video-actions-dropdown {
+            margin-left: 5px;
+          }
+
+          ::ng-deep.action-button {
+            @include peertube-button;
+            @include button-with-icon(21px, 0, -1px);
+            @include apply-svg-color(pvar(--actionButtonColor));
+
+            font-size: 100%;
+            font-weight: $font-semibold;
+            display: inline-block;
+            padding: 0 10px 0 10px;
+            white-space: nowrap;
+            background-color: transparent !important;
+            color: pvar(--actionButtonColor);
+            text-transform: uppercase;
+
+            &::after {
+              display: none;
+            }
+
+            &:hover {
+              opacity: 0.9;
+            }
+
+            &.action-button-like,
+            &.action-button-dislike {
+              filter: brightness(120%);
+
+              .count {
+                margin-right: 5px;
+              }
+            }
+
+            &.action-button-like.activated {
+              .count {
+                color: pvar(--activatedActionButtonColor);
+              }
+
+              my-global-icon {
+                @include apply-svg-color(pvar(--activatedActionButtonColor));
+              }
+            }
+
+            &.action-button-dislike.activated {
+              .count {
+                color: pvar(--activatedActionButtonColor);
+              }
+
+              my-global-icon {
+                @include apply-svg-color(pvar(--activatedActionButtonColor));
+              }
+            }
+
+            &.action-button-support {
+              color: pvar(--supportButtonColor);
+
+              my-global-icon {
+                @include apply-svg-color(pvar(--supportButtonColor));
+              }
+            }
+
+            &.action-button-support {
+              my-global-icon {
+                ::ng-deep path:first-child {
+                  fill: pvar(--supportButtonHeartColor) !important;
+                }
+              }
+            }
+
+            &.action-button-save {
+              my-global-icon {
+                top: 0 !important;
+                right: -1px;
+              }
+            }
+
+            .icon-text {
+              margin-left: 3px;
+            }
+          }
+        }
+
+        .video-info-likes-dislikes-bar-outer-container {
+          position: relative;
+        }
+
+        .video-info-likes-dislikes-bar-inner-container {
+          position: absolute;
+          height: 20px;
+        }
+
+        .video-info-likes-dislikes-bar {
+          $likes-bar-height: 2px;
+          height: $likes-bar-height;
+          margin-top: -$likes-bar-height;
+          width: 120px;
+          background-color: #ccc;
+          position: relative;
+          top: 10px;
+
+          .likes-bar {
+            height: 100%;
+            background-color: #909090;
+
+            &.liked {
+              background-color: pvar(--activatedActionButtonColor);
+            }
+          }
+        }
+      }
+    }
+
+    .video-info-description {
+      margin: 20px 0;
+      margin-left: $video-info-margin-left;
+      font-size: 15px;
+
+      .video-info-description-html {
+        @include peertube-word-wrap;
+
+        /deep/ a {
+          text-decoration: none;
+        }
+      }
+
+      .glyphicon, .description-loading {
+        margin-left: 3px;
+      }
+
+      .description-loading {
+        display: inline-block;
+      }
+
+      .video-info-description-more {
+        cursor: pointer;
+        font-weight: $font-semibold;
+        color: pvar(--greyForegroundColor);
+        font-size: 14px;
+
+        .glyphicon {
+          position: relative;
+          top: 2px;
+        }
+      }
+    }
+
+    .video-attributes {
+      margin-left: $video-info-margin-left;
+    }
+
+    .video-attributes .video-attribute {
+      font-size: 13px;
+      display: block;
+      margin-bottom: 12px;
+
+      .video-attribute-label {
+        min-width: 142px;
+        padding-right: 5px;
+        display: inline-block;
+        color: pvar(--greyForegroundColor);
+        font-weight: $font-bold;
+      }
+
+      a.video-attribute-value {
+        @include disable-default-a-behaviour;
+        color: pvar(--mainForegroundColor);
+
+        &:hover {
+          opacity: 0.9;
+        }
+      }
+
+      &.video-attribute-tags {
+        .video-attribute-value:not(:nth-child(2)) {
+          &::before {
+            content: ', '
+          }
+        }
+      }
+    }
+  }
+
+  ::ng-deep .other-videos {
+    padding-left: 15px;
+    min-width: $video-miniature-width;
+
+    @media screen and (min-width: 1800px - (3* $video-miniature-width)) {
+      width: min-content;
+    }
+
+    .title-page {
+      margin: 0 !important;
+    }
+
+    .video-miniature {
+      display: flex;
+      width: max-content;
+      height: 100%;
+      padding-bottom: 20px;
+      flex-wrap: wrap;
+    }
+
+    .video-bottom {
+      @media screen and (max-width: 1800px - (3* $video-miniature-width)) {
+        margin-left: 1rem;
+      }
+      @media screen and (max-width: 500px) {
+        margin-left: 0;
+        margin-top: .5rem;
+      }
+    }
+  }
+}
+
+my-video-comments {
+  display: inline-block;
+  width: 100%;
+  margin-bottom: 20px;
+}
+
+// If the view is not expanded, take into account the menu
+.privacy-concerns {
+  z-index: z(dropdown) + 1;
+  width: calc(100% - #{$menu-width});
+}
+
+@media screen and (max-width: $small-view) {
+  .privacy-concerns {
+    margin-left: $menu-width - 15px; // Menu is absolute
+  }
+}
+
+:host-context(.expanded) {
+  .privacy-concerns {
+    width: 100%;
+    margin-left: -15px;
+  }
+}
+
+.privacy-concerns {
+  position: fixed;
+  bottom: 0;
+  z-index: z(privacymsg);
+
+  padding: 5px 15px;
+
+  display: flex;
+  flex-wrap: nowrap;
+  align-items: center;
+  justify-content: space-between;
+  background-color: rgba(0, 0, 0, 0.9);
+  color: #fff;
+
+  .privacy-concerns-text {
+    margin: 0 5px;
+  }
+
+  a {
+    @include disable-default-a-behaviour;
+
+    color: pvar(--mainColor);
+    transition: color 0.3s;
+
+    &:hover {
+      color: #fff;
+    }
+  }
+
+  .privacy-concerns-button {
+    padding: 5px 8px 5px 7px;
+    margin-left: auto;
+    border-radius: 3px;
+    white-space: nowrap;
+    cursor: pointer;
+    transition: background-color 0.3s;
+    font-weight: $font-semibold;
+
+    &:hover {
+      background-color: #000;
+    }
+  }
+
+  .privacy-concerns-okay {
+    background-color: pvar(--mainColor);
+    margin-left: 10px;
+  }
+}
+
+@media screen and (max-width: 1600px) {
+  .video-bottom .video-info .video-attributes .video-attribute {
+    margin-bottom: 5px;
+  }
+}
+
+@media screen and (max-width: 1300px) {
+  .privacy-concerns {
+    font-size: 12px;
+    padding: 2px 5px;
+
+    .privacy-concerns-text {
+      margin: 0;
+    }
+  }
+}
+
+@media screen and (max-width: 1100px) {
+  #video-wrapper {
+    flex-direction: column;
+    justify-content: center;
+
+    my-video-watch-playlist ::ng-deep .playlist {
+      @include playlist-below-player;
+    }
+  }
+
+  .video-bottom {
+    flex-direction: column;
+
+    ::ng-deep .other-videos {
+      padding-left: 0 !important;
+
+      ::ng-deep .video-miniature  {
+        flex-direction: row;
+        width: auto;
+      }
+    }
+  }
+}
+
+@media screen and (max-width: 600px) {
+  .video-bottom {
+    margin-top: 20px !important;
+    padding-bottom: 20px !important;
+
+    .video-info {
+      padding: 0;
+
+      .video-info-first-row {
+
+        .video-info-name {
+          font-size: 20px;
+          height: auto;
+        }
+      }
+    }
+  }
+
+  ::ng-deep .other-videos .video-miniature  {
+    flex-direction: column;
+  }
+
+  .privacy-concerns {
+    width: 100%;
+
+    strong {
+      display: none;
+    }
+  }
+}
+
+@media screen and (max-width: 450px) {
+  .video-bottom {
+    .action-button .icon-text {
+      display: none !important;
+    }
+
+    .video-info .video-info-first-row {
+      .video-info-name {
+        font-size: 18px;
+      }
+
+      .video-info-date-views {
+        font-size: 14px;
+      }
+
+      .video-actions-rates {
+        margin-top: 10px;
+      }
+    }
+
+    .video-info-description {
+      font-size: 14px !important;
+    }
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
new file mode 100644 (file)
index 0000000..5b0b34c
--- /dev/null
@@ -0,0 +1,782 @@
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
+import { forkJoin, Observable, Subscription } from 'rxjs'
+import { catchError } from 'rxjs/operators'
+import { PlatformLocation } from '@angular/common'
+import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { RedirectService } from '@app/core/routing/redirect.service'
+import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers'
+import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
+import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
+import { MetaService } from '@ngx-meta/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
+import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
+import {
+  CustomizationOptions,
+  P2PMediaLoaderOptions,
+  PeertubePlayerManager,
+  PeertubePlayerManagerOptions,
+  PlayerMode,
+  videojs
+} from '../../../assets/player/peertube-player-manager'
+import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
+import { environment } from '../../../environments/environment'
+import { VideoShareComponent } from './modal/video-share.component'
+import { VideoSupportComponent } from './modal/video-support.component'
+import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
+
+@Component({
+  selector: 'my-video-watch',
+  templateUrl: './video-watch.component.html',
+  styleUrls: [ './video-watch.component.scss' ]
+})
+export class VideoWatchComponent implements OnInit, OnDestroy {
+  private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern'
+
+  @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
+  @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
+  @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent
+  @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
+
+  player: any
+  playerElement: HTMLVideoElement
+  theaterEnabled = false
+  userRating: UserVideoRateType = null
+  descriptionLoading = false
+
+  video: VideoDetails = null
+  videoCaptions: VideoCaption[] = []
+
+  playlist: VideoPlaylist = null
+
+  completeDescriptionShown = false
+  completeVideoDescription: string
+  shortVideoDescription: string
+  videoHTMLDescription = ''
+  likesBarTooltipText = ''
+  hasAlreadyAcceptedPrivacyConcern = false
+  remoteServerDown = false
+  hotkeys: Hotkey[] = []
+
+  tooltipLike = ''
+  tooltipDislike = ''
+  tooltipSupport = ''
+  tooltipSaveToPlaylist = ''
+
+  private nextVideoUuid = ''
+  private nextVideoTitle = ''
+  private currentTime: number
+  private paramsSub: Subscription
+  private queryParamsSub: Subscription
+  private configSub: Subscription
+
+  private serverConfig: ServerConfig
+
+  constructor (
+    private elementRef: ElementRef,
+    private changeDetector: ChangeDetectorRef,
+    private route: ActivatedRoute,
+    private router: Router,
+    private videoService: VideoService,
+    private playlistService: VideoPlaylistService,
+    private confirmService: ConfirmService,
+    private metaService: MetaService,
+    private authService: AuthService,
+    private userService: UserService,
+    private serverService: ServerService,
+    private restExtractor: RestExtractor,
+    private notifier: Notifier,
+    private markdownService: MarkdownService,
+    private zone: NgZone,
+    private redirectService: RedirectService,
+    private videoCaptionService: VideoCaptionService,
+    private i18n: I18n,
+    private hotkeysService: HotkeysService,
+    private hooks: HooksService,
+    private location: PlatformLocation,
+    @Inject(LOCALE_ID) private localeId: string
+  ) {
+    this.tooltipLike = this.i18n('Like this video')
+    this.tooltipDislike = this.i18n('Dislike this video')
+    this.tooltipSupport = this.i18n('Support options for this video')
+    this.tooltipSaveToPlaylist = this.i18n('Save to playlist')
+  }
+
+  get user () {
+    return this.authService.getUser()
+  }
+
+  get anonymousUser () {
+    return this.userService.getAnonymousUser()
+  }
+
+  async ngOnInit () {
+    this.serverConfig = this.serverService.getTmpConfig()
+
+    this.configSub = this.serverService.getConfig()
+        .subscribe(config => {
+          this.serverConfig = config
+
+          if (
+            isWebRTCDisabled() ||
+            this.serverConfig.tracker.enabled === false ||
+            getStoredP2PEnabled() === false ||
+            peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true'
+          ) {
+            this.hasAlreadyAcceptedPrivacyConcern = true
+          }
+        })
+
+    this.paramsSub = this.route.params.subscribe(routeParams => {
+      const videoId = routeParams[ 'videoId' ]
+      if (videoId) this.loadVideo(videoId)
+
+      const playlistId = routeParams[ 'playlistId' ]
+      if (playlistId) this.loadPlaylist(playlistId)
+    })
+
+    this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => {
+      const videoId = queryParams[ 'videoId' ]
+      if (videoId) this.loadVideo(videoId)
+
+      const start = queryParams[ 'start' ]
+      if (this.player && start) this.player.currentTime(parseInt(start, 10))
+    })
+
+    this.initHotkeys()
+
+    this.theaterEnabled = getStoredTheater()
+
+    this.hooks.runAction('action:video-watch.init', 'video-watch')
+  }
+
+  ngOnDestroy () {
+    this.flushPlayer()
+
+    // Unsubscribe subscriptions
+    if (this.paramsSub) this.paramsSub.unsubscribe()
+    if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
+
+    // Unbind hotkeys
+    this.hotkeysService.remove(this.hotkeys)
+  }
+
+  setLike () {
+    if (this.isUserLoggedIn() === false) return
+
+    // Already liked this video
+    if (this.userRating === 'like') this.setRating('none')
+    else this.setRating('like')
+  }
+
+  setDislike () {
+    if (this.isUserLoggedIn() === false) return
+
+    // Already disliked this video
+    if (this.userRating === 'dislike') this.setRating('none')
+    else this.setRating('dislike')
+  }
+
+  getRatePopoverText () {
+    if (this.isUserLoggedIn()) return undefined
+
+    return this.i18n('You need to be connected to rate this content.')
+  }
+
+  showMoreDescription () {
+    if (this.completeVideoDescription === undefined) {
+      return this.loadCompleteDescription()
+    }
+
+    this.updateVideoDescription(this.completeVideoDescription)
+    this.completeDescriptionShown = true
+  }
+
+  showLessDescription () {
+    this.updateVideoDescription(this.shortVideoDescription)
+    this.completeDescriptionShown = false
+  }
+
+  loadCompleteDescription () {
+    this.descriptionLoading = true
+
+    this.videoService.loadCompleteDescription(this.video.descriptionPath)
+        .subscribe(
+          description => {
+            this.completeDescriptionShown = true
+            this.descriptionLoading = false
+
+            this.shortVideoDescription = this.video.description
+            this.completeVideoDescription = description
+
+            this.updateVideoDescription(this.completeVideoDescription)
+          },
+
+          error => {
+            this.descriptionLoading = false
+            this.notifier.error(error.message)
+          }
+        )
+  }
+
+  showSupportModal () {
+    this.pausePlayer()
+
+    this.videoSupportModal.show()
+  }
+
+  showShareModal () {
+    this.pausePlayer()
+
+    this.videoShareModal.show(this.currentTime)
+  }
+
+  isUserLoggedIn () {
+    return this.authService.isLoggedIn()
+  }
+
+  getVideoTags () {
+    if (!this.video || Array.isArray(this.video.tags) === false) return []
+
+    return this.video.tags
+  }
+
+  onRecommendations (videos: Video[]) {
+    if (videos.length > 0) {
+      // The recommended videos's first element should be the next video
+      const video = videos[0]
+      this.nextVideoUuid = video.uuid
+      this.nextVideoTitle = video.name
+    }
+  }
+
+  onModalOpened () {
+    this.pausePlayer()
+  }
+
+  onVideoRemoved () {
+    this.redirectService.redirectToHomepage()
+  }
+
+  declinedPrivacyConcern () {
+    peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false')
+    this.hasAlreadyAcceptedPrivacyConcern = false
+  }
+
+  acceptedPrivacyConcern () {
+    peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true')
+    this.hasAlreadyAcceptedPrivacyConcern = true
+  }
+
+  isVideoToTranscode () {
+    return this.video && this.video.state.id === VideoState.TO_TRANSCODE
+  }
+
+  isVideoToImport () {
+    return this.video && this.video.state.id === VideoState.TO_IMPORT
+  }
+
+  hasVideoScheduledPublication () {
+    return this.video && this.video.scheduledUpdate !== undefined
+  }
+
+  isVideoBlur (video: Video) {
+    return video.isVideoNSFWForUser(this.user, this.serverConfig)
+  }
+
+  isAutoPlayEnabled () {
+    return (
+      (this.user && this.user.autoPlayNextVideo) ||
+      this.anonymousUser.autoPlayNextVideo
+    )
+  }
+
+  handleTimestampClicked (timestamp: number) {
+    if (this.player) this.player.currentTime(timestamp)
+    scrollToTop()
+  }
+
+  isPlaylistAutoPlayEnabled () {
+    return (
+      (this.user && this.user.autoPlayNextVideoPlaylist) ||
+      this.anonymousUser.autoPlayNextVideoPlaylist
+    )
+  }
+
+  private loadVideo (videoId: string) {
+    // Video did not change
+    if (this.video && this.video.uuid === videoId) return
+
+    if (this.player) this.player.pause()
+
+    const videoObs = this.hooks.wrapObsFun(
+      this.videoService.getVideo.bind(this.videoService),
+      { videoId },
+      'video-watch',
+      'filter:api.video-watch.video.get.params',
+      'filter:api.video-watch.video.get.result'
+    )
+
+    // Video did change
+    forkJoin([
+      videoObs,
+      this.videoCaptionService.listCaptions(videoId)
+    ])
+      .pipe(
+        // If 401, the video is private or blocked so redirect to 404
+        catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
+      )
+      .subscribe(([ video, captionsResult ]) => {
+        const queryParams = this.route.snapshot.queryParams
+
+        const urlOptions = {
+          startTime: queryParams.start,
+          stopTime: queryParams.stop,
+
+          muted: queryParams.muted,
+          loop: queryParams.loop,
+          subtitle: queryParams.subtitle,
+
+          playerMode: queryParams.mode,
+          peertubeLink: false
+        }
+
+        this.onVideoFetched(video, captionsResult.data, urlOptions)
+            .catch(err => this.handleError(err))
+      })
+  }
+
+  private loadPlaylist (playlistId: string) {
+    // Playlist did not change
+    if (this.playlist && this.playlist.uuid === playlistId) return
+
+    this.playlistService.getVideoPlaylist(playlistId)
+      .pipe(
+        // If 401, the video is private or blocked so redirect to 404
+        catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
+      )
+      .subscribe(playlist => {
+        this.playlist = playlist
+
+        const videoId = this.route.snapshot.queryParams['videoId']
+        this.videoWatchPlaylist.loadPlaylistElements(playlist, !videoId)
+      })
+  }
+
+  private updateVideoDescription (description: string) {
+    this.video.description = description
+    this.setVideoDescriptionHTML()
+      .catch(err => console.error(err))
+  }
+
+  private async setVideoDescriptionHTML () {
+    const html = await this.markdownService.textMarkdownToHTML(this.video.description)
+    this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
+  }
+
+  private setVideoLikesBarTooltipText () {
+    this.likesBarTooltipText = this.i18n('{{likesNumber}} likes / {{dislikesNumber}} dislikes', {
+      likesNumber: this.video.likes,
+      dislikesNumber: this.video.dislikes
+    })
+  }
+
+  private handleError (err: any) {
+    const errorMessage: string = typeof err === 'string' ? err : err.message
+    if (!errorMessage) return
+
+    // Display a message in the video player instead of a notification
+    if (errorMessage.indexOf('from xs param') !== -1) {
+      this.flushPlayer()
+      this.remoteServerDown = true
+      this.changeDetector.detectChanges()
+
+      return
+    }
+
+    this.notifier.error(errorMessage)
+  }
+
+  private checkUserRating () {
+    // Unlogged users do not have ratings
+    if (this.isUserLoggedIn() === false) return
+
+    this.videoService.getUserVideoRating(this.video.id)
+        .subscribe(
+          ratingObject => {
+            if (ratingObject) {
+              this.userRating = ratingObject.rating
+            }
+          },
+
+          err => this.notifier.error(err.message)
+        )
+  }
+
+  private async onVideoFetched (
+    video: VideoDetails,
+    videoCaptions: VideoCaption[],
+    urlOptions: CustomizationOptions & { playerMode: PlayerMode }
+  ) {
+    this.video = video
+    this.videoCaptions = videoCaptions
+
+    // Re init attributes
+    this.descriptionLoading = false
+    this.completeDescriptionShown = false
+    this.remoteServerDown = false
+    this.currentTime = undefined
+
+    this.videoWatchPlaylist.updatePlaylistIndex(video)
+
+    if (this.isVideoBlur(this.video)) {
+      const res = await this.confirmService.confirm(
+        this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
+        this.i18n('Mature or explicit content')
+      )
+      if (res === false) return this.location.back()
+    }
+
+    // Flush old player if needed
+    this.flushPlayer()
+
+    // Build video element, because videojs removes it on dispose
+    const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
+    this.playerElement = document.createElement('video')
+    this.playerElement.className = 'video-js vjs-peertube-skin'
+    this.playerElement.setAttribute('playsinline', 'true')
+    playerElementWrapper.appendChild(this.playerElement)
+
+    const params = {
+      video: this.video,
+      videoCaptions,
+      urlOptions,
+      user: this.user
+    }
+    const { playerMode, playerOptions } = await this.hooks.wrapFun(
+      this.buildPlayerManagerOptions.bind(this),
+      params,
+      'video-watch',
+      'filter:internal.video-watch.player.build-options.params',
+      'filter:internal.video-watch.player.build-options.result'
+    )
+
+    this.zone.runOutsideAngular(async () => {
+      this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player)
+      this.player.focus()
+
+      this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
+
+      this.player.on('timeupdate', () => {
+        this.currentTime = Math.floor(this.player.currentTime())
+      })
+
+      /**
+       * replaces this.player.one('ended')
+       * 'condition()': true to make the upnext functionality trigger,
+       *                false to disable the upnext functionality
+       * go to the next video in 'condition()' if you don't want of the timer.
+       * 'next': function triggered at the end of the timer.
+       * 'suspended': function used at each clic of the timer checking if we need
+       * to reset progress and wait until 'suspended' becomes truthy again.
+       */
+      this.player.upnext({
+        timeout: 10000, // 10s
+        headText: this.i18n('Up Next'),
+        cancelText: this.i18n('Cancel'),
+        suspendedText: this.i18n('Autoplay is suspended'),
+        getTitle: () => this.nextVideoTitle,
+        next: () => this.zone.run(() => this.autoplayNext()),
+        condition: () => {
+          if (this.playlist) {
+            if (this.isPlaylistAutoPlayEnabled()) {
+              // upnext will not trigger, and instead the next video will play immediately
+              this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
+            }
+          } else if (this.isAutoPlayEnabled()) {
+            return true // upnext will trigger
+          }
+          return false // upnext will not trigger, and instead leave the video stopping
+        },
+        suspended: () => {
+          return (
+            !isXPercentInViewport(this.player.el(), 80) ||
+            !document.getElementById('content').contains(document.activeElement)
+          )
+        }
+      })
+
+      this.player.one('stopped', () => {
+        if (this.playlist) {
+          if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
+        }
+      })
+
+      this.player.on('theaterChange', (_: any, enabled: boolean) => {
+        this.zone.run(() => this.theaterEnabled = enabled)
+      })
+
+      this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { player: this.player })
+    })
+
+    this.setVideoDescriptionHTML()
+    this.setVideoLikesBarTooltipText()
+
+    this.setOpenGraphTags()
+    this.checkUserRating()
+
+    this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', { videojs })
+  }
+
+  private autoplayNext () {
+    if (this.playlist) {
+      this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
+    } else if (this.nextVideoUuid) {
+      this.router.navigate([ '/videos/watch', this.nextVideoUuid ])
+    }
+  }
+
+  private setRating (nextRating: UserVideoRateType) {
+    const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable<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)
+  }
+}
diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts
new file mode 100644 (file)
index 0000000..421170d
--- /dev/null
@@ -0,0 +1,65 @@
+import { QRCodeModule } from 'angularx-qrcode'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { RecommendationsModule } from './recommendations/recommendations.module'
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
+import { VideoCommentAddComponent } from './comment/video-comment-add.component'
+import { VideoCommentComponent } from './comment/video-comment.component'
+import { VideoCommentService } from './comment/video-comment.service'
+import { VideoCommentsComponent } from './comment/video-comments.component'
+import { VideoShareComponent } from './modal/video-share.component'
+import { VideoSupportComponent } from './modal/video-support.component'
+import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
+import { VideoDurationPipe } from './video-duration-formatter.pipe'
+import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
+import { VideoWatchRoutingModule } from './video-watch-routing.module'
+import { VideoWatchComponent } from './video-watch.component'
+
+@NgModule({
+  imports: [
+    VideoWatchRoutingModule,
+    NgbTooltipModule,
+    QRCodeModule,
+    RecommendationsModule,
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedVideoMiniatureModule,
+    SharedVideoPlaylistModule,
+    SharedUserSubscriptionModule,
+    SharedModerationModule,
+    SharedGlobalIconModule
+  ],
+
+  declarations: [
+    VideoWatchComponent,
+    VideoWatchPlaylistComponent,
+
+    VideoShareComponent,
+    VideoSupportComponent,
+    VideoCommentsComponent,
+    VideoCommentAddComponent,
+    VideoCommentComponent,
+
+    TimestampRouteTransformerDirective,
+    VideoDurationPipe,
+    TimestampRouteTransformerDirective
+  ],
+
+  exports: [
+    VideoWatchComponent,
+
+    TimestampRouteTransformerDirective
+  ],
+
+  providers: [
+    VideoCommentService
+  ]
+})
+export class VideoWatchModule { }
diff --git a/client/src/app/+videos/index.ts b/client/src/app/+videos/index.ts
new file mode 100644 (file)
index 0000000..028a585
--- /dev/null
@@ -0,0 +1 @@
+export * from './videos.module'
diff --git a/client/src/app/+videos/video-list/index.ts b/client/src/app/+videos/video-list/index.ts
new file mode 100644 (file)
index 0000000..af1bd58
--- /dev/null
@@ -0,0 +1,5 @@
+export * from './overview'
+export * from './video-local.component'
+export * from './video-recently-added.component'
+export * from './video-trending.component'
+export * from './video-most-liked.component'
diff --git a/client/src/app/+videos/video-list/overview/index.ts b/client/src/app/+videos/video-list/overview/index.ts
new file mode 100644 (file)
index 0000000..e6cfa48
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './overview.service'
+export * from './video-overview.component'
+export * from './videos-overview.model'
diff --git a/client/src/app/+videos/video-list/overview/overview.service.ts b/client/src/app/+videos/video-list/overview/overview.service.ts
new file mode 100644 (file)
index 0000000..4458454
--- /dev/null
@@ -0,0 +1,78 @@
+import { forkJoin, Observable, of } from 'rxjs'
+import { catchError, map, switchMap, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, ServerService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { peertubeTranslate, VideosOverview as VideosOverviewServer } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { VideosOverview } from './videos-overview.model'
+
+@Injectable()
+export class OverviewService {
+  static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor,
+    private videosService: VideoService,
+    private serverService: ServerService
+  ) {}
+
+  getVideosOverview (page: number): Observable<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)
+      )
+  }
+
+}
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.html b/client/src/app/+videos/video-list/overview/video-overview.component.html
new file mode 100644 (file)
index 0000000..ca986c6
--- /dev/null
@@ -0,0 +1,52 @@
+<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>
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.scss b/client/src/app/+videos/video-list/overview/video-overview.component.scss
new file mode 100644 (file)
index 0000000..c1d1018
--- /dev/null
@@ -0,0 +1,16 @@
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.section-title {
+  // make the element span a full grid row within .videos grid
+  grid-column: 1 / -1;
+}
+
+.margin-content {
+  @include fluid-videos-miniature-layout;
+}
+
+.section {
+  @include miniature-rows;
+}
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.ts b/client/src/app/+videos/video-list/overview/video-overview.component.ts
new file mode 100644 (file)
index 0000000..b3be1d7
--- /dev/null
@@ -0,0 +1,94 @@
+import { Subject } from 'rxjs'
+import { Component, OnInit } from '@angular/core'
+import { Notifier, ScreenService, User, UserService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { OverviewService } from './overview.service'
+import { VideosOverview } from './videos-overview.model'
+
+@Component({
+  selector: 'my-video-overview',
+  templateUrl: './video-overview.component.html',
+  styleUrls: [ './video-overview.component.scss' ]
+})
+export class VideoOverviewComponent implements OnInit {
+  onDataSubject = new Subject<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
+          }
+        )
+  }
+}
diff --git a/client/src/app/+videos/video-list/overview/videos-overview.model.ts b/client/src/app/+videos/video-list/overview/videos-overview.model.ts
new file mode 100644 (file)
index 0000000..6765ad9
--- /dev/null
@@ -0,0 +1,20 @@
+import { Video } from '@app/shared/shared-main'
+import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '@shared/models'
+
+export class VideosOverview implements VideosOverviewServer {
+  channels: {
+    channel: VideoChannelSummary
+    videos: Video[]
+  }[]
+
+  categories: {
+    category: VideoConstant<number>
+    videos: Video[]
+  }[]
+
+  tags: {
+    tag: string
+    videos: Video[]
+  }[]
+  [key: string]: any
+}
diff --git a/client/src/app/+videos/video-list/video-local.component.ts b/client/src/app/+videos/video-list/video-local.component.ts
new file mode 100644 (file)
index 0000000..b4c71ac
--- /dev/null
@@ -0,0 +1,86 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserRight, VideoFilter, VideoSortField } from '@shared/models'
+
+@Component({
+  selector: 'my-videos-local',
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
+})
+export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy {
+  titlePage: string
+  sort = '-publishedAt' as VideoSortField
+  filter: VideoFilter = 'local'
+
+  useUserVideoPreferences = true
+
+  constructor (
+    protected i18n: I18n,
+    protected router: Router,
+    protected serverService: ServerService,
+    protected route: ActivatedRoute,
+    protected notifier: Notifier,
+    protected authService: AuthService,
+    protected userService: UserService,
+    protected screenService: ScreenService,
+    protected storageService: LocalStorageService,
+    private videoService: VideoService,
+    private hooks: HooksService
+  ) {
+    super()
+
+    this.titlePage = i18n('Local videos')
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+
+    if (this.authService.isLoggedIn()) {
+      const user = this.authService.getUser()
+      this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS)
+    }
+
+    this.generateSyndicationList()
+  }
+
+  ngOnDestroy () {
+    super.ngOnDestroy()
+  }
+
+  getVideosObservable (page: number) {
+    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+    const params = {
+      videoPagination: newPagination,
+      sort: this.sort,
+      filter: this.filter,
+      categoryOneOf: this.categoryOneOf,
+      languageOneOf: this.languageOneOf,
+      nsfwPolicy: this.nsfwPolicy,
+      skipCount: true
+    }
+
+    return this.hooks.wrapObsFun(
+      this.videoService.getVideos.bind(this.videoService),
+      params,
+      'common',
+      'filter:api.local-videos.videos.list.params',
+      'filter:api.local-videos.videos.list.result'
+    )
+  }
+
+  generateSyndicationList () {
+    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf)
+  }
+
+  toggleModerationDisplay () {
+    this.filter = this.filter === 'local' ? 'all-local' as 'all-local' : 'local' as 'local'
+
+    this.reloadVideos()
+  }
+}
diff --git a/client/src/app/+videos/video-list/video-most-liked.component.ts b/client/src/app/+videos/video-list/video-most-liked.component.ts
new file mode 100644 (file)
index 0000000..ca14851
--- /dev/null
@@ -0,0 +1,70 @@
+import { Component, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
+
+@Component({
+  selector: 'my-videos-most-liked',
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
+})
+export class VideoMostLikedComponent extends AbstractVideoList implements OnInit {
+  titlePage: string
+  defaultSort: VideoSortField = '-likes'
+
+  useUserVideoPreferences = true
+
+  constructor (
+    protected i18n: I18n,
+    protected router: Router,
+    protected serverService: ServerService,
+    protected route: ActivatedRoute,
+    protected notifier: Notifier,
+    protected authService: AuthService,
+    protected userService: UserService,
+    protected screenService: ScreenService,
+    protected storageService: LocalStorageService,
+    private videoService: VideoService,
+    private hooks: HooksService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+
+    this.generateSyndicationList()
+
+    this.titlePage = this.i18n('Most liked videos')
+    this.titleTooltip = this.i18n('Videos that have the higher number of likes.')
+  }
+
+  getVideosObservable (page: number) {
+    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+    const params = {
+      videoPagination: newPagination,
+      sort: this.sort,
+      categoryOneOf: this.categoryOneOf,
+      languageOneOf: this.languageOneOf,
+      nsfwPolicy: this.nsfwPolicy,
+      skipCount: true
+    }
+
+    return this.hooks.wrapObsFun(
+      this.videoService.getVideos.bind(this.videoService),
+      params,
+      'common',
+      'filter:api.most-liked-videos.videos.list.params',
+      'filter:api.most-liked-videos.videos.list.result'
+    )
+  }
+
+  generateSyndicationList () {
+    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
+  }
+}
diff --git a/client/src/app/+videos/video-list/video-recently-added.component.ts b/client/src/app/+videos/video-list/video-recently-added.component.ts
new file mode 100644 (file)
index 0000000..c939513
--- /dev/null
@@ -0,0 +1,74 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
+
+@Component({
+  selector: 'my-videos-recently-added',
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
+})
+export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy {
+  titlePage: string
+  sort: VideoSortField = '-publishedAt'
+  groupByDate = true
+
+  useUserVideoPreferences = true
+
+  constructor (
+    protected i18n: I18n,
+    protected route: ActivatedRoute,
+    protected serverService: ServerService,
+    protected router: Router,
+    protected notifier: Notifier,
+    protected authService: AuthService,
+    protected userService: UserService,
+    protected screenService: ScreenService,
+    protected storageService: LocalStorageService,
+    private videoService: VideoService,
+    private hooks: HooksService
+  ) {
+    super()
+
+    this.titlePage = i18n('Recently added')
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+
+    this.generateSyndicationList()
+  }
+
+  ngOnDestroy () {
+    super.ngOnDestroy()
+  }
+
+  getVideosObservable (page: number) {
+    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+    const params = {
+      videoPagination: newPagination,
+      sort: this.sort,
+      categoryOneOf: this.categoryOneOf,
+      languageOneOf: this.languageOneOf,
+      nsfwPolicy: this.nsfwPolicy,
+      skipCount: true
+    }
+
+    return this.hooks.wrapObsFun(
+      this.videoService.getVideos.bind(this.videoService),
+      params,
+      'common',
+      'filter:api.recently-added-videos.videos.list.params',
+      'filter:api.recently-added-videos.videos.list.result'
+    )
+  }
+
+  generateSyndicationList () {
+    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
+  }
+}
diff --git a/client/src/app/+videos/video-list/video-trending.component.ts b/client/src/app/+videos/video-list/video-trending.component.ts
new file mode 100644 (file)
index 0000000..10eab18
--- /dev/null
@@ -0,0 +1,87 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
+
+@Component({
+  selector: 'my-videos-trending',
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
+})
+export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
+  titlePage: string
+  defaultSort: VideoSortField = '-trending'
+
+  useUserVideoPreferences = true
+
+  constructor (
+    protected i18n: I18n,
+    protected router: Router,
+    protected serverService: ServerService,
+    protected route: ActivatedRoute,
+    protected notifier: Notifier,
+    protected authService: AuthService,
+    protected userService: UserService,
+    protected screenService: ScreenService,
+    protected storageService: LocalStorageService,
+    private videoService: VideoService,
+    private hooks: HooksService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+
+    this.generateSyndicationList()
+
+    this.serverService.getConfig().subscribe(
+      config => {
+        const trendingDays = config.trending.videos.intervalDays
+
+        if (trendingDays === 1) {
+          this.titlePage = this.i18n('Trending for the last 24 hours')
+          this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours')
+        } else {
+          this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays })
+          this.titleTooltip = this.i18n(
+            'Trending videos are those totalizing the greatest number of views during the last {{days}} days',
+            { days: trendingDays }
+          )
+        }
+      })
+  }
+
+  ngOnDestroy () {
+    super.ngOnDestroy()
+  }
+
+  getVideosObservable (page: number) {
+    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+    const params = {
+      videoPagination: newPagination,
+      sort: this.sort,
+      categoryOneOf: this.categoryOneOf,
+      languageOneOf: this.languageOneOf,
+      nsfwPolicy: this.nsfwPolicy,
+      skipCount: true
+    }
+
+    return this.hooks.wrapObsFun(
+      this.videoService.getVideos.bind(this.videoService),
+      params,
+      'common',
+      'filter:api.trending-videos.videos.list.params',
+      'filter:api.trending-videos.videos.list.result'
+    )
+  }
+
+  generateSyndicationList () {
+    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
+  }
+}
diff --git a/client/src/app/+videos/video-list/video-user-subscriptions.component.ts b/client/src/app/+videos/video-list/video-user-subscriptions.component.ts
new file mode 100644 (file)
index 0000000..41ad9b2
--- /dev/null
@@ -0,0 +1,75 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
+import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
+
+@Component({
+  selector: 'my-videos-user-subscriptions',
+  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
+})
+export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
+  titlePage: string
+  sort = '-publishedAt' as VideoSortField
+  ownerDisplayType: OwnerDisplayType = 'auto'
+  groupByDate = true
+
+  constructor (
+    protected i18n: I18n,
+    protected router: Router,
+    protected serverService: ServerService,
+    protected route: ActivatedRoute,
+    protected notifier: Notifier,
+    protected authService: AuthService,
+    protected userService: UserService,
+    protected screenService: ScreenService,
+    protected storageService: LocalStorageService,
+    private userSubscription: UserSubscriptionService,
+    private videoService: VideoService,
+    private hooks: HooksService
+  ) {
+    super()
+
+    this.titlePage = i18n('Videos from your subscriptions')
+    this.actions.push({
+      routerLink: '/my-account/subscriptions',
+      label: i18n('Subscriptions'),
+      iconName: 'cog'
+    })
+  }
+
+  ngOnInit () {
+    super.ngOnInit()
+  }
+
+  ngOnDestroy () {
+    super.ngOnDestroy()
+  }
+
+  getVideosObservable (page: number) {
+    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+    const params = {
+      videoPagination: newPagination,
+      sort: this.sort,
+      skipCount: true
+    }
+
+    return this.hooks.wrapObsFun(
+      this.userSubscription.getUserSubscriptionVideos.bind(this.userSubscription),
+      params,
+      'common',
+      'filter:api.user-subscriptions-videos.videos.list.params',
+      'filter:api.user-subscriptions-videos.videos.list.result'
+    )
+  }
+
+  generateSyndicationList () {
+    // not implemented yet
+  }
+}
diff --git a/client/src/app/+videos/videos-routing.module.ts b/client/src/app/+videos/videos-routing.module.ts
new file mode 100644 (file)
index 0000000..e0e877f
--- /dev/null
@@ -0,0 +1,125 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MetaGuard } from '@ngx-meta/core'
+import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
+import { VideoLocalComponent } from './video-list/video-local.component'
+import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
+import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
+import { VideoTrendingComponent } from './video-list/video-trending.component'
+import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
+import { VideosComponent } from './videos.component'
+
+const videosRoutes: Routes = [
+  {
+    path: '',
+    component: VideosComponent,
+    canActivateChild: [ MetaGuard ],
+    children: [
+      {
+        path: 'overview',
+        component: VideoOverviewComponent,
+        data: {
+          meta: {
+            title: 'Discover videos'
+          }
+        }
+      },
+      {
+        path: 'trending',
+        component: VideoTrendingComponent,
+        data: {
+          meta: {
+            title: 'Trending videos'
+          },
+          reuse: {
+            enabled: true,
+            key: 'trending-videos-list'
+          }
+        }
+      },
+      {
+        path: 'most-liked',
+        component: VideoMostLikedComponent,
+        data: {
+          meta: {
+            title: 'Most liked videos'
+          },
+          reuse: {
+            enabled: true,
+            key: 'most-liked-videos-list'
+          }
+        }
+      },
+      {
+        path: 'recently-added',
+        component: VideoRecentlyAddedComponent,
+        data: {
+          meta: {
+            title: 'Recently added videos'
+          },
+          reuse: {
+            enabled: true,
+            key: 'recently-added-videos-list'
+          }
+        }
+      },
+      {
+        path: 'subscriptions',
+        component: VideoUserSubscriptionsComponent,
+        data: {
+          meta: {
+            title: 'Subscriptions'
+          },
+          reuse: {
+            enabled: true,
+            key: 'subscription-videos-list'
+          }
+        }
+      },
+      {
+        path: 'local',
+        component: VideoLocalComponent,
+        data: {
+          meta: {
+            title: 'Local videos'
+          },
+          reuse: {
+            enabled: true,
+            key: 'local-videos-list'
+          }
+        }
+      },
+      {
+        path: 'upload',
+        loadChildren: () => import('@app/+videos/+video-edit/video-add.module').then(m => m.VideoAddModule),
+        data: {
+          meta: {
+            title: 'Upload a video'
+          }
+        }
+      },
+      {
+        path: 'update/:uuid',
+        loadChildren: () => import('@app/+videos/+video-edit/video-update.module').then(m => m.VideoUpdateModule),
+        data: {
+          meta: {
+            title: 'Edit a video'
+          }
+        }
+      },
+      {
+        path: 'watch',
+        loadChildren: () => import('@app/+videos/+video-watch/video-watch.module').then(m => m.VideoWatchModule),
+        data: {
+          preload: 3000
+        }
+      }
+    ]
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(videosRoutes) ],
+  exports: [ RouterModule ]
+})
+export class VideosRoutingModule {}
diff --git a/client/src/app/+videos/videos.component.ts b/client/src/app/+videos/videos.component.ts
new file mode 100644 (file)
index 0000000..585a3ad
--- /dev/null
@@ -0,0 +1,6 @@
+import { Component } from '@angular/core'
+
+@Component({
+  template: '<router-outlet></router-outlet>'
+})
+export class VideosComponent {}
diff --git a/client/src/app/+videos/videos.module.ts b/client/src/app/+videos/videos.module.ts
new file mode 100644 (file)
index 0000000..1cf68bf
--- /dev/null
@@ -0,0 +1,47 @@
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { OverviewService } from './video-list'
+import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
+import { VideoLocalComponent } from './video-list/video-local.component'
+import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
+import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
+import { VideoTrendingComponent } from './video-list/video-trending.component'
+import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
+import { VideosRoutingModule } from './videos-routing.module'
+import { VideosComponent } from './videos.component'
+
+@NgModule({
+  imports: [
+    VideosRoutingModule,
+
+    SharedMainModule,
+    SharedFormModule,
+    SharedVideoMiniatureModule,
+    SharedUserSubscriptionModule,
+    SharedGlobalIconModule
+  ],
+
+  declarations: [
+    VideosComponent,
+
+    VideoTrendingComponent,
+    VideoMostLikedComponent,
+    VideoRecentlyAddedComponent,
+    VideoLocalComponent,
+    VideoUserSubscriptionsComponent,
+    VideoOverviewComponent
+  ],
+
+  exports: [
+    VideosComponent
+  ],
+
+  providers: [
+    OverviewService
+  ]
+})
+export class VideosModule { }
index ceda414150847c0f94fc13dd39bf69632ae3d0d1..a39be17dcf0517fa01d7dca2a491680b1521e8ed 100644 (file)
@@ -4,6 +4,7 @@ import { AppComponent } from '@app/app.component'
 import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy'
 import { MenuGuards } from '@app/core/routing/menu-guard.service'
 import { PreloadSelectedModulesList } from './core'
+import { EmptyComponent } from './empty.component'
 
 const routes: Routes = [
   {
@@ -36,9 +37,25 @@ const routes: Routes = [
     path: 'signup',
     loadChildren: () => import('./+signup/+register/register.module').then(m => m.RegisterModule)
   },
+  {
+    path: 'reset-password',
+    loadChildren: () => import('./+reset-password/reset-password.module').then(m => m.ResetPasswordModule)
+  },
+  {
+    path: 'login',
+    loadChildren: () => import('./+login/login.module').then(m => m.LoginModule)
+  },
+  {
+    path: 'search',
+    loadChildren: () => import('./+search/search.module').then(m => m.SearchModule)
+  },
+  {
+    path: 'videos',
+    loadChildren: () => import('./+videos/videos.module').then(m => m.VideosModule)
+  },
   {
     path: '',
-    component: AppComponent // Avoid 404, app component will redirect dynamically
+    component: EmptyComponent // Avoid 404, app component will redirect dynamically
   },
   {
     path: '**',
index 7fbc6463b0dd8c03ad89aa9df65e76f03eee16f8..c6e9d73157538d0cd54f4a0e7d22c10d89f21610 100644 (file)
@@ -4,15 +4,13 @@ import { LOCALE_ID, NgModule, TRANSLATIONS, TRANSLATIONS_FORMAT } from '@angular
 import { BrowserModule } from '@angular/platform-browser'
 import { ServerService } from '@app/core'
 import localeOc from '@app/helpers/locales/oc'
-import { ResetPasswordModule } from '@app/reset-password'
-import { SearchModule } from '@app/search'
 import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
 import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models'
 import { AppRoutingModule } from './app-routing.module'
 import { AppComponent } from './app.component'
 import { CoreModule } from './core'
 import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
-import { LoginModule } from './login'
+import { HighlightPipe } from './header/highlight.pipe'
 import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
 import { ConfirmComponent } from './modal/confirm.component'
 import { CustomModalComponent } from './modal/custom-modal.component'
@@ -24,7 +22,6 @@ import { SharedGlobalIconModule } from './shared/shared-icons'
 import { SharedInstanceModule } from './shared/shared-instance'
 import { SharedMainModule } from './shared/shared-main'
 import { SharedUserInterfaceSettingsModule } from './shared/shared-user-settings'
-import { VideosModule } from './videos'
 
 registerLocaleData(localeOc, 'oc')
 
@@ -41,6 +38,7 @@ registerLocaleData(localeOc, 'oc')
     HeaderComponent,
     SearchTypeaheadComponent,
     SuggestionComponent,
+    HighlightPipe,
 
     CustomModalComponent,
     WelcomeModalComponent,
@@ -58,12 +56,6 @@ registerLocaleData(localeOc, 'oc')
     SharedGlobalIconModule,
     SharedInstanceModule,
 
-    LoginModule,
-    ResetPasswordModule,
-    SearchModule,
-
-    VideosModule,
-
     MetaModule.forRoot({
       provide: MetaLoader,
       useFactory: (serverService: ServerService) => {
diff --git a/client/src/app/empty.component.ts b/client/src/app/empty.component.ts
new file mode 100644 (file)
index 0000000..5cde05a
--- /dev/null
@@ -0,0 +1,10 @@
+
+import { Component } from '@angular/core'
+
+@Component({
+  selector: 'my-empty',
+  template: ''
+})
+export class EmptyComponent {
+
+}
diff --git a/client/src/app/header/highlight.pipe.ts b/client/src/app/header/highlight.pipe.ts
new file mode 100644 (file)
index 0000000..50ee5c1
--- /dev/null
@@ -0,0 +1,54 @@
+import { PipeTransform, Pipe } from '@angular/core'
+import { SafeHtml } from '@angular/platform-browser'
+
+// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
+@Pipe({ name: 'highlight' })
+export class HighlightPipe implements PipeTransform {
+  /* use this for single match search */
+  static SINGLE_MATCH = 'Single-Match'
+  /* use this for single match search with a restriction that target should start with search string */
+  static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
+  /* use this for global search */
+  static MULTI_MATCH = 'Multi-Match'
+
+  transform (
+    contentString: string = null,
+    stringToHighlight: string = null,
+    option = 'Single-And-StartsWith-Match',
+    caseSensitive = false,
+    highlightStyleName = 'search-highlight'
+  ): SafeHtml {
+    if (stringToHighlight && contentString && option) {
+      let regex: any = ''
+      const caseFlag: string = !caseSensitive ? 'i' : ''
+
+      switch (option) {
+        case 'Single-Match': {
+          regex = new RegExp(stringToHighlight, caseFlag)
+          break
+        }
+        case 'Single-And-StartsWith-Match': {
+          regex = new RegExp('^' + stringToHighlight, caseFlag)
+          break
+        }
+        case 'Multi-Match': {
+          regex = new RegExp(stringToHighlight, 'g' + caseFlag)
+          break
+        }
+        default: {
+          // default will be a global case-insensitive match
+          regex = new RegExp(stringToHighlight, 'gi')
+        }
+      }
+
+      const replaced = contentString.replace(
+        regex,
+        (match) => `<span class="${highlightStyleName}">${match}</span>`
+      )
+
+      return replaced
+    } else {
+      return contentString
+    }
+  }
+}
diff --git a/client/src/app/login/index.ts b/client/src/app/login/index.ts
deleted file mode 100644 (file)
index f1301d8..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './login-routing.module'
-export * from './login.component'
-export * from './login.module'
diff --git a/client/src/app/login/login-routing.module.ts b/client/src/app/login/login-routing.module.ts
deleted file mode 100644 (file)
index 22f59b4..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { MetaGuard } from '@ngx-meta/core'
-import { LoginComponent } from './login.component'
-import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
-
-const loginRoutes: Routes = [
-  {
-    path: 'login',
-    component: LoginComponent,
-    canActivate: [ MetaGuard ],
-    data: {
-      meta: {
-        title: 'Login'
-      }
-    },
-    resolve: {
-      serverConfig: ServerConfigResolver
-    }
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(loginRoutes) ],
-  exports: [ RouterModule ]
-})
-export class LoginRoutingModule {}
diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html
deleted file mode 100644 (file)
index 599b203..0000000
+++ /dev/null
@@ -1,114 +0,0 @@
-<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>
diff --git a/client/src/app/login/login.component.scss b/client/src/app/login/login.component.scss
deleted file mode 100644 (file)
index fde6cc1..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-label {
-  display: block;
-}
-
-input:not([type=submit]) {
-  @include peertube-input-text(340px);
-  display: inline-block;
-  margin-right: 5px;
-
-}
-
-input[type=submit] {
-  @include peertube-button;
-  @include orange-button;
-}
-
-.create-an-account, .forgot-password-button {
-  color: pvar(--mainForegroundColor);
-  cursor: pointer;
-  transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1);
-
-  &:hover {
-    text-decoration: none !important;
-    opacity: .7 !important;
-  }
-}
-
-.login-form-and-externals {
-  display: flex;
-  flex-wrap: wrap;
-  font-size: 15px;
-
-  form {
-    margin: 0 50px 20px 0;
-  }
-
-  .external-login-blocks {
-    min-width: 200px;
-
-    .block-title {
-      font-weight: $font-semibold;
-    }
-
-    .external-login-block {
-      @include disable-default-a-behaviour;
-
-      cursor: pointer;
-      border: 1px solid #d1d7e0;
-      border-radius: 5px;
-      color: pvar(--mainForegroundColor);
-      margin: 10px 10px 0 0;
-      display: flex;
-      justify-content: center;
-      align-items: center;
-      min-height: 35px;
-      min-width: 100px;
-
-      &:hover {
-        background-color: rgba(209, 215, 224, 0.5)
-      }
-    }
-  }
-}
diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts
deleted file mode 100644 (file)
index cbc51ee..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-import { environment } from 'src/environments/environment'
-import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
-import { ActivatedRoute } from '@angular/router'
-import { AuthService, Notifier, RedirectService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { FormReactive, FormValidatorService, LoginValidatorsService } from '@app/shared/shared-forms'
-import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
-
-@Component({
-  selector: 'my-login',
-  templateUrl: './login.component.html',
-  styleUrls: [ './login.component.scss' ]
-})
-
-export class LoginComponent extends FormReactive implements OnInit, AfterViewInit {
-  @ViewChild('usernameInput', { static: false }) usernameInput: ElementRef
-  @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
-
-  error: string = null
-  forgotPasswordEmail = ''
-
-  isAuthenticatedWithExternalAuth = false
-  externalAuthError = false
-  externalLogins: string[] = []
-
-  private openedForgotPasswordModal: NgbModalRef
-  private serverConfig: ServerConfig
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private route: ActivatedRoute,
-    private modalService: NgbModal,
-    private loginValidatorsService: LoginValidatorsService,
-    private authService: AuthService,
-    private userService: UserService,
-    private redirectService: RedirectService,
-    private notifier: Notifier,
-    private hooks: HooksService,
-    private i18n: I18n
-  ) {
-    super()
-  }
-
-  get signupAllowed () {
-    return this.serverConfig.signup.allowed === true
-  }
-
-  isEmailDisabled () {
-    return this.serverConfig.email.enabled === false
-  }
-
-  ngOnInit () {
-    const snapshot = this.route.snapshot
-
-    this.serverConfig = snapshot.data.serverConfig
-
-    if (snapshot.queryParams.externalAuthToken) {
-      this.loadExternalAuthToken(snapshot.queryParams.username, snapshot.queryParams.externalAuthToken)
-      return
-    }
-
-    if (snapshot.queryParams.externalAuthError) {
-      this.externalAuthError = true
-      return
-    }
-
-    this.buildForm({
-      username: this.loginValidatorsService.LOGIN_USERNAME,
-      password: this.loginValidatorsService.LOGIN_PASSWORD
-    })
-  }
-
-  ngAfterViewInit () {
-    if (this.usernameInput) {
-      this.usernameInput.nativeElement.focus()
-    }
-
-    this.hooks.runAction('action:login.init', 'login')
-  }
-
-  getExternalLogins () {
-    return this.serverConfig.plugin.registeredExternalAuths
-  }
-
-  getAuthHref (auth: RegisteredExternalAuthConfig) {
-    return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
-  }
-
-  login () {
-    this.error = null
-
-    const { username, password } = this.form.value
-
-    this.authService.login(username, password)
-      .subscribe(
-        () => this.redirectService.redirectToPreviousRoute(),
-
-        err => this.handleError(err)
-      )
-  }
-
-  askResetPassword () {
-    this.userService.askResetPassword(this.forgotPasswordEmail)
-      .subscribe(
-        () => {
-          const message = this.i18n(
-            'An email with the reset password instructions will be sent to {{email}}. The link will expire within 1 hour.',
-            { email: this.forgotPasswordEmail }
-          )
-          this.notifier.success(message)
-          this.hideForgotPasswordModal()
-        },
-
-        err => this.notifier.error(err.message)
-      )
-  }
-
-  openForgotPasswordModal () {
-    this.openedForgotPasswordModal = this.modalService.open(this.forgotPasswordModal)
-  }
-
-  hideForgotPasswordModal () {
-    this.openedForgotPasswordModal.close()
-  }
-
-  private loadExternalAuthToken (username: string, token: string) {
-    this.isAuthenticatedWithExternalAuth = true
-
-    this.authService.login(username, null, token)
-    .subscribe(
-      () => this.redirectService.redirectToPreviousRoute(),
-
-      err => {
-        this.handleError(err)
-        this.isAuthenticatedWithExternalAuth = false
-      }
-    )
-  }
-
-  private handleError (err: any) {
-    if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.')
-    else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.')
-    else this.error = err.message
-  }
-}
diff --git a/client/src/app/login/login.module.ts b/client/src/app/login/login.module.ts
deleted file mode 100644 (file)
index c419024..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-import { NgModule } from '@angular/core'
-import { SharedFormModule } from '@app/shared/shared-forms'
-import { SharedGlobalIconModule } from '@app/shared/shared-icons'
-import { SharedMainModule } from '@app/shared/shared-main'
-import { LoginRoutingModule } from './login-routing.module'
-import { LoginComponent } from './login.component'
-
-@NgModule({
-  imports: [
-    LoginRoutingModule,
-
-    SharedMainModule,
-    SharedFormModule,
-    SharedGlobalIconModule
-  ],
-
-  declarations: [
-    LoginComponent
-  ],
-
-  exports: [
-    LoginComponent
-  ],
-
-  providers: [
-  ]
-})
-export class LoginModule { }
diff --git a/client/src/app/reset-password/index.ts b/client/src/app/reset-password/index.ts
deleted file mode 100644 (file)
index 438dc57..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './reset-password-routing.module'
-export * from './reset-password.component'
-export * from './reset-password.module'
diff --git a/client/src/app/reset-password/reset-password-routing.module.ts b/client/src/app/reset-password/reset-password-routing.module.ts
deleted file mode 100644 (file)
index d443b51..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { MetaGuard } from '@ngx-meta/core'
-import { ResetPasswordComponent } from './reset-password.component'
-
-const resetPasswordRoutes: Routes = [
-  {
-    path: 'reset-password',
-    component: ResetPasswordComponent,
-    canActivate: [ MetaGuard ],
-    data: {
-      meta: {
-        title: 'Reset password'
-      }
-    }
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(resetPasswordRoutes) ],
-  exports: [ RouterModule ]
-})
-export class ResetPasswordRoutingModule {}
diff --git a/client/src/app/reset-password/reset-password.component.html b/client/src/app/reset-password/reset-password.component.html
deleted file mode 100644 (file)
index af30af4..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<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>
diff --git a/client/src/app/reset-password/reset-password.component.scss b/client/src/app/reset-password/reset-password.component.scss
deleted file mode 100644 (file)
index efec6b7..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-input:not([type=submit]) {
-  @include peertube-input-text(340px);
-  display: block;
-}
-
-input[type=submit] {
-  @include peertube-button;
-  @include orange-button;
-}
diff --git a/client/src/app/reset-password/reset-password.component.ts b/client/src/app/reset-password/reset-password.component.ts
deleted file mode 100644 (file)
index 8d50e98..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Component, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { Notifier, UserService } from '@app/core'
-import { FormReactive, FormValidatorService, ResetPasswordValidatorsService, UserValidatorsService } from '@app/shared/shared-forms'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
-  selector: 'my-login',
-  templateUrl: './reset-password.component.html',
-  styleUrls: [ './reset-password.component.scss' ]
-})
-
-export class ResetPasswordComponent extends FormReactive implements OnInit {
-  private userId: number
-  private verificationString: string
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private resetPasswordValidatorsService: ResetPasswordValidatorsService,
-    private userValidatorsService: UserValidatorsService,
-    private userService: UserService,
-    private notifier: Notifier,
-    private router: Router,
-    private route: ActivatedRoute,
-    private i18n: I18n
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    this.buildForm({
-      password: this.userValidatorsService.USER_PASSWORD,
-      'password-confirm': this.resetPasswordValidatorsService.RESET_PASSWORD_CONFIRM
-    })
-
-    this.userId = this.route.snapshot.queryParams['userId']
-    this.verificationString = this.route.snapshot.queryParams['verificationString']
-
-    if (!this.userId || !this.verificationString) {
-      this.notifier.error(this.i18n('Unable to find user id or verification string.'))
-      this.router.navigate([ '/' ])
-    }
-  }
-
-  resetPassword () {
-    this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password)
-      .subscribe(
-        () => {
-          this.notifier.success(this.i18n('Your password has been successfully reset!'))
-          this.router.navigate([ '/login' ])
-        },
-
-        err => this.notifier.error(err.message)
-      )
-  }
-
-  isConfirmedPasswordValid () {
-    const values = this.form.value
-    return values.password === values['password-confirm']
-  }
-}
diff --git a/client/src/app/reset-password/reset-password.module.ts b/client/src/app/reset-password/reset-password.module.ts
deleted file mode 100644 (file)
index c77f1c4..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-import { NgModule } from '@angular/core'
-import { SharedFormModule } from '@app/shared/shared-forms'
-import { SharedMainModule } from '@app/shared/shared-main'
-import { ResetPasswordRoutingModule } from './reset-password-routing.module'
-import { ResetPasswordComponent } from './reset-password.component'
-
-@NgModule({
-  imports: [
-    ResetPasswordRoutingModule,
-
-    SharedMainModule,
-    SharedFormModule
-  ],
-
-  declarations: [
-    ResetPasswordComponent
-  ],
-
-  exports: [
-    ResetPasswordComponent
-  ],
-
-  providers: [
-  ]
-})
-export class ResetPasswordModule { }
diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts
deleted file mode 100644 (file)
index 516854a..0000000
+++ /dev/null
@@ -1,160 +0,0 @@
-import { NSFWQuery, SearchTargetType } from '@shared/models'
-
-export class AdvancedSearch {
-  startDate: string // ISO 8601
-  endDate: string // ISO 8601
-
-  originallyPublishedStartDate: string // ISO 8601
-  originallyPublishedEndDate: string // ISO 8601
-
-  nsfw: NSFWQuery
-
-  categoryOneOf: string
-
-  licenceOneOf: string
-
-  languageOneOf: string
-
-  tagsOneOf: string
-  tagsAllOf: string
-
-  durationMin: number // seconds
-  durationMax: number // seconds
-
-  sort: string
-
-  searchTarget: SearchTargetType
-
-  // Filters we don't want to count, because they are mandatory
-  private silentFilters = new Set([ 'sort', 'searchTarget' ])
-
-  constructor (options?: {
-    startDate?: string
-    endDate?: string
-    originallyPublishedStartDate?: string
-    originallyPublishedEndDate?: string
-    nsfw?: NSFWQuery
-    categoryOneOf?: string
-    licenceOneOf?: string
-    languageOneOf?: string
-    tagsOneOf?: string
-    tagsAllOf?: string
-    durationMin?: string
-    durationMax?: string
-    sort?: string
-    searchTarget?: SearchTargetType
-  }) {
-    if (!options) return
-
-    this.startDate = options.startDate || undefined
-    this.endDate = options.endDate || undefined
-    this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined
-    this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined
-
-    this.nsfw = options.nsfw || undefined
-    this.categoryOneOf = options.categoryOneOf || undefined
-    this.licenceOneOf = options.licenceOneOf || undefined
-    this.languageOneOf = options.languageOneOf || undefined
-    this.tagsOneOf = options.tagsOneOf || undefined
-    this.tagsAllOf = options.tagsAllOf || undefined
-    this.durationMin = parseInt(options.durationMin, 10)
-    this.durationMax = parseInt(options.durationMax, 10)
-
-    this.searchTarget = options.searchTarget || undefined
-
-    if (isNaN(this.durationMin)) this.durationMin = undefined
-    if (isNaN(this.durationMax)) this.durationMax = undefined
-
-    this.sort = options.sort || '-match'
-  }
-
-  containsValues () {
-    const exceptions = new Set([ 'sort', 'searchTarget' ])
-
-    const obj = this.toUrlObject()
-    for (const k of Object.keys(obj)) {
-      if (this.silentFilters.has(k)) continue
-
-      if (obj[k] !== undefined && obj[k] !== '') return true
-    }
-
-    return false
-  }
-
-  reset () {
-    this.startDate = undefined
-    this.endDate = undefined
-    this.originallyPublishedStartDate = undefined
-    this.originallyPublishedEndDate = undefined
-    this.nsfw = undefined
-    this.categoryOneOf = undefined
-    this.licenceOneOf = undefined
-    this.languageOneOf = undefined
-    this.tagsOneOf = undefined
-    this.tagsAllOf = undefined
-    this.durationMin = undefined
-    this.durationMax = undefined
-
-    this.sort = '-match'
-  }
-
-  toUrlObject () {
-    return {
-      startDate: this.startDate,
-      endDate: this.endDate,
-      originallyPublishedStartDate: this.originallyPublishedStartDate,
-      originallyPublishedEndDate: this.originallyPublishedEndDate,
-      nsfw: this.nsfw,
-      categoryOneOf: this.categoryOneOf,
-      licenceOneOf: this.licenceOneOf,
-      languageOneOf: this.languageOneOf,
-      tagsOneOf: this.tagsOneOf,
-      tagsAllOf: this.tagsAllOf,
-      durationMin: this.durationMin,
-      durationMax: this.durationMax,
-      sort: this.sort,
-      searchTarget: this.searchTarget
-    }
-  }
-
-  toAPIObject () {
-    return {
-      startDate: this.startDate,
-      endDate: this.endDate,
-      originallyPublishedStartDate: this.originallyPublishedStartDate,
-      originallyPublishedEndDate: this.originallyPublishedEndDate,
-      nsfw: this.nsfw,
-      categoryOneOf: this.intoArray(this.categoryOneOf),
-      licenceOneOf: this.intoArray(this.licenceOneOf),
-      languageOneOf: this.intoArray(this.languageOneOf),
-      tagsOneOf: this.intoArray(this.tagsOneOf),
-      tagsAllOf: this.intoArray(this.tagsAllOf),
-      durationMin: this.durationMin,
-      durationMax: this.durationMax,
-      sort: this.sort,
-      searchTarget: this.searchTarget
-    }
-  }
-
-  size () {
-    let acc = 0
-
-    const obj = this.toUrlObject()
-    for (const k of Object.keys(obj)) {
-      if (this.silentFilters.has(k)) continue
-
-      if (obj[k] !== undefined && obj[k] !== '') acc++
-    }
-
-    return acc
-  }
-
-  private intoArray (value: any) {
-    if (!value) return undefined
-    if (Array.isArray(value)) return value
-
-    if (typeof value === 'string') return value.split(',')
-
-    return [ value ]
-  }
-}
diff --git a/client/src/app/search/channel-lazy-load.resolver.ts b/client/src/app/search/channel-lazy-load.resolver.ts
deleted file mode 100644 (file)
index 5b6961e..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-import { map } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
-import { SearchService } from './search.service'
-
-@Injectable()
-export class ChannelLazyLoadResolver implements Resolve<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)
-        })
-      )
-  }
-}
diff --git a/client/src/app/search/highlight.pipe.ts b/client/src/app/search/highlight.pipe.ts
deleted file mode 100644 (file)
index 50ee5c1..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-import { PipeTransform, Pipe } from '@angular/core'
-import { SafeHtml } from '@angular/platform-browser'
-
-// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
-@Pipe({ name: 'highlight' })
-export class HighlightPipe implements PipeTransform {
-  /* use this for single match search */
-  static SINGLE_MATCH = 'Single-Match'
-  /* use this for single match search with a restriction that target should start with search string */
-  static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
-  /* use this for global search */
-  static MULTI_MATCH = 'Multi-Match'
-
-  transform (
-    contentString: string = null,
-    stringToHighlight: string = null,
-    option = 'Single-And-StartsWith-Match',
-    caseSensitive = false,
-    highlightStyleName = 'search-highlight'
-  ): SafeHtml {
-    if (stringToHighlight && contentString && option) {
-      let regex: any = ''
-      const caseFlag: string = !caseSensitive ? 'i' : ''
-
-      switch (option) {
-        case 'Single-Match': {
-          regex = new RegExp(stringToHighlight, caseFlag)
-          break
-        }
-        case 'Single-And-StartsWith-Match': {
-          regex = new RegExp('^' + stringToHighlight, caseFlag)
-          break
-        }
-        case 'Multi-Match': {
-          regex = new RegExp(stringToHighlight, 'g' + caseFlag)
-          break
-        }
-        default: {
-          // default will be a global case-insensitive match
-          regex = new RegExp(stringToHighlight, 'gi')
-        }
-      }
-
-      const replaced = contentString.replace(
-        regex,
-        (match) => `<span class="${highlightStyleName}">${match}</span>`
-      )
-
-      return replaced
-    } else {
-      return contentString
-    }
-  }
-}
diff --git a/client/src/app/search/index.ts b/client/src/app/search/index.ts
deleted file mode 100644 (file)
index 40f4e02..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './search-routing.module'
-export * from './search.component'
-export * from './search.module'
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
deleted file mode 100644 (file)
index e20aef8..0000000
+++ /dev/null
@@ -1,193 +0,0 @@
-<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>
diff --git a/client/src/app/search/search-filters.component.scss b/client/src/app/search/search-filters.component.scss
deleted file mode 100644 (file)
index a88a1c0..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-form {
-  margin-top: 40px;
-}
-
-.radio-label {
-  font-size: 15px;
-  font-weight: $font-bold;
-}
-
-.peertube-radio-container {
-  @include peertube-radio-container;
-
-  display: inline-block;
-  margin-right: 30px;
-}
-
-.peertube-select-container {
-  @include peertube-select-container(auto);
-
-  margin-bottom: 1rem;
-}
-
-.form-group {
-  margin-bottom: 25px;
-}
-
-input[type=text] {
-  @include peertube-input-text(100%);
-  display: block;
-}
-
-input[type=submit] {
-  @include peertube-button-link;
-  @include orange-button;
-}
-
-.submit-button {
-  text-align: right;
-}
-
-.reset-button {
-  @include peertube-button;
-
-  font-weight: $font-semibold;
-  display: inline-block;
-  padding: 0 10px 0 10px;
-  white-space: nowrap;
-  background: transparent;
-
-  margin-right: 1rem;
-}
-
-.reset-button-small {
-  font-size: 80%;
-  height: unset;
-  line-height: unset;
-  margin: unset;
-  margin-bottom: 0.5rem;
-}
-
-.label-container {
-  display: flex;
-  white-space: nowrap;
-}
-
-@include ng2-tags;
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts
deleted file mode 100644 (file)
index 14a5d04..0000000
+++ /dev/null
@@ -1,269 +0,0 @@
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { ValidatorFn } from '@angular/forms'
-import { ServerService } from '@app/core'
-import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { VideoValidatorsService } from '@app/shared/shared-forms'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig, VideoConstant } from '@shared/models'
-
-@Component({
-  selector: 'my-search-filters',
-  styleUrls: [ './search-filters.component.scss' ],
-  templateUrl: './search-filters.component.html'
-})
-export class SearchFiltersComponent implements OnInit {
-  @Input() advancedSearch: AdvancedSearch = new AdvancedSearch()
-
-  @Output() filtered = new EventEmitter<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()
-  }
-}
diff --git a/client/src/app/search/search-routing.module.ts b/client/src/app/search/search-routing.module.ts
deleted file mode 100644 (file)
index 9da900e..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { SearchComponent } from '@app/search/search.component'
-import { MetaGuard } from '@ngx-meta/core'
-import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
-import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
-
-const searchRoutes: Routes = [
-  {
-    path: 'search',
-    component: SearchComponent,
-    canActivate: [ MetaGuard ],
-    data: {
-      meta: {
-        title: 'Search'
-      }
-    }
-  },
-  {
-    path: 'search/lazy-load-video',
-    component: SearchComponent,
-    canActivate: [ MetaGuard ],
-    resolve: {
-      data: VideoLazyLoadResolver
-    }
-  },
-  {
-    path: 'search/lazy-load-channel',
-    component: SearchComponent,
-    canActivate: [ MetaGuard ],
-    resolve: {
-      data: ChannelLazyLoadResolver
-    }
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(searchRoutes) ],
-  exports: [ RouterModule ]
-})
-export class SearchRoutingModule {}
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
deleted file mode 100644 (file)
index 9bff024..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-<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>
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss
deleted file mode 100644 (file)
index 6e59adb..0000000
+++ /dev/null
@@ -1,191 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.search-result {
-  padding: 40px;
-
-  .results-header {
-    font-size: 16px;
-    padding-bottom: 20px;
-    margin-bottom: 30px;
-    border-bottom: 1px solid #DADADA;
-
-    .first-line {
-      display: flex;
-      flex-direction: row;
-
-      .results-counter {
-        flex-grow: 1;
-
-        .search-value {
-          font-weight: $font-semibold;
-        }
-      }
-
-      .results-filter-button {
-        cursor: pointer;
-
-        .icon.icon-filter {
-          @include icon(20px);
-
-          position: relative;
-          top: -1px;
-          margin-right: 5px;
-          background-image: url('../../assets/images/search/filter.svg');
-        }
-      }
-    }
-  }
-
-  .entry {
-    display: flex;
-    min-height: 130px;
-    padding-bottom: 20px;
-    margin-bottom: 20px;
-
-    &.video-channel {
-      img {
-        $image-size: 130px;
-        $margin-size: ($video-thumbnail-width - $image-size) / 2; // So we have the same width than the video miniature
-
-        @include avatar($image-size);
-
-        margin: 0 ($margin-size + 10) 0 $margin-size;
-      }
-
-      .video-channel-info {
-        flex-grow: 1;
-        width: fit-content;
-
-        .video-channel-names {
-          @include disable-default-a-behaviour;
-
-          display: flex;
-          align-items: baseline;
-          color: pvar(--mainForegroundColor);
-          width: fit-content;
-
-          .video-channel-display-name {
-            font-weight: $font-semibold;
-            font-size: 18px;
-          }
-
-          .video-channel-name {
-            font-size: 14px;
-            color: $grey-actor-name;
-            margin-left: 5px;
-          }
-        }
-      }
-    }
-  }
-}
-
-@media screen and (min-width: $small-view) and (max-width: breakpoint(xl)) {
-  .video-channel-info .video-channel-names {
-    flex-direction: column !important;
-
-    .video-channel-name {
-      @include ellipsis; // Ellipsis and max-width on channel-name to not break screen
-
-      max-width: 250px;
-      margin-left: 0 !important;
-    }
-  }
-
-  :host-context(.main-col:not(.expanded)) {
-    // Override the min-width: 500px to not break screen
-    ::ng-deep .video-miniature-information {
-      min-width: 300px !important;
-    }
-  }
-}
-
-@media screen and (min-width: $small-view) and (max-width: breakpoint(lg)) {
-  :host-context(.main-col:not(.expanded)) {
-    .video-channel-info .video-channel-names {
-      .video-channel-name {
-        max-width: 160px;
-      }
-    }
-
-    // Override the min-width: 500px to not break screen
-    ::ng-deep .video-miniature-information {
-      min-width: $video-thumbnail-width !important;
-    }
-  }
-
-  :host-context(.expanded) {
-    // Override the min-width: 500px to not break screen
-    ::ng-deep .video-miniature-information {
-      min-width: 300px !important;
-    }
-  }
-}
-
-@media screen and (max-width: $small-view) {
-  .search-result {
-    .entry.video-channel,
-    .entry.video {
-      flex-direction: column;
-      height: auto;
-      justify-content: center;
-      align-items: center;
-      text-align: center;
-
-      img {
-        margin: 0;
-      }
-
-      img {
-        margin: 0;
-      }
-
-      .video-channel-info .video-channel-names {
-        align-items: center;
-        flex-direction: column !important;
-
-        .video-channel-name {
-          margin-left: 0 !important;
-        }
-      }
-
-      my-subscribe-button {
-        margin-top: 5px;
-      }
-    }
-  }
-}
-
-@media screen and (max-width: $mobile-view) {
-  .search-result {
-    padding: 20px 10px;
-
-    .results-header {
-      font-size: 15px !important;
-    }
-
-    .entry {
-      &.video {
-        .video-info-name,
-        .video-info-account {
-          margin: auto;
-        }
-
-        my-video-thumbnail {
-          margin-right: 0 !important;
-
-          ::ng-deep .video-thumbnail {
-            width: 100%;
-            height: auto;
-
-            img {
-              width: 100%;
-              height: auto;
-            }
-          }
-        }
-      }
-    }
-  }
-}
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
deleted file mode 100644 (file)
index 83b06e0..0000000
+++ /dev/null
@@ -1,260 +0,0 @@
-import { forkJoin, of, Subscription } from 'rxjs'
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
-import { Video, VideoChannel } from '@app/shared/shared-main'
-import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
-import { MetaService } from '@ngx-meta/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { SearchTargetType, ServerConfig } from '@shared/models'
-import { AdvancedSearch } from './advanced-search.model'
-import { SearchService } from './search.service'
-
-@Component({
-  selector: 'my-search',
-  styleUrls: [ './search.component.scss' ],
-  templateUrl: './search.component.html'
-})
-export class SearchComponent implements OnInit, OnDestroy {
-  results: (Video | VideoChannel)[] = []
-
-  pagination: ComponentPagination = {
-    currentPage: 1,
-    itemsPerPage: 10, // Only for videos, use another variable for channels
-    totalItems: null
-  }
-  advancedSearch: AdvancedSearch = new AdvancedSearch()
-  isSearchFilterCollapsed = true
-  currentSearch: string
-
-  videoDisplayOptions: MiniatureDisplayOptions = {
-    date: true,
-    views: true,
-    by: true,
-    avatar: false,
-    privacyLabel: false,
-    privacyText: false,
-    state: false,
-    blacklistInfo: false
-  }
-
-  errorMessage: string
-  serverConfig: ServerConfig
-
-  userMiniature: User
-
-  private subActivatedRoute: Subscription
-  private isInitialLoad = false // set to false to show the search filters on first arrival
-  private firstSearch = true
-
-  private channelsPerPage = 2
-
-  private lastSearchTarget: SearchTargetType
-
-  constructor (
-    private i18n: I18n,
-    private route: ActivatedRoute,
-    private router: Router,
-    private metaService: MetaService,
-    private notifier: Notifier,
-    private searchService: SearchService,
-    private authService: AuthService,
-    private userService: UserService,
-    private hooks: HooksService,
-    private serverService: ServerService
-  ) { }
-
-  ngOnInit () {
-    this.serverService.getConfig()
-      .subscribe(config => this.serverConfig = config)
-
-    this.subActivatedRoute = this.route.queryParams.subscribe(
-      async queryParams => {
-        const querySearch = queryParams['search']
-        const searchTarget = queryParams['searchTarget']
-
-        // Search updated, reset filters
-        if (this.currentSearch !== querySearch || searchTarget !== this.advancedSearch.searchTarget) {
-          this.resetPagination()
-          this.advancedSearch.reset()
-
-          this.currentSearch = querySearch || undefined
-          this.updateTitle()
-        }
-
-        this.advancedSearch = new AdvancedSearch(queryParams)
-        if (!this.advancedSearch.searchTarget) {
-          this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget()
-        }
-
-        // Don't hide filters if we have some of them AND the user just came on the webpage
-        this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
-        this.isInitialLoad = false
-
-        this.search()
-      },
-
-      err => this.notifier.error(err.text)
-    )
-
-    this.userService.getAnonymousOrLoggedUser()
-      .subscribe(user => this.userMiniature = user)
-
-    this.hooks.runAction('action:search.init', 'search')
-  }
-
-  ngOnDestroy () {
-    if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
-  }
-
-  isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
-    return d instanceof VideoChannel
-  }
-
-  isVideo (v: VideoChannel | Video): v is Video {
-    return v instanceof Video
-  }
-
-  isUserLoggedIn () {
-    return this.authService.isLoggedIn()
-  }
-
-  search () {
-    forkJoin([
-      this.getVideosObs(),
-      this.getVideoChannelObs()
-    ]).subscribe(
-      ([videosResult, videoChannelsResult]) => {
-        this.results = this.results
-          .concat(videoChannelsResult.data)
-          .concat(videosResult.data)
-
-        this.pagination.totalItems = videosResult.total + videoChannelsResult.total
-        this.lastSearchTarget = this.advancedSearch.searchTarget
-
-        // Focus on channels if there are no enough videos
-        if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
-          this.resetPagination()
-          this.firstSearch = false
-
-          this.channelsPerPage = 10
-          this.search()
-        }
-
-        this.firstSearch = false
-      },
-
-      err => {
-        if (this.advancedSearch.searchTarget !== 'search-index') {
-          this.notifier.error(err.message)
-          return
-        }
-
-        this.notifier.error(
-          this.i18n('Search index is unavailable. Retrying with instance results instead.'),
-          this.i18n('Search error')
-        )
-        this.advancedSearch.searchTarget = 'local'
-        this.search()
-      }
-    )
-  }
-
-  onNearOfBottom () {
-    // Last page
-    if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
-
-    this.pagination.currentPage += 1
-    this.search()
-  }
-
-  onFiltered () {
-    this.resetPagination()
-
-    this.updateUrlFromAdvancedSearch()
-  }
-
-  numberOfFilters () {
-    return this.advancedSearch.size()
-  }
-
-  // Add VideoChannel for typings, but the template already checks "video" argument is a video
-  removeVideoFromArray (video: Video | VideoChannel) {
-    this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
-  }
-
-  getChannelUrl (channel: VideoChannel) {
-    if (this.advancedSearch.searchTarget === 'search-index' && channel.url) {
-      const remoteUriConfig = this.serverConfig.search.remoteUri
-
-      // Redirect on the external instance if not allowed to fetch remote data
-      const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
-      const fromPath = window.location.pathname + window.location.search
-
-      return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ]
-    }
-
-    return [ '/video-channels', channel.nameWithHost ]
-  }
-
-  hideActions () {
-    return this.lastSearchTarget === 'search-index'
-  }
-
-  private resetPagination () {
-    this.pagination.currentPage = 1
-    this.pagination.totalItems = null
-    this.channelsPerPage = 2
-
-    this.results = []
-  }
-
-  private updateTitle () {
-    const suffix = this.currentSearch ? ' ' + this.currentSearch : ''
-    this.metaService.setTitle(this.i18n('Search') + suffix)
-  }
-
-  private updateUrlFromAdvancedSearch () {
-    const search = this.currentSearch || undefined
-
-    this.router.navigate([], {
-      relativeTo: this.route,
-      queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search })
-    })
-  }
-
-  private getVideosObs () {
-    const params = {
-      search: this.currentSearch,
-      componentPagination: this.pagination,
-      advancedSearch: this.advancedSearch
-    }
-
-    return this.hooks.wrapObsFun(
-      this.searchService.searchVideos.bind(this.searchService),
-      params,
-      'search',
-      'filter:api.search.videos.list.params',
-      'filter:api.search.videos.list.result'
-    )
-  }
-
-  private getVideoChannelObs () {
-    if (!this.currentSearch) return of({ data: [], total: 0 })
-
-    const params = {
-      search: this.currentSearch,
-      componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
-      searchTarget: this.advancedSearch.searchTarget
-    }
-
-    return this.hooks.wrapObsFun(
-      this.searchService.searchVideoChannels.bind(this.searchService),
-      params,
-      'search',
-      'filter:api.search.video-channels.list.params',
-      'filter:api.search.video-channels.list.result'
-    )
-  }
-}
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
deleted file mode 100644 (file)
index 65c954d..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-import { TagInputModule } from 'ngx-chips'
-import { NgModule } from '@angular/core'
-import { SharedFormModule } from '@app/shared/shared-forms'
-import { SharedMainModule } from '@app/shared/shared-main'
-import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
-import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
-import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
-import { HighlightPipe } from './highlight.pipe'
-import { SearchFiltersComponent } from './search-filters.component'
-import { SearchRoutingModule } from './search-routing.module'
-import { SearchComponent } from './search.component'
-import { SearchService } from './search.service'
-import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
-
-@NgModule({
-  imports: [
-    TagInputModule,
-
-    SearchRoutingModule,
-    SharedMainModule,
-    SharedFormModule,
-    SharedUserSubscriptionModule,
-    SharedVideoMiniatureModule
-  ],
-
-  declarations: [
-    SearchComponent,
-    SearchFiltersComponent
-  ],
-
-  exports: [
-    TagInputModule,
-    SearchComponent
-  ],
-
-  providers: [
-    SearchService,
-    VideoLazyLoadResolver,
-    ChannelLazyLoadResolver,
-    HighlightPipe
-  ]
-})
-export class SearchModule { }
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
deleted file mode 100644 (file)
index 3634203..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Observable } from 'rxjs'
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
-import { peertubeLocalStorage } from '@app/helpers'
-import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
-import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models'
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
-import { environment } from '../../environments/environment'
-
-@Injectable()
-export class SearchService {
-  static BASE_SEARCH_URL = environment.apiUrl + '/api/v1/search/'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor,
-    private restService: RestService,
-    private videoService: VideoService
-  ) {
-    // Add ability to override search endpoint if the user updated this local storage key
-    const searchUrl = peertubeLocalStorage.getItem('search-url')
-    if (searchUrl) SearchService.BASE_SEARCH_URL = searchUrl
-  }
-
-  searchVideos (parameters: {
-    search: string,
-    componentPagination?: ComponentPaginationLight,
-    advancedSearch?: AdvancedSearch
-  }): Observable<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))
-               )
-  }
-}
diff --git a/client/src/app/search/video-lazy-load.resolver.ts b/client/src/app/search/video-lazy-load.resolver.ts
deleted file mode 100644 (file)
index 8d846d3..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-import { map } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
-import { SearchService } from './search.service'
-
-@Injectable()
-export class VideoLazyLoadResolver implements Resolve<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)
-        })
-      )
-  }
-}
diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts
new file mode 100644 (file)
index 0000000..516854a
--- /dev/null
@@ -0,0 +1,160 @@
+import { NSFWQuery, SearchTargetType } from '@shared/models'
+
+export class AdvancedSearch {
+  startDate: string // ISO 8601
+  endDate: string // ISO 8601
+
+  originallyPublishedStartDate: string // ISO 8601
+  originallyPublishedEndDate: string // ISO 8601
+
+  nsfw: NSFWQuery
+
+  categoryOneOf: string
+
+  licenceOneOf: string
+
+  languageOneOf: string
+
+  tagsOneOf: string
+  tagsAllOf: string
+
+  durationMin: number // seconds
+  durationMax: number // seconds
+
+  sort: string
+
+  searchTarget: SearchTargetType
+
+  // Filters we don't want to count, because they are mandatory
+  private silentFilters = new Set([ 'sort', 'searchTarget' ])
+
+  constructor (options?: {
+    startDate?: string
+    endDate?: string
+    originallyPublishedStartDate?: string
+    originallyPublishedEndDate?: string
+    nsfw?: NSFWQuery
+    categoryOneOf?: string
+    licenceOneOf?: string
+    languageOneOf?: string
+    tagsOneOf?: string
+    tagsAllOf?: string
+    durationMin?: string
+    durationMax?: string
+    sort?: string
+    searchTarget?: SearchTargetType
+  }) {
+    if (!options) return
+
+    this.startDate = options.startDate || undefined
+    this.endDate = options.endDate || undefined
+    this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined
+    this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined
+
+    this.nsfw = options.nsfw || undefined
+    this.categoryOneOf = options.categoryOneOf || undefined
+    this.licenceOneOf = options.licenceOneOf || undefined
+    this.languageOneOf = options.languageOneOf || undefined
+    this.tagsOneOf = options.tagsOneOf || undefined
+    this.tagsAllOf = options.tagsAllOf || undefined
+    this.durationMin = parseInt(options.durationMin, 10)
+    this.durationMax = parseInt(options.durationMax, 10)
+
+    this.searchTarget = options.searchTarget || undefined
+
+    if (isNaN(this.durationMin)) this.durationMin = undefined
+    if (isNaN(this.durationMax)) this.durationMax = undefined
+
+    this.sort = options.sort || '-match'
+  }
+
+  containsValues () {
+    const exceptions = new Set([ 'sort', 'searchTarget' ])
+
+    const obj = this.toUrlObject()
+    for (const k of Object.keys(obj)) {
+      if (this.silentFilters.has(k)) continue
+
+      if (obj[k] !== undefined && obj[k] !== '') return true
+    }
+
+    return false
+  }
+
+  reset () {
+    this.startDate = undefined
+    this.endDate = undefined
+    this.originallyPublishedStartDate = undefined
+    this.originallyPublishedEndDate = undefined
+    this.nsfw = undefined
+    this.categoryOneOf = undefined
+    this.licenceOneOf = undefined
+    this.languageOneOf = undefined
+    this.tagsOneOf = undefined
+    this.tagsAllOf = undefined
+    this.durationMin = undefined
+    this.durationMax = undefined
+
+    this.sort = '-match'
+  }
+
+  toUrlObject () {
+    return {
+      startDate: this.startDate,
+      endDate: this.endDate,
+      originallyPublishedStartDate: this.originallyPublishedStartDate,
+      originallyPublishedEndDate: this.originallyPublishedEndDate,
+      nsfw: this.nsfw,
+      categoryOneOf: this.categoryOneOf,
+      licenceOneOf: this.licenceOneOf,
+      languageOneOf: this.languageOneOf,
+      tagsOneOf: this.tagsOneOf,
+      tagsAllOf: this.tagsAllOf,
+      durationMin: this.durationMin,
+      durationMax: this.durationMax,
+      sort: this.sort,
+      searchTarget: this.searchTarget
+    }
+  }
+
+  toAPIObject () {
+    return {
+      startDate: this.startDate,
+      endDate: this.endDate,
+      originallyPublishedStartDate: this.originallyPublishedStartDate,
+      originallyPublishedEndDate: this.originallyPublishedEndDate,
+      nsfw: this.nsfw,
+      categoryOneOf: this.intoArray(this.categoryOneOf),
+      licenceOneOf: this.intoArray(this.licenceOneOf),
+      languageOneOf: this.intoArray(this.languageOneOf),
+      tagsOneOf: this.intoArray(this.tagsOneOf),
+      tagsAllOf: this.intoArray(this.tagsAllOf),
+      durationMin: this.durationMin,
+      durationMax: this.durationMax,
+      sort: this.sort,
+      searchTarget: this.searchTarget
+    }
+  }
+
+  size () {
+    let acc = 0
+
+    const obj = this.toUrlObject()
+    for (const k of Object.keys(obj)) {
+      if (this.silentFilters.has(k)) continue
+
+      if (obj[k] !== undefined && obj[k] !== '') acc++
+    }
+
+    return acc
+  }
+
+  private intoArray (value: any) {
+    if (!value) return undefined
+    if (Array.isArray(value)) return value
+
+    if (typeof value === 'string') return value.split(',')
+
+    return [ value ]
+  }
+}
diff --git a/client/src/app/shared/shared-search/index.ts b/client/src/app/shared/shared-search/index.ts
new file mode 100644 (file)
index 0000000..f687f67
--- /dev/null
@@ -0,0 +1,3 @@
+export * from './advanced-search.model'
+export * from './search.service'
+export * from './shared-search.module'
diff --git a/client/src/app/shared/shared-search/search.service.ts b/client/src/app/shared/shared-search/search.service.ts
new file mode 100644 (file)
index 0000000..96b954c
--- /dev/null
@@ -0,0 +1,88 @@
+import { Observable } from 'rxjs'
+import { catchError, map, switchMap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
+import { peertubeLocalStorage } from '@app/helpers'
+import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { ResultList, SearchTargetType, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { AdvancedSearch } from './advanced-search.model'
+
+@Injectable()
+export class SearchService {
+  static BASE_SEARCH_URL = environment.apiUrl + '/api/v1/search/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor,
+    private restService: RestService,
+    private videoService: VideoService
+  ) {
+    // Add ability to override search endpoint if the user updated this local storage key
+    const searchUrl = peertubeLocalStorage.getItem('search-url')
+    if (searchUrl) SearchService.BASE_SEARCH_URL = searchUrl
+  }
+
+  searchVideos (parameters: {
+    search: string,
+    componentPagination?: ComponentPaginationLight,
+    advancedSearch?: AdvancedSearch
+  }): Observable<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))
+               )
+  }
+}
diff --git a/client/src/app/shared/shared-search/shared-search.module.ts b/client/src/app/shared/shared-search/shared-search.module.ts
new file mode 100644 (file)
index 0000000..134300d
--- /dev/null
@@ -0,0 +1,20 @@
+import { NgModule } from '@angular/core'
+import { SharedMainModule } from '../shared-main'
+import { SearchService } from './search.service'
+
+@NgModule({
+  imports: [
+    SharedMainModule
+  ],
+
+  declarations: [
+  ],
+
+  exports: [
+  ],
+
+  providers: [
+    SearchService
+  ]
+})
+export class SharedSearchModule { }
diff --git a/client/src/app/videos/+video-edit/shared/i18n-primeng-calendar.service.ts b/client/src/app/videos/+video-edit/shared/i18n-primeng-calendar.service.ts
deleted file mode 100644 (file)
index b05852f..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Injectable } from '@angular/core'
-
-@Injectable()
-export class I18nPrimengCalendarService {
-  private readonly calendarLocale: any = {}
-
-  constructor (private i18n: I18n) {
-    this.calendarLocale = {
-      firstDayOfWeek: 0,
-      dayNames: [
-        this.i18n('Sunday'),
-        this.i18n('Monday'),
-        this.i18n('Tuesday'),
-        this.i18n('Wednesday'),
-        this.i18n('Thursday'),
-        this.i18n('Friday'),
-        this.i18n('Saturday')
-      ],
-
-      dayNamesShort: [
-        this.i18n({ value: 'Sun', description: 'Day name short' }),
-        this.i18n({ value: 'Mon', description: 'Day name short' }),
-        this.i18n({ value: 'Tue', description: 'Day name short' }),
-        this.i18n({ value: 'Wed', description: 'Day name short' }),
-        this.i18n({ value: 'Thu', description: 'Day name short' }),
-        this.i18n({ value: 'Fri', description: 'Day name short' }),
-        this.i18n({ value: 'Sat', description: 'Day name short' })
-      ],
-
-      dayNamesMin: [
-        this.i18n({ value: 'Su', description: 'Day name min' }),
-        this.i18n({ value: 'Mo', description: 'Day name min' }),
-        this.i18n({ value: 'Tu', description: 'Day name min' }),
-        this.i18n({ value: 'We', description: 'Day name min' }),
-        this.i18n({ value: 'Th', description: 'Day name min' }),
-        this.i18n({ value: 'Fr', description: 'Day name min' }),
-        this.i18n({ value: 'Sa', description: 'Day name min' })
-      ],
-
-      monthNames: [
-        this.i18n('January'),
-        this.i18n('February'),
-        this.i18n('March'),
-        this.i18n('April'),
-        this.i18n('May'),
-        this.i18n('June'),
-        this.i18n('July'),
-        this.i18n('August'),
-        this.i18n('September'),
-        this.i18n('October'),
-        this.i18n('November'),
-        this.i18n('December')
-      ],
-
-      monthNamesShort: [
-        this.i18n({ value: 'Jan', description: 'Month name short' }),
-        this.i18n({ value: 'Feb', description: 'Month name short' }),
-        this.i18n({ value: 'Mar', description: 'Month name short' }),
-        this.i18n({ value: 'Apr', description: 'Month name short' }),
-        this.i18n({ value: 'May', description: 'Month name short' }),
-        this.i18n({ value: 'Jun', description: 'Month name short' }),
-        this.i18n({ value: 'Jul', description: 'Month name short' }),
-        this.i18n({ value: 'Aug', description: 'Month name short' }),
-        this.i18n({ value: 'Sep', description: 'Month name short' }),
-        this.i18n({ value: 'Oct', description: 'Month name short' }),
-        this.i18n({ value: 'Nov', description: 'Month name short' }),
-        this.i18n({ value: 'Dec', description: 'Month name short' })
-      ],
-
-      today: this.i18n('Today'),
-
-      clear: this.i18n('Clear')
-    }
-  }
-
-  getCalendarLocale () {
-    return this.calendarLocale
-  }
-
-  getTimezone () {
-    const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1]
-    const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
-
-    return `${timezone} - ${gmt}`
-  }
-
-  getDateFormat () {
-    return this.i18n({
-      value: 'yy-mm-dd ',
-      description: 'Date format in this locale.'
-    })
-  }
-}
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html
deleted file mode 100644 (file)
index 6a9e31b..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss
deleted file mode 100644 (file)
index b257a16..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.peertube-select-container {
-  @include peertube-select-container(auto);
-}
-
-.caption-file {
-  margin-top: 20px;
-  width: max-content;
-
-  ::ng-deep .root {
-    width: max-content;
-  }
-}
-
-.warning-replace-caption {
-  color: red;
-  margin-top: 10px;
-}
\ No newline at end of file
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts
deleted file mode 100644 (file)
index a90d04c..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { ServerService } from '@app/core'
-import { FormReactive, FormValidatorService, VideoCaptionsValidatorsService } from '@app/shared/shared-forms'
-import { VideoCaptionEdit } from '@app/shared/shared-main'
-import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { ServerConfig, VideoConstant } from '@shared/models'
-
-@Component({
-  selector: 'my-video-caption-add-modal',
-  styleUrls: [ './video-caption-add-modal.component.scss' ],
-  templateUrl: './video-caption-add-modal.component.html'
-})
-
-export class VideoCaptionAddModalComponent extends FormReactive implements OnInit {
-  @Input() existingCaptions: string[]
-  @Input() serverConfig: ServerConfig
-
-  @Output() captionAdded = new EventEmitter<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()
-  }
-}
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html
deleted file mode 100644 (file)
index c11a60d..0000000
+++ /dev/null
@@ -1,280 +0,0 @@
-<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 &#10004;</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>
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss
deleted file mode 100644 (file)
index 69b9072..0000000
+++ /dev/null
@@ -1,197 +0,0 @@
-// Bootstrap grid utilities require functions, variables and mixins
-@import 'node_modules/bootstrap/scss/functions';
-@import 'node_modules/bootstrap/scss/variables';
-@import 'node_modules/bootstrap/scss/mixins';
-@import 'node_modules/bootstrap/scss/grid';
-
-@import 'variables';
-@import 'mixins';
-
-label {
-  font-weight: $font-regular;
-  font-size: 100%;
-}
-
-.peertube-select-container {
-  @include peertube-select-container(auto);
-}
-
-.title-page a {
-  color: pvar(--mainForegroundColor);
-
-  &:hover {
-    text-decoration: none;
-    opacity: .8;
-  }
-}
-
-my-peertube-checkbox {
-  display: block;
-  margin-bottom: 1rem;
-}
-
-.nav-tabs {
-  margin-bottom: 15px;
-}
-
-.video-edit {
-  height: 100%;
-  min-height: 300px;
-
-  .form-group {
-    margin-bottom: 25px;
-  }
-
-  input {
-    @include peertube-input-text(100%);
-    display: block;
-  }
-
-  .label-tags + span {
-    font-size: 15px;
-  }
-
-  .advanced-settings .form-group {
-    margin-bottom: 20px;
-  }
-}
-
-.captions {
-
-  .captions-header {
-    text-align: right;
-    margin-bottom: 1rem;
-
-    .create-caption {
-      @include create-button;
-    }
-  }
-
-  .caption-entry {
-    display: flex;
-    height: 40px;
-    align-items: center;
-
-    a.caption-entry-label {
-      @include disable-default-a-behaviour;
-
-      flex-grow: 1;
-      color: #000;
-
-      &:hover {
-        opacity: 0.8;
-      }
-    }
-
-    .caption-entry-label {
-      font-size: 15px;
-      font-weight: bold;
-
-      margin-right: 20px;
-      width: 150px;
-    }
-
-    .caption-entry-state {
-      width: 200px;
-
-      &.caption-entry-state-create {
-        color: #39CC0B;
-      }
-
-      &.caption-entry-state-delete {
-        color: #FF0000;
-      }
-    }
-
-    .caption-entry-delete {
-      @include peertube-button;
-      @include grey-button;
-    }
-  }
-
-  .no-caption {
-    text-align: center;
-    font-size: 15px;
-  }
-}
-
-.submit-container {
-  text-align: right;
-
-  .message-submit {
-    display: inline-block;
-    margin-right: 25px;
-
-    color: pvar(--greyForegroundColor);
-    font-size: 15px;
-  }
-
-  .submit-button {
-    @include peertube-button;
-    @include orange-button;
-    @include button-with-icon(20px, 1px);
-
-    display: inline-block;
-
-    input {
-      cursor: inherit;
-      background-color: inherit;
-      border: none;
-      padding: 0;
-      outline: 0;
-      color: inherit;
-      font-weight: $font-semibold;
-    }
-  }
-}
-
-p-calendar {
-  display: block;
-
-  ::ng-deep {
-    input,
-    .ui-calendar {
-      width: 100%;
-    }
-
-    input {
-      @include peertube-input-text(100%);
-      color: #000;
-    }
-  }
-}
-
-@include ng2-tags;
-
-// columns for the video
-.col-video-edit {
-  @include make-col-ready();
-
-  @include media-breakpoint-up(md) {
-    @include make-col(7);
-
-    & + .col-video-edit {
-      @include make-col(5);
-    }
-  }
-
-  @include media-breakpoint-up(xl) {
-    @include make-col(8);
-
-    & + .col-video-edit {
-      @include make-col(4);
-    }
-  }
-}
-
-:host-context(.expanded) {
-  .col-video-edit {
-    @include media-breakpoint-up(md) {
-      @include make-col(8);
-
-      & + .col-video-edit {
-        @include make-col(4);
-      }
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
deleted file mode 100644 (file)
index 239e453..0000000
+++ /dev/null
@@ -1,274 +0,0 @@
-import { map } from 'rxjs/operators'
-import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
-import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
-import { ServerService } from '@app/core'
-import { removeElementFromArray } from '@app/helpers'
-import { FormReactiveValidationMessages, FormValidatorService, VideoValidatorsService } from '@app/shared/shared-forms'
-import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
-import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
-import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
-import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
-
-@Component({
-  selector: 'my-video-edit',
-  styleUrls: [ './video-edit.component.scss' ],
-  templateUrl: './video-edit.component.html'
-})
-export class VideoEditComponent implements OnInit, OnDestroy {
-  @Input() form: FormGroup
-  @Input() formErrors: { [ id: string ]: string } = {}
-  @Input() validationMessages: FormReactiveValidationMessages = {}
-  @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
-  @Input() schedulePublicationPossible = true
-  @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
-  @Input() waitTranscodingEnabled = true
-
-  @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
-
-  // So that it can be accessed in the template
-  readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
-
-  videoPrivacies: VideoConstant<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 || '' })
-  }
-}
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.module.ts b/client/src/app/videos/+video-edit/shared/video-edit.module.ts
deleted file mode 100644 (file)
index 96061a3..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import { TagInputModule } from 'ngx-chips'
-import { CalendarModule } from 'primeng/calendar'
-import { NgModule } from '@angular/core'
-import { SharedFormModule } from '@app/shared/shared-forms'
-import { SharedGlobalIconModule } from '@app/shared/shared-icons'
-import { SharedMainModule } from '@app/shared/shared-main'
-import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
-import { VideoEditComponent } from './video-edit.component'
-
-@NgModule({
-  imports: [
-    TagInputModule,
-    CalendarModule,
-
-    SharedMainModule,
-    SharedFormModule,
-    SharedGlobalIconModule
-  ],
-
-  declarations: [
-    VideoEditComponent,
-    VideoCaptionAddModalComponent
-  ],
-
-  exports: [
-    TagInputModule,
-    CalendarModule,
-
-    SharedMainModule,
-    SharedFormModule,
-    SharedGlobalIconModule,
-
-    VideoEditComponent
-  ],
-
-  providers: []
-})
-export class VideoEditModule { }
diff --git a/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts b/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts
deleted file mode 100644 (file)
index 7b1a38c..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Directive, Output, EventEmitter, HostBinding, HostListener } from '@angular/core'
-
-@Directive({
-  selector: '[dragDrop]'
-})
-export class DragDropDirective {
-  @Output() fileDropped = new EventEmitter<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)
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html
deleted file mode 100644 (file)
index 7287f79..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss
deleted file mode 100644 (file)
index 1fef749..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-@import 'variables';
-@import 'mixins';
-
-.first-step-block {
-  .torrent-or-magnet {
-    @include divider($color: pvar(--inputPlaceholderColor), $background: pvar(--submenuColor));
-    
-    &[data-content] {
-      margin: 1.5rem 0;
-    }
-  }
-
-  .form-group-magnet-uri {
-    margin-bottom: 40px;
-  }
-}
-
-
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts
deleted file mode 100644 (file)
index 5b453a1..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
-import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
-import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
-import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { LoadingBarService } from '@ngx-loading-bar/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPrivacy, VideoUpdate } from '@shared/models'
-
-@Component({
-  selector: 'my-video-import-torrent',
-  templateUrl: './video-import-torrent.component.html',
-  styleUrls: [
-    '../shared/video-edit.component.scss',
-    './video-import-torrent.component.scss',
-    './video-send.scss'
-  ]
-})
-export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate {
-  @Output() firstStepDone = new EventEmitter<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())
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html
deleted file mode 100644 (file)
index 1910da4..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts
deleted file mode 100644 (file)
index d0bd1f5..0000000
+++ /dev/null
@@ -1,178 +0,0 @@
-import { map, switchMap } from 'rxjs/operators'
-import { Component, EventEmitter, OnInit, Output } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
-import { getAbsoluteAPIUrl, scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
-import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
-import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { LoadingBarService } from '@ngx-loading-bar/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPrivacy, VideoUpdate } from '@shared/models'
-
-@Component({
-  selector: 'my-video-import-url',
-  templateUrl: './video-import-url.component.html',
-  styleUrls: [
-    '../shared/video-edit.component.scss',
-    './video-send.scss'
-  ]
-})
-export class VideoImportUrlComponent extends VideoSend implements OnInit, CanComponentDeactivate {
-  @Output() firstStepDone = new EventEmitter<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
-          })
-        })
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-send.scss b/client/src/app/videos/+video-edit/video-add-components/video-send.scss
deleted file mode 100644 (file)
index ebe14c5..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-@import 'variables';
-@import 'mixins';
-
-$width-size: 190px;
-
-.alert.alert-danger {
-  text-align: center;
-
-  & > div {
-    font-weight: $font-semibold;
-  }
-}
-
-.first-step-block {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-
-  .upload-icon {
-    width: 90px;
-    margin-bottom: 25px;
-
-    @include apply-svg-color(#C6C6C6);
-  }
-
-  .peertube-select-container {
-    @include peertube-select-container($width-size);
-  }
-
-  input[type=text] {
-    @include peertube-input-text($width-size);
-    display: block;
-  }
-
-  input[type=button] {
-    @include peertube-button;
-    @include orange-button;
-
-    width: $width-size;
-    margin-top: 30px;
-  }
-
-  .button-file {
-    @include peertube-button-file(max-content);
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-send.ts b/client/src/app/videos/+video-edit/video-add-components/video-send.ts
deleted file mode 100644 (file)
index 9447932..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-import { catchError, switchMap, tap } from 'rxjs/operators'
-import { EventEmitter, OnInit } from '@angular/core'
-import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
-import { populateAsyncUserVideoChannels } from '@app/helpers'
-import { FormReactive } from '@app/shared/shared-forms'
-import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
-import { LoadingBarService } from '@ngx-loading-bar/core'
-import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
-
-export abstract class VideoSend extends FormReactive implements OnInit {
-  userVideoChannels: { id: number, label: string, support: string }[] = []
-  videoPrivacies: VideoConstant<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
-          })
-        )
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
deleted file mode 100644 (file)
index dad88a6..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
deleted file mode 100644 (file)
index a4f87b0..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-@import 'variables';
-@import 'mixins';
-
-.first-step-block {
-  .form-group-channel {
-    margin-bottom: 20px;
-    margin-top: 35px;
-  }
-
-  .audio-image-info {
-    margin-bottom: 10px;
-  }
-
-  .audio-preview {
-    margin: 30px 0;
-  }
-}
-
-.upload-progress-cancel {
-  display: flex;
-  margin-top: 25px;
-  margin-bottom: 40px;
-
-  .progress {
-    @include progressbar;
-    flex-grow: 1;
-    height: 30px;
-    font-size: 15px;
-    background-color: rgba(11, 204, 41, 0.16);
-
-    .progress-bar {
-      background-color: $green;
-      line-height: 30px;
-      text-align: left;
-      font-weight: $font-bold;
-
-      span {
-        margin-left: 18px;
-      }
-    }
-  }
-
-  input {
-    @include peertube-button;
-    @include grey-button;
-
-    margin-left: 10px;
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
deleted file mode 100644 (file)
index eb7ac32..0000000
+++ /dev/null
@@ -1,306 +0,0 @@
-import { BytesPipe } from 'ngx-pipes'
-import { Subscription } from 'rxjs'
-import { HttpEventType, HttpResponse } from '@angular/common/http'
-import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, CanComponentDeactivate, Notifier, ServerService, UserService } from '@app/core'
-import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
-import { VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
-import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { LoadingBarService } from '@ngx-loading-bar/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPrivacy } from '@shared/models'
-
-@Component({
-  selector: 'my-video-upload',
-  templateUrl: './video-upload.component.html',
-  styleUrls: [
-    '../shared/video-edit.component.scss',
-    './video-upload.component.scss',
-    './video-send.scss'
-  ]
-})
-export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate {
-  @Output() firstStepDone = new EventEmitter<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))
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add-routing.module.ts b/client/src/app/videos/+video-edit/video-add-routing.module.ts
deleted file mode 100644 (file)
index 9ff66be..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { CanDeactivateGuard, LoginGuard } from '@app/core'
-import { MetaGuard } from '@ngx-meta/core'
-import { VideoAddComponent } from './video-add.component'
-
-const videoAddRoutes: Routes = [
-  {
-    path: '',
-    component: VideoAddComponent,
-    canActivate: [ MetaGuard, LoginGuard ],
-    canDeactivate: [ CanDeactivateGuard ]
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(videoAddRoutes) ],
-  exports: [ RouterModule ]
-})
-export class VideoAddRoutingModule {}
diff --git a/client/src/app/videos/+video-edit/video-add.component.html b/client/src/app/videos/+video-edit/video-add.component.html
deleted file mode 100644 (file)
index 79bfc6e..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-edit/video-add.component.scss b/client/src/app/videos/+video-edit/video-add.component.scss
deleted file mode 100644 (file)
index 0ad57d8..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-$border-width: 3px;
-$border-type: solid;
-$border-color: #EAEAEA;
-$nav-link-height: 40px;
-
-.margin-content {
-  padding-top: 50px;
-}
-
-.alert {
-  font-size: 15px;
-}
-
-::ng-deep .video-add-nav {
-  border-bottom: $border-width $border-type $border-color;
-  margin: 50px 0 0 0 !important;
-
-  &.hide-nav {
-    display: none !important;
-  }
-
-  a.nav-link {
-    @include disable-default-a-behaviour;
-
-    margin-bottom: -$border-width;
-    height: $nav-link-height !important;
-    padding: 0 30px !important;
-    font-size: 15px;
-
-    &.active {
-      border: $border-width $border-type $border-color;
-      border-bottom: none;
-      background-color: pvar(--submenuColor) !important;
-
-      span {
-        border-bottom: 2px solid pvar(--mainColor);
-        font-weight: $font-bold;
-      }
-    }
-  }
-}
-
-::ng-deep .upload-video-container {
-  border: $border-width $border-type $border-color;
-  border-top: transparent;
-
-  background-color: pvar(--submenuColor);
-  border-bottom-left-radius: 3px;
-  border-bottom-right-radius: 3px;
-  width: 100%;
-  min-height: 440px;
-  padding-bottom: 20px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-
-  &.dragover {
-    border: 3px dashed pvar(--mainColor);
-  }
-}
-
-@mixin nav-scroll {
-  ::ng-deep .video-add-nav {
-    height: #{$nav-link-height + $border-width * 2};
-    overflow-x: auto;
-    white-space: nowrap;
-    flex-wrap: unset;
-
-    /* Hide active tab style to not have a moving tab effect */
-    a.nav-link.active {
-      border: none;
-      background-color: pvar(--mainBackgroundColor) !important;
-    }
-  }
-}
-
-/* Make .video-add-nav tabs scrollable on small devices */
-@media screen and (max-width: $small-view) {
-  @include nav-scroll();
-}
-
-@media screen and (max-width: #{$small-view + $menu-width}) {
-  :host-context(.main-col:not(.expanded)) {
-    @include nav-scroll();
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts
deleted file mode 100644 (file)
index 5bd7688..0000000
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
-import { AuthService, CanComponentDeactivate, ServerService } from '@app/core'
-import { ServerConfig } from '@shared/models'
-import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
-import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
-import { VideoUploadComponent } from './video-add-components/video-upload.component'
-
-@Component({
-  selector: 'my-videos-add',
-  templateUrl: './video-add.component.html',
-  styleUrls: [ './video-add.component.scss' ]
-})
-export class VideoAddComponent implements OnInit, CanComponentDeactivate {
-  @ViewChild('videoUpload') videoUpload: VideoUploadComponent
-  @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent
-  @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent
-
-  secondStepType: 'upload' | 'import-url' | 'import-torrent'
-  videoName: string
-  serverConfig: ServerConfig
-
-  constructor (
-    private auth: AuthService,
-    private serverService: ServerService
-  ) {}
-
-  ngOnInit () {
-    this.serverConfig = this.serverService.getTmpConfig()
-
-    this.serverService.getConfig()
-      .subscribe(config => this.serverConfig = config)
-  }
-
-  onFirstStepDone (type: 'upload' | 'import-url' | 'import-torrent', videoName: string) {
-    this.secondStepType = type
-    this.videoName = videoName
-  }
-
-  onError () {
-    this.videoName = undefined
-    this.secondStepType = undefined
-  }
-
-  @HostListener('window:beforeunload', [ '$event' ])
-  onUnload (event: any) {
-    const { text, canDeactivate } = this.canDeactivate()
-
-    if (canDeactivate) return
-
-    event.returnValue = text
-    return text
-  }
-
-  canDeactivate (): { canDeactivate: boolean, text?: string} {
-    if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate()
-    if (this.secondStepType === 'import-url') return this.videoImportUrl.canDeactivate()
-    if (this.secondStepType === 'import-torrent') return this.videoImportTorrent.canDeactivate()
-
-    return { canDeactivate: true }
-  }
-
-  isVideoImportHttpEnabled () {
-    return this.serverConfig.import.videos.http.enabled
-  }
-
-  isVideoImportTorrentEnabled () {
-    return this.serverConfig.import.videos.torrent.enabled
-  }
-
-  isInSecondStep () {
-    return !!this.secondStepType
-  }
-
-  isRootUser () {
-    return this.auth.getUser().username === 'root'
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-add.module.ts b/client/src/app/videos/+video-edit/video-add.module.ts
deleted file mode 100644 (file)
index 477c1cf..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-import { NgModule } from '@angular/core'
-import { CanDeactivateGuard } from '@app/core'
-import { VideoEditModule } from './shared/video-edit.module'
-import { DragDropDirective } from './video-add-components/drag-drop.directive'
-import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
-import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
-import { VideoUploadComponent } from './video-add-components/video-upload.component'
-import { VideoAddRoutingModule } from './video-add-routing.module'
-import { VideoAddComponent } from './video-add.component'
-
-@NgModule({
-  imports: [
-    VideoAddRoutingModule,
-
-    VideoEditModule
-  ],
-
-  declarations: [
-    VideoAddComponent,
-    VideoUploadComponent,
-    VideoImportUrlComponent,
-    VideoImportTorrentComponent,
-    DragDropDirective
-  ],
-
-  exports: [ ],
-
-  providers: [
-    CanDeactivateGuard
-  ]
-})
-export class VideoAddModule { }
diff --git a/client/src/app/videos/+video-edit/video-update-routing.module.ts b/client/src/app/videos/+video-edit/video-update-routing.module.ts
deleted file mode 100644 (file)
index a04351b..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { CanDeactivateGuard, LoginGuard } from '@app/core'
-import { MetaGuard } from '@ngx-meta/core'
-import { VideoUpdateComponent } from './video-update.component'
-import { VideoUpdateResolver } from './video-update.resolver'
-
-const videoUpdateRoutes: Routes = [
-  {
-    path: '',
-    component: VideoUpdateComponent,
-    canActivate: [ MetaGuard, LoginGuard ],
-    canDeactivate: [ CanDeactivateGuard ],
-    resolve: {
-      videoData: VideoUpdateResolver
-    }
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(videoUpdateRoutes) ],
-  exports: [ RouterModule ]
-})
-export class VideoUpdateRoutingModule {}
diff --git a/client/src/app/videos/+video-edit/video-update.component.html b/client/src/app/videos/+video-edit/video-update.component.html
deleted file mode 100644 (file)
index fbc642d..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts
deleted file mode 100644 (file)
index 7bd6eb5..0000000
+++ /dev/null
@@ -1,155 +0,0 @@
-import { map, switchMap } from 'rxjs/operators'
-import { Component, HostListener, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
-import { LoadingBarService } from '@ngx-loading-bar/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPrivacy } from '@shared/models'
-
-@Component({
-  selector: 'my-videos-update',
-  styleUrls: [ './shared/video-edit.component.scss' ],
-  templateUrl: './video-update.component.html'
-})
-export class VideoUpdateComponent extends FormReactive implements OnInit {
-  video: VideoEdit
-
-  isUpdatingVideo = false
-  userVideoChannels: { id: number, label: string, support: string }[] = []
-  schedulePublicationPossible = false
-  videoCaptions: VideoCaptionEdit[] = []
-  waitTranscodingEnabled = true
-
-  private updateDone = false
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private route: ActivatedRoute,
-    private router: Router,
-    private notifier: Notifier,
-    private videoService: VideoService,
-    private loadingBar: LoadingBarService,
-    private videoCaptionService: VideoCaptionService,
-    private i18n: I18n
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    this.buildForm({})
-
-    this.route.data
-        .pipe(map(data => data.videoData))
-        .subscribe(({ video, videoChannels, videoCaptions }) => {
-          this.video = new VideoEdit(video)
-          this.userVideoChannels = videoChannels
-          this.videoCaptions = videoCaptions
-
-          this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
-
-          const videoFiles = (video as VideoDetails).getFiles()
-          if (videoFiles.length > 1) { // Already transcoded
-            this.waitTranscodingEnabled = false
-          }
-
-          // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout
-          setTimeout(() => this.hydrateFormFromVideo())
-        },
-
-        err => {
-          console.error(err)
-          this.notifier.error(err.message)
-        }
-      )
-  }
-
-  @HostListener('window:beforeunload', [ '$event' ])
-  onUnload (event: any) {
-    const { text, canDeactivate } = this.canDeactivate()
-
-    if (canDeactivate) return
-
-    event.returnValue = text
-    return text
-  }
-
-  canDeactivate (): { canDeactivate: boolean, text?: string } {
-    if (this.updateDone === true) return { canDeactivate: true }
-
-    const text = this.i18n('You have unsaved changes! If you leave, your changes will be lost.')
-
-    for (const caption of this.videoCaptions) {
-      if (caption.action) return { canDeactivate: false, text }
-    }
-
-    return { canDeactivate: this.formChanged === false, text }
-  }
-
-  checkForm () {
-    this.forceCheck()
-
-    return this.form.valid
-  }
-
-  update () {
-    if (this.checkForm() === false
-      || this.isUpdatingVideo === true) {
-      return
-    }
-
-    this.video.patch(this.form.value)
-
-    this.loadingBar.start()
-    this.isUpdatingVideo = true
-
-    // Update the video
-    this.videoService.updateVideo(this.video)
-        .pipe(
-          // Then update captions
-          switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
-        )
-        .subscribe(
-          () => {
-            this.updateDone = true
-            this.isUpdatingVideo = false
-            this.loadingBar.complete()
-            this.notifier.success(this.i18n('Video updated.'))
-            this.router.navigate([ '/videos/watch', this.video.uuid ])
-          },
-
-          err => {
-            this.loadingBar.complete()
-            this.isUpdatingVideo = false
-            this.notifier.error(err.message)
-            console.error(err)
-          }
-        )
-  }
-
-  private hydrateFormFromVideo () {
-    this.form.patchValue(this.video.toFormPatch())
-
-    const objects = [
-      {
-        url: 'thumbnailUrl',
-        name: 'thumbnailfile'
-      },
-      {
-        url: 'previewUrl',
-        name: 'previewfile'
-      }
-    ]
-
-    for (const obj of objects) {
-      fetch(this.video[obj.url])
-        .then(response => response.blob())
-        .then(data => {
-          this.form.patchValue({
-            [ obj.name ]: data
-          })
-        })
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-edit/video-update.module.ts b/client/src/app/videos/+video-edit/video-update.module.ts
deleted file mode 100644 (file)
index 322c696..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-import { NgModule } from '@angular/core'
-import { CanDeactivateGuard } from '@app/core'
-import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver'
-import { VideoEditModule } from './shared/video-edit.module'
-import { VideoUpdateRoutingModule } from './video-update-routing.module'
-import { VideoUpdateComponent } from './video-update.component'
-
-@NgModule({
-  imports: [
-    VideoUpdateRoutingModule,
-
-    VideoEditModule
-  ],
-
-  declarations: [
-    VideoUpdateComponent
-  ],
-
-  exports: [ ],
-
-  providers: [
-    VideoUpdateResolver,
-    CanDeactivateGuard
-  ]
-})
-export class VideoUpdateModule { }
diff --git a/client/src/app/videos/+video-edit/video-update.resolver.ts b/client/src/app/videos/+video-edit/video-update.resolver.ts
deleted file mode 100644 (file)
index 30bcf4d..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-import { forkJoin } from 'rxjs'
-import { map, switchMap } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
-import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main'
-
-@Injectable()
-export class VideoUpdateResolver implements Resolve<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 }))
-               )
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html
deleted file mode 100644 (file)
index 9b43d91..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss
deleted file mode 100644 (file)
index b3725ab..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-form {
-  margin-bottom: 30px;
-}
-
-.avatar-and-textarea {
-  display: flex;
-  margin-bottom: 10px;
-
-  img {
-    @include avatar(25px);
-
-    vertical-align: top;
-    margin-right: 10px;
-  }
-
-  .form-group {
-    flex-grow: 1;
-    margin: 0;
-
-    textarea {
-      @include peertube-textarea(100%, 60px);
-
-      &:focus::placeholder {
-        opacity: 0;
-      }
-    }
-  }
-}
-
-.comment-buttons {
-  display: flex;
-  justify-content: flex-end;
-
-  button {
-    @include peertube-button;
-    @include disable-outline;
-    @include disable-default-a-behaviour;
-
-    &:not(:last-child) {
-      margin-right: .5rem;
-    }
-
-    &:last-child {
-      @include orange-button;
-    }
-  }
-
-  .cancel-button {
-    @include tertiary-button;
-
-    font-weight: $font-semibold;
-    display: inline-block;
-    padding: 0 10px 0 10px;
-    white-space: nowrap;
-    background: transparent;
-  }
-}
-
-@media screen and (max-width: 600px) {
-  textarea, .comment-buttons button {
-    font-size: 14px !important;
-  }
-
-  textarea {
-    padding: 5px !important;
-  }
-}
-
-.modal-body {
-  .btn {
-    @include peertube-button;
-    @include orange-button;
-  }
-
-  span {
-    float: left;
-    margin-bottom: 20px;
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
deleted file mode 100644 (file)
index 79505c7..0000000
+++ /dev/null
@@ -1,149 +0,0 @@
-import { Observable } from 'rxjs'
-import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { Router } from '@angular/router'
-import { Notifier, User } from '@app/core'
-import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
-import { Video } from '@app/shared/shared-main'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { VideoCommentCreate } from '@shared/models'
-import { VideoComment } from './video-comment.model'
-import { VideoCommentService } from './video-comment.service'
-
-@Component({
-  selector: 'my-video-comment-add',
-  templateUrl: './video-comment-add.component.html',
-  styleUrls: ['./video-comment-add.component.scss']
-})
-export class VideoCommentAddComponent extends FormReactive implements OnInit {
-  @Input() user: User
-  @Input() video: Video
-  @Input() parentComment: VideoComment
-  @Input() parentComments: VideoComment[]
-  @Input() focusOnInit = false
-
-  @Output() commentCreated = new EventEmitter<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)
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts
deleted file mode 100644 (file)
index 7c2aaea..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
-import { VideoComment } from './video-comment.model'
-
-export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
-  comment: VideoComment
-  children: VideoCommentThreadTree[]
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.html b/client/src/app/videos/+video-watch/comment/video-comment.component.html
deleted file mode 100644 (file)
index 002de57..0000000
+++ /dev/null
@@ -1,95 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.scss b/client/src/app/videos/+video-watch/comment/video-comment.component.scss
deleted file mode 100644 (file)
index e7ef795..0000000
+++ /dev/null
@@ -1,189 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.root-comment {
-  font-size: 15px;
-  display: flex;
-
-  .left {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    margin-right: 10px;
-
-    .vertical-border {
-      width: 2px;
-      height: 100%;
-      background-color: rgba(0, 0, 0, 0.05);
-      margin: 10px calc(1rem + 1px);
-    }
-  }
-
-  .right {
-    width: 100%;
-  }
-
-  .comment-avatar {
-    @include avatar(36px);
-  }
-
-  .comment {
-    flex-grow: 1;
-    // Fix word-wrap with flex
-    min-width: 1px;
-
-    .highlighted-comment {
-      display: inline-block;
-      background-color: #F5F5F5;
-      color: #3d3d3d;
-      padding: 0 5px;
-      font-size: 13px;
-      margin-bottom: 5px;
-      font-weight: $font-semibold;
-      border-radius: 3px;
-    }
-
-    .comment-account-date {
-      display: flex;
-      margin-bottom: 4px;
-
-      .video-author {
-        height: 20px;
-        background-color: #888888;
-        border-radius: 12px;
-        margin-bottom: 2px;
-        max-width: 100%;
-        box-sizing: border-box;
-        flex-direction: row;
-        align-items: center;
-        display: inline-flex;
-        padding-right: 6px;
-        padding-left: 6px;
-        color: white !important;
-      }
-
-      .comment-account {
-        word-break: break-all;
-        font-weight: 600;
-        font-size: 90%;
-
-        a {
-          @include disable-default-a-behaviour;
-
-          color: pvar(--mainForegroundColor);
-        }
-
-        .comment-account-fid {
-          opacity: .6;
-        }
-      }
-
-      .comment-date {
-        font-size: 90%;
-        color: pvar(--greyForegroundColor);
-        margin-left: 5px;
-        text-decoration: none;
-      }
-    }
-
-    .comment-html {
-      @include peertube-word-wrap;
-
-      // Mentions
-      ::ng-deep a {
-
-        &:not(.linkified-url) {
-          @include disable-default-a-behaviour;
-
-          color: pvar(--mainForegroundColor);
-
-          font-weight: $font-semibold;
-        }
-
-      }
-
-      // Paragraphs
-      ::ng-deep p {
-        margin-bottom: .3rem;
-      }
-
-      &.comment-html-deleted {
-        color: pvar(--greyForegroundColor);
-        margin-bottom: 1rem;
-      }
-    }
-
-    .comment-actions {
-      margin-bottom: 10px;
-      display: flex;
-
-      ::ng-deep .dropdown-toggle,
-      .comment-action-reply,
-      .comment-action-delete {
-        color: pvar(--greyForegroundColor);
-        cursor: pointer;
-        margin-right: 10px;
-
-        &:hover {
-          color: pvar(--mainForegroundColor);
-        }
-      }
-
-      ::ng-deep .action-button {
-        background-color: transparent;
-        padding: 0;
-        font-weight: unset;
-      }
-    }
-
-    my-video-comment-add {
-      ::ng-deep form {
-        margin-top: 1rem;
-        margin-bottom: 0;
-      }
-    }
-  }
-
-  .children {
-    // Reduce avatars size for replies
-    .comment-avatar {
-      @include avatar(25px);
-    }
-
-    .left {
-      margin-right: 6px;
-    }
-  }
-}
-
-@media screen and (max-width: 1200px) {
-  .children {
-    margin-left: -10px;
-  }
-}
-
-@media screen and (max-width: 600px) {
-  .root-comment {
-    .children {
-      margin-left: -20px;
-
-      .left {
-        align-items: flex-start;
-
-        .vertical-border {
-          margin-left: 2px;
-        }
-      }
-    }
-
-    .comment {
-      .comment-account-date {
-        flex-direction: column;
-
-        .comment-date {
-          margin-left: 0;
-        }
-      }
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
deleted file mode 100644 (file)
index 27846c1..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
-import { MarkdownService, Notifier, UserService } from '@app/core'
-import { AuthService } from '@app/core/auth'
-import { Account, Actor, Video } from '@app/shared/shared-main'
-import { User, UserRight } from '@shared/models'
-import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
-import { VideoComment } from './video-comment.model'
-
-@Component({
-  selector: 'my-video-comment',
-  templateUrl: './video-comment.component.html',
-  styleUrls: ['./video-comment.component.scss']
-})
-export class VideoCommentComponent implements OnInit, OnChanges {
-  @Input() video: Video
-  @Input() comment: VideoComment
-  @Input() parentComments: VideoComment[] = []
-  @Input() commentTree: VideoCommentThreadTree
-  @Input() inReplyToCommentId: number
-  @Input() highlightedComment = false
-  @Input() firstInThread = false
-
-  @Output() wantedToDelete = new EventEmitter<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
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.model.ts b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
deleted file mode 100644 (file)
index e854431..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-import { getAbsoluteAPIUrl } from '@app/helpers'
-import { Actor } from '@app/shared/shared-main'
-import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models'
-
-export class VideoComment implements VideoCommentServerModel {
-  id: number
-  url: string
-  text: string
-  threadId: number
-  inReplyToCommentId: number
-  videoId: number
-  createdAt: Date | string
-  updatedAt: Date | string
-  deletedAt: Date | string
-  isDeleted: boolean
-  account: AccountInterface
-  totalRepliesFromVideoAuthor: number
-  totalReplies: number
-  by: string
-  accountAvatarUrl: string
-
-  isLocal: boolean
-
-  constructor (hash: VideoCommentServerModel) {
-    this.id = hash.id
-    this.url = hash.url
-    this.text = hash.text
-    this.threadId = hash.threadId
-    this.inReplyToCommentId = hash.inReplyToCommentId
-    this.videoId = hash.videoId
-    this.createdAt = new Date(hash.createdAt.toString())
-    this.updatedAt = new Date(hash.updatedAt.toString())
-    this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null
-    this.isDeleted = hash.isDeleted
-    this.account = hash.account
-    this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor
-    this.totalReplies = hash.totalReplies
-
-    if (this.account) {
-      this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
-      this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
-
-      const absoluteAPIUrl = getAbsoluteAPIUrl()
-      const thisHost = new URL(absoluteAPIUrl).host
-      this.isLocal = this.account.host.trim() === thisHost
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.service.ts b/client/src/app/videos/+video-watch/comment/video-comment.service.ts
deleted file mode 100644 (file)
index a73fb9c..0000000
+++ /dev/null
@@ -1,149 +0,0 @@
-import { Observable } from 'rxjs'
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
-import { objectLineFeedToHtml } from '@app/helpers'
-import {
-  FeedFormat,
-  ResultList,
-  VideoComment as VideoCommentServerModel,
-  VideoCommentCreate,
-  VideoCommentThreadTree as VideoCommentThreadTreeServerModel
-} from '@shared/models'
-import { environment } from '../../../../environments/environment'
-import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
-import { VideoComment } from './video-comment.model'
-
-@Injectable()
-export class VideoCommentService {
-  private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
-  private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor,
-    private restService: RestService
-  ) {}
-
-  addCommentThread (videoId: number | string, comment: VideoCommentCreate) {
-    const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
-    const normalizedComment = objectLineFeedToHtml(comment, 'text')
-
-    return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
-               .pipe(
-                  map(data => this.extractVideoComment(data.comment)),
-                  catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) {
-    const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
-    const normalizedComment = objectLineFeedToHtml(comment, 'text')
-
-    return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
-               .pipe(
-                 map(data => this.extractVideoComment(data.comment)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
-  getVideoCommentThreads (parameters: {
-    videoId: number | string,
-    componentPagination: ComponentPaginationLight,
-    sort: string
-  }): Observable<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
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.html b/client/src/app/videos/+video-watch/comment/video-comments.component.html
deleted file mode 100644 (file)
index dd1d435..0000000
+++ /dev/null
@@ -1,98 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.scss b/client/src/app/videos/+video-watch/comment/video-comments.component.scss
deleted file mode 100644 (file)
index df42fae..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-#highlighted-comment {
-  margin-bottom: 25px;
-}
-
-.view-replies {
-  font-weight: $font-semibold;
-  font-size: 15px;
-  cursor: pointer;
-}
-
-.glyphicon, .comment-thread-loading {
-  margin-right: 5px;
-  display: inline-block;
-  font-size: 13px;
-}
-
-.title-block {
-  .title-page {
-    margin-right: 0;
-  }
-
-  my-feed {
-    display: inline-block;
-    margin-left: 5px;
-    opacity: 0;
-    transition: ease-in .2s opacity;
-  }
-  &:hover my-feed {
-    opacity: 1;
-  }
-}
-
-#dropdown-sort-comments {
-  font-weight: 600;
-  text-transform: uppercase;
-  border: none;
-  transform: translateY(-7%);
-}
-
-@media screen and (max-width: 600px) {
-  .view-replies {
-    margin-left: 46px;
-  }
-}
-
-@media screen and (max-width: 450px) {
-  .view-replies {
-    font-size: 14px;
-  }
-}
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
deleted file mode 100644 (file)
index df0018e..0000000
+++ /dev/null
@@ -1,232 +0,0 @@
-import { Subject, Subscription } from 'rxjs'
-import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
-import { ActivatedRoute } from '@angular/router'
-import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { Syndication, VideoDetails } from '@app/shared/shared-main'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
-import { VideoComment } from './video-comment.model'
-import { VideoCommentService } from './video-comment.service'
-
-@Component({
-  selector: 'my-video-comments',
-  templateUrl: './video-comments.component.html',
-  styleUrls: ['./video-comments.component.scss']
-})
-export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
-  @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
-  @Input() video: VideoDetails
-  @Input() user: User
-
-  @Output() timestampClicked = new EventEmitter<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)
-  }
-}
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html
deleted file mode 100644 (file)
index 5e6a2d5..0000000
+++ /dev/null
@@ -1,187 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss
deleted file mode 100644 (file)
index 091d4dc..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-@import '_mixins';
-@import '_variables';
-
-my-input-readonly-copy {
-  width: 100%;
-}
-
-.title-page.title-page-single {
-  margin-top: 0;
-}
-
-.playlist {
-  margin-bottom: 50px;
-}
-
-.peertube-select-container {
-  @include peertube-select-container(200px);
-}
-
-.qr-code-group {
-  text-align: center;
-}
-
-.nav-content {
-  margin-top: 30px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: column;
-}
-
-.alert {
-  margin-top: 20px;
-}
-
-.filters {
-  margin-top: 30px;
-
-  .advanced-filters-button {
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    margin-top: 20px;
-    font-size: 16px;
-    font-weight: $font-semibold;
-    cursor: pointer;
-
-    .glyphicon {
-      margin-right: 5px;
-    }
-  }
-
-  .form-group {
-    margin-bottom: 0;
-    height: 34px;
-    display: flex;
-    align-items: center;
-  }
-
-  .video-caption-block {
-    display: flex;
-    align-items: center;
-
-    .peertube-select-container {
-      margin-left: 10px;
-    }
-  }
-
-  .start-at,
-  .stop-at {
-    width: 300px;
-    display: flex;
-    align-items: center;
-
-    my-timestamp-input {
-      margin-left: 10px;
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts
deleted file mode 100644 (file)
index b42b775..0000000
+++ /dev/null
@@ -1,126 +0,0 @@
-import { Component, ElementRef, Input, ViewChild } from '@angular/core'
-import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { VideoCaption } from '@shared/models'
-import { VideoDetails } from '@app/shared/shared-main'
-import { VideoPlaylist } from '@app/shared/shared-video-playlist'
-
-type Customizations = {
-  startAtCheckbox: boolean
-  startAt: number
-
-  stopAtCheckbox: boolean
-  stopAt: number
-
-  subtitleCheckbox: boolean
-  subtitle: string
-
-  loop: boolean
-  autoplay: boolean
-  muted: boolean
-  title: boolean
-  warningTitle: boolean
-  controls: boolean
-}
-
-@Component({
-  selector: 'my-video-share',
-  templateUrl: './video-share.component.html',
-  styleUrls: [ './video-share.component.scss' ]
-})
-export class VideoShareComponent {
-  @ViewChild('modal', { static: true }) modal: ElementRef
-
-  @Input() video: VideoDetails = null
-  @Input() videoCaptions: VideoCaption[] = []
-  @Input() playlist: VideoPlaylist = null
-
-  activeId: 'url' | 'qrcode' | 'embed' = 'url'
-  customizations: Customizations
-  isAdvancedCustomizationCollapsed = true
-  includeVideoInPlaylist = false
-
-  constructor (private modalService: NgbModal) { }
-
-  show (currentVideoTimestamp?: number) {
-    let subtitle: string
-    if (this.videoCaptions.length !== 0) {
-      subtitle = this.videoCaptions[0].language.id
-    }
-
-    this.customizations = {
-      startAtCheckbox: false,
-      startAt: currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0,
-
-      stopAtCheckbox: false,
-      stopAt: this.video.duration,
-
-      subtitleCheckbox: false,
-      subtitle,
-
-      loop: false,
-      autoplay: false,
-      muted: false,
-
-      // Embed options
-      title: true,
-      warningTitle: true,
-      controls: true
-    }
-
-    this.modalService.open(this.modal, { centered: true })
-  }
-
-  getVideoIframeCode () {
-    const options = this.getOptions(this.video.embedUrl)
-
-    const embedUrl = buildVideoLink(options)
-    return buildVideoEmbed(embedUrl)
-  }
-
-  getVideoUrl () {
-    const baseUrl = window.location.origin + '/videos/watch/' + this.video.uuid
-    const options = this.getOptions(baseUrl)
-
-    return buildVideoLink(options)
-  }
-
-  getPlaylistUrl () {
-    const base = window.location.origin + '/videos/watch/playlist/' + this.playlist.uuid
-
-    if (!this.includeVideoInPlaylist) return base
-
-    return base + '?videoId=' + this.video.uuid
-  }
-
-  notSecure () {
-    return window.location.protocol === 'http:'
-  }
-
-  isInEmbedTab () {
-    return this.activeId === 'embed'
-  }
-
-  hasPlaylist () {
-    return !!this.playlist
-  }
-
-  private getOptions (baseUrl?: string) {
-    return {
-      baseUrl,
-
-      startTime: this.customizations.startAtCheckbox ? this.customizations.startAt : undefined,
-      stopTime: this.customizations.stopAtCheckbox ? this.customizations.stopAt : undefined,
-
-      subtitle: this.customizations.subtitleCheckbox ? this.customizations.subtitle : undefined,
-
-      loop: this.customizations.loop,
-      autoplay: this.customizations.autoplay,
-      muted: this.customizations.muted,
-
-      title: this.customizations.title,
-      warningTitle: this.customizations.warningTitle,
-      controls: this.customizations.controls
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.html b/client/src/app/videos/+video-watch/modal/video-support.component.html
deleted file mode 100644 (file)
index 935656d..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.scss b/client/src/app/videos/+video-watch/modal/video-support.component.scss
deleted file mode 100644 (file)
index 184e090..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-.action-button-cancel {
-  margin-right: 0 !important;
-}
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.ts b/client/src/app/videos/+video-watch/modal/video-support.component.ts
deleted file mode 100644 (file)
index 48d5f29..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Component, Input, ViewChild } from '@angular/core'
-import { MarkdownService } from '@app/core'
-import { VideoDetails } from '@app/shared/shared-main'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-
-@Component({
-  selector: 'my-video-support',
-  templateUrl: './video-support.component.html',
-  styleUrls: [ './video-support.component.scss' ]
-})
-export class VideoSupportComponent {
-  @Input() video: VideoDetails = null
-
-  @ViewChild('modal', { static: true }) modal: NgbModal
-
-  videoHTMLSupport = ''
-
-  constructor (
-    private markdownService: MarkdownService,
-    private modalService: NgbModal
-  ) { }
-
-  show () {
-    this.modalService.open(this.modal, { centered: true })
-
-    this.markdownService.enhancedMarkdownToHTML(this.video.support)
-      .then(r => this.videoHTMLSupport = r)
-  }
-}
diff --git a/client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts b/client/src/app/videos/+video-watch/timestamp-route-transformer.directive.ts
deleted file mode 100644 (file)
index 45e0236..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-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()
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-duration-formatter.pipe.ts b/client/src/app/videos/+video-watch/video-duration-formatter.pipe.ts
deleted file mode 100644 (file)
index 4b67674..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Pipe({
-  name: 'myVideoDurationFormatter'
-})
-export class VideoDurationPipe implements PipeTransform {
-
-  constructor (private i18n: I18n) {
-
-  }
-
-  transform (value: number): string {
-    const hours = Math.floor(value / 3600)
-    const minutes = Math.floor((value % 3600) / 60)
-    const seconds = value % 60
-
-    if (hours > 0) {
-      return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds })
-    }
-
-    if (minutes > 0) {
-      return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds })
-    }
-
-    return this.i18n('{{seconds}} sec', { seconds })
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.html b/client/src/app/videos/+video-watch/video-watch-playlist.component.html
deleted file mode 100644 (file)
index 246ef83..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.scss b/client/src/app/videos/+video-watch/video-watch-playlist.component.scss
deleted file mode 100644 (file)
index 0b0a2a8..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_bootstrap-variables';
-@import '_miniature';
-
-.playlist {
-  min-width: 200px;
-  max-width: 470px;
-  height: 66vh;
-  background-color: pvar(--mainBackgroundColor);
-  overflow-y: auto;
-  border-bottom: 1px solid $separator-border-color;
-
-  .playlist-info {
-    padding: 5px 30px;
-    background-color: #e4e4e4;
-
-    .playlist-display-name {
-      font-size: 18px;
-      font-weight: $font-semibold;
-      margin-bottom: 5px;
-    }
-
-    .playlist-by-index {
-      color: pvar(--greyForegroundColor);
-      display: flex;
-
-      .playlist-by {
-        margin-right: 5px;
-      }
-
-      .playlist-index span:first-child::after {
-        content: '/';
-        margin: 0 3px;
-      }
-    }
-
-    .playlist-controls {
-      display: flex;
-      margin: 10px 0;
-
-      my-global-icon:not(:last-child) {
-        margin-right: .5rem;
-      }
-
-      my-global-icon {
-        &:not(.active) {
-          opacity: .5
-        }
-
-        ::ng-deep {
-          cursor: pointer;
-        }
-      }
-    }
-  }
-
-  my-video-playlist-element-miniature {
-    ::ng-deep {
-      .video {
-        .position {
-          margin-right: 0;
-        }
-
-        .video-info {
-          .video-info-name {
-            font-size: 15px;
-          }
-        }
-      }
-
-      my-video-thumbnail {
-        @include thumbnail-size-component(90px, 50px);
-      }
-
-      .fake-thumbnail {
-        width: 90px;
-        height: 50px;
-      }
-    }
-  }
-}
-
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
deleted file mode 100644 (file)
index 2c21be6..0000000
+++ /dev/null
@@ -1,201 +0,0 @@
-import { Component, Input } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core'
-import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage'
-import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
-
-@Component({
-  selector: 'my-video-watch-playlist',
-  templateUrl: './video-watch-playlist.component.html',
-  styleUrls: [ './video-watch-playlist.component.scss' ]
-})
-export class VideoWatchPlaylistComponent {
-  static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist'
-  static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist'
-
-  @Input() video: VideoDetails
-  @Input() playlist: VideoPlaylist
-
-  playlistElements: VideoPlaylistElement[] = []
-  playlistPagination: ComponentPagination = {
-    currentPage: 1,
-    itemsPerPage: 30,
-    totalItems: null
-  }
-
-  autoPlayNextVideoPlaylist: boolean
-  autoPlayNextVideoPlaylistSwitchText = ''
-  loopPlaylist: boolean
-  loopPlaylistSwitchText = ''
-  noPlaylistVideos = false
-  currentPlaylistPosition = 1
-
-  constructor (
-    private userService: UserService,
-    private auth: AuthService,
-    private notifier: Notifier,
-    private i18n: I18n,
-    private videoPlaylist: VideoPlaylistService,
-    private localStorageService: LocalStorageService,
-    private sessionStorageService: SessionStorageService,
-    private router: Router
-  ) {
-    // defaults to true
-    this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn()
-      ? this.auth.getUser().autoPlayNextVideoPlaylist
-      : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false'
-    this.setAutoPlayNextVideoPlaylistSwitchText()
-
-    // defaults to false
-    this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true'
-    this.setLoopPlaylistSwitchText()
-  }
-
-  onPlaylistVideosNearOfBottom () {
-    // Last page
-    if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return
-
-    this.playlistPagination.currentPage += 1
-    this.loadPlaylistElements(this.playlist,false)
-  }
-
-  onElementRemoved (playlistElement: VideoPlaylistElement) {
-    this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id)
-
-    this.playlistPagination.totalItems--
-  }
-
-  isPlaylistOwned () {
-    return this.playlist.isLocal === true &&
-      this.auth.isLoggedIn() &&
-      this.playlist.ownerAccount.name === this.auth.getUser().username
-  }
-
-  isUnlistedPlaylist () {
-    return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED
-  }
-
-  isPrivatePlaylist () {
-    return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
-  }
-
-  isPublicPlaylist () {
-    return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC
-  }
-
-  loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) {
-    this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination)
-        .subscribe(({ total, data }) => {
-          this.playlistElements = this.playlistElements.concat(data)
-          this.playlistPagination.totalItems = total
-
-          const firstAvailableVideos = this.playlistElements.find(e => !!e.video)
-          if (!firstAvailableVideos) {
-            this.noPlaylistVideos = true
-            return
-          }
-
-          this.updatePlaylistIndex(this.video)
-
-          if (redirectToFirst) {
-            const extras = {
-              queryParams: {
-                start: firstAvailableVideos.startTimestamp,
-                stop: firstAvailableVideos.stopTimestamp,
-                videoId: firstAvailableVideos.video.uuid
-              },
-              replaceUrl: true
-            }
-            this.router.navigate([], extras)
-          }
-        })
-  }
-
-  updatePlaylistIndex (video: VideoDetails) {
-    if (this.playlistElements.length === 0 || !video) return
-
-    for (const playlistElement of this.playlistElements) {
-      if (playlistElement.video && playlistElement.video.id === video.id) {
-        this.currentPlaylistPosition = playlistElement.position
-        return
-      }
-    }
-
-    // Load more videos to find our video
-    this.onPlaylistVideosNearOfBottom()
-  }
-
-  findNextPlaylistVideo (position = this.currentPlaylistPosition): VideoPlaylistElement {
-    if (this.currentPlaylistPosition >= this.playlistPagination.totalItems) {
-      // we have reached the end of the playlist: either loop or stop
-      if (this.loopPlaylist) {
-        this.currentPlaylistPosition = position = 0
-      } else {
-        return
-      }
-    }
-
-    const next = this.playlistElements.find(e => e.position === position)
-
-    if (!next || !next.video) {
-      return this.findNextPlaylistVideo(position + 1)
-    }
-
-    return next
-  }
-
-  navigateToNextPlaylistVideo () {
-    const next = this.findNextPlaylistVideo(this.currentPlaylistPosition + 1)
-    if (!next) return
-    const start = next.startTimestamp
-    const stop = next.stopTimestamp
-    this.router.navigate([],{ queryParams: { videoId: next.video.uuid, start, stop } })
-  }
-
-  switchAutoPlayNextVideoPlaylist () {
-    this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist
-    this.setAutoPlayNextVideoPlaylistSwitchText()
-
-    peertubeLocalStorage.setItem(
-      VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST,
-      this.autoPlayNextVideoPlaylist.toString()
-    )
-
-    if (this.auth.isLoggedIn()) {
-      const details = {
-        autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist
-      }
-
-      this.userService.updateMyProfile(details).subscribe(
-        () => {
-          this.auth.refreshUserInformation()
-        },
-        err => this.notifier.error(err.message)
-      )
-    }
-  }
-
-  switchLoopPlaylist () {
-    this.loopPlaylist = !this.loopPlaylist
-    this.setLoopPlaylistSwitchText()
-
-    peertubeSessionStorage.setItem(
-      VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST,
-      this.loopPlaylist.toString()
-    )
-  }
-
-  private setAutoPlayNextVideoPlaylistSwitchText () {
-    this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist
-      ? this.i18n('Stop autoplaying next video')
-      : this.i18n('Autoplay next video')
-  }
-
-  private setLoopPlaylistSwitchText () {
-    this.loopPlaylistSwitchText = this.loopPlaylist
-      ? this.i18n('Stop looping playlist videos')
-      : this.i18n('Loop playlist videos')
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-watch-routing.module.ts b/client/src/app/videos/+video-watch/video-watch-routing.module.ts
deleted file mode 100644 (file)
index d8fecb8..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { MetaGuard } from '@ngx-meta/core'
-import { VideoWatchComponent } from './video-watch.component'
-
-const videoWatchRoutes: Routes = [
-  {
-    path: 'playlist/:playlistId',
-    component: VideoWatchComponent,
-    canActivate: [ MetaGuard ]
-  },
-  {
-    path: ':videoId/comments/:commentId',
-    redirectTo: ':videoId'
-  },
-  {
-    path: ':videoId',
-    component: VideoWatchComponent,
-    canActivate: [ MetaGuard ]
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(videoWatchRoutes) ],
-  exports: [ RouterModule ]
-})
-export class VideoWatchRoutingModule {}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
deleted file mode 100644 (file)
index 0447268..0000000
+++ /dev/null
@@ -1,277 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
deleted file mode 100644 (file)
index 2e08398..0000000
+++ /dev/null
@@ -1,607 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_bootstrap-variables';
-@import '_miniature';
-
-$player-factor: 1.7; // 16/9
-$video-info-margin-left: 44px;
-
-@function getPlayerHeight($width){
-  @return calc(#{$width} / #{$player-factor})
-}
-
-@function getPlayerWidth($height){
-  @return calc(#{$height} * #{$player-factor})
-}
-
-@mixin playlist-below-player {
-  width: 100% !important;
-  height: auto !important;
-  max-height: 300px !important;
-  max-width: initial;
-  border-bottom: 1px solid $separator-border-color !important;
-}
-
-.root {
-  &.theater-enabled #video-wrapper {
-    flex-direction: column;
-    justify-content: center;
-
-    #videojs-wrapper {
-      width: 100%;
-    }
-
-    ::ng-deep .video-js {
-      $height: calc(100vh - #{$header-height} - #{$theater-bottom-space});
-
-      height: $height;
-      width: 100%;
-      max-width: initial;
-    }
-
-    my-video-watch-playlist ::ng-deep .playlist {
-      @include playlist-below-player;
-    }
-  }
-}
-
-.blocked-label {
-  font-weight: $font-semibold;
-}
-
-#video-wrapper {
-  background-color: #000;
-  display: flex;
-  justify-content: center;
-
-  #videojs-wrapper {
-    display: flex;
-    justify-content: center;
-    flex-grow: 1;
-  }
-
-  .remote-server-down {
-    color: #fff;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    text-align: center;
-    justify-content: center;
-    background-color: #141313;
-    width: 100%;
-    font-size: 24px;
-    height: 500px;
-
-    @media screen and (max-width: 1000px) {
-      font-size: 20px;
-    }
-
-    @media screen and (max-width: 600px) {
-      font-size: 16px;
-    }
-  }
-
-  ::ng-deep .video-js {
-    width: 100%;
-    max-width: getPlayerWidth(66vh);
-    height: 66vh;
-
-    // VideoJS create an inner video player
-    video {
-      outline: 0;
-      position: relative !important;
-    }
-  }
-
-  @media screen and (max-width: 600px) {
-    .remote-server-down,
-    ::ng-deep .video-js {
-      width: 100vw;
-      height: getPlayerHeight(100vw)
-    }
-  }
-}
-
-.alert {
-  text-align: center;
-  border-radius: 0;
-}
-
-.flex-direction-column {
-  flex-direction: column;
-}
-
-#video-not-found {
-  height: 300px;
-  line-height: 300px;
-  margin-top: 50px;
-  text-align: center;
-  font-weight: $font-semibold;
-  font-size: 15px;
-}
-
-.video-bottom {
-  display: flex;
-  margin-top: 1.5rem;
-
-  .video-info {
-    flex-grow: 1;
-    // Set min width for flex item
-    min-width: 1px;
-    max-width: 100%;
-
-    .video-info-first-row {
-      display: flex;
-
-      & > div:first-child {
-        flex-grow: 1;
-      }
-
-      .video-info-name {
-        margin-right: 30px;
-        min-height: 40px; // Align with the action buttons
-        font-size: 27px;
-        font-weight: $font-semibold;
-        flex-grow: 1;
-      }
-
-      .video-info-first-row-bottom {
-        display: flex;
-        flex-wrap: wrap;
-        align-items: center;
-        width: 100%;
-      }
-
-      .video-info-date-views {
-        align-self: start;
-        margin-bottom: 10px;
-        margin-right: 10px;
-        font-size: 1em;
-      }
-
-      .video-info-channel {
-        font-weight: $font-semibold;
-        font-size: 15px;
-
-        a {
-          @include disable-default-a-behaviour;
-
-          color: pvar(--mainForegroundColor);
-
-          &:hover {
-            opacity: 0.8;
-          }
-
-          img {
-            @include avatar(18px);
-
-            margin: -2px 5px 0 0;
-          }
-        }
-
-        .video-info-channel-left {
-          flex-grow: 1;
-
-          .video-info-channel-left-links {
-            display: flex;
-            flex-direction: column;
-            position: relative;
-            line-height: 1.37;
-
-            a:nth-of-type(2) {
-              font-weight: 500;
-              font-size: 90%;
-            }
-          }
-        }
-
-        my-subscribe-button {
-          margin-left: 5px;
-        }
-      }
-
-      my-feed {
-        margin-left: 5px;
-        margin-top: 1px;
-      }
-
-      .video-actions-rates {
-        margin: 0 0 10px 0;
-        align-items: start;
-        width: max-content;
-        margin-left: auto;
-
-        .video-actions {
-          height: 40px; // Align with the title
-          display: flex;
-          align-items: center;
-
-          .action-button:not(:first-child),
-          .action-dropdown,
-          my-video-actions-dropdown {
-            margin-left: 5px;
-          }
-
-          ::ng-deep.action-button {
-            @include peertube-button;
-            @include button-with-icon(21px, 0, -1px);
-            @include apply-svg-color(pvar(--actionButtonColor));
-
-            font-size: 100%;
-            font-weight: $font-semibold;
-            display: inline-block;
-            padding: 0 10px 0 10px;
-            white-space: nowrap;
-            background-color: transparent !important;
-            color: pvar(--actionButtonColor);
-            text-transform: uppercase;
-
-            &::after {
-              display: none;
-            }
-
-            &:hover {
-              opacity: 0.9;
-            }
-
-            &.action-button-like,
-            &.action-button-dislike {
-              filter: brightness(120%);
-
-              .count {
-                margin-right: 5px;
-              }
-            }
-
-            &.action-button-like.activated {
-              .count {
-                color: pvar(--activatedActionButtonColor);
-              }
-
-              my-global-icon {
-                @include apply-svg-color(pvar(--activatedActionButtonColor));
-              }
-            }
-
-            &.action-button-dislike.activated {
-              .count {
-                color: pvar(--activatedActionButtonColor);
-              }
-
-              my-global-icon {
-                @include apply-svg-color(pvar(--activatedActionButtonColor));
-              }
-            }
-
-            &.action-button-support {
-              color: pvar(--supportButtonColor);
-
-              my-global-icon {
-                @include apply-svg-color(pvar(--supportButtonColor));
-              }
-            }
-
-            &.action-button-support {
-              my-global-icon {
-                ::ng-deep path:first-child {
-                  fill: pvar(--supportButtonHeartColor) !important;
-                }
-              }
-            }
-
-            &.action-button-save {
-              my-global-icon {
-                top: 0 !important;
-                right: -1px;
-              }
-            }
-
-            .icon-text {
-              margin-left: 3px;
-            }
-          }
-        }
-
-        .video-info-likes-dislikes-bar-outer-container {
-          position: relative;
-        }
-
-        .video-info-likes-dislikes-bar-inner-container {
-          position: absolute;
-          height: 20px;
-        }
-
-        .video-info-likes-dislikes-bar {
-          $likes-bar-height: 2px;
-          height: $likes-bar-height;
-          margin-top: -$likes-bar-height;
-          width: 120px;
-          background-color: #ccc;
-          position: relative;
-          top: 10px;
-
-          .likes-bar {
-            height: 100%;
-            background-color: #909090;
-
-            &.liked {
-              background-color: pvar(--activatedActionButtonColor);
-            }
-          }
-        }
-      }
-    }
-
-    .video-info-description {
-      margin: 20px 0;
-      margin-left: $video-info-margin-left;
-      font-size: 15px;
-
-      .video-info-description-html {
-        @include peertube-word-wrap;
-
-        /deep/ a {
-          text-decoration: none;
-        }
-      }
-
-      .glyphicon, .description-loading {
-        margin-left: 3px;
-      }
-
-      .description-loading {
-        display: inline-block;
-      }
-
-      .video-info-description-more {
-        cursor: pointer;
-        font-weight: $font-semibold;
-        color: pvar(--greyForegroundColor);
-        font-size: 14px;
-
-        .glyphicon {
-          position: relative;
-          top: 2px;
-        }
-      }
-    }
-
-    .video-attributes {
-      margin-left: $video-info-margin-left;
-    }
-
-    .video-attributes .video-attribute {
-      font-size: 13px;
-      display: block;
-      margin-bottom: 12px;
-
-      .video-attribute-label {
-        min-width: 142px;
-        padding-right: 5px;
-        display: inline-block;
-        color: pvar(--greyForegroundColor);
-        font-weight: $font-bold;
-      }
-
-      a.video-attribute-value {
-        @include disable-default-a-behaviour;
-        color: pvar(--mainForegroundColor);
-
-        &:hover {
-          opacity: 0.9;
-        }
-      }
-
-      &.video-attribute-tags {
-        .video-attribute-value:not(:nth-child(2)) {
-          &::before {
-            content: ', '
-          }
-        }
-      }
-    }
-  }
-
-  ::ng-deep .other-videos {
-    padding-left: 15px;
-    min-width: $video-miniature-width;
-
-    @media screen and (min-width: 1800px - (3* $video-miniature-width)) {
-      width: min-content;
-    }
-
-    .title-page {
-      margin: 0 !important;
-    }
-
-    .video-miniature {
-      display: flex;
-      width: max-content;
-      height: 100%;
-      padding-bottom: 20px;
-      flex-wrap: wrap;
-    }
-
-    .video-bottom {
-      @media screen and (max-width: 1800px - (3* $video-miniature-width)) {
-        margin-left: 1rem;
-      }
-      @media screen and (max-width: 500px) {
-        margin-left: 0;
-        margin-top: .5rem;
-      }
-    }
-  }
-}
-
-my-video-comments {
-  display: inline-block;
-  width: 100%;
-  margin-bottom: 20px;
-}
-
-// If the view is not expanded, take into account the menu
-.privacy-concerns {
-  z-index: z(dropdown) + 1;
-  width: calc(100% - #{$menu-width});
-}
-
-@media screen and (max-width: $small-view) {
-  .privacy-concerns {
-    margin-left: $menu-width - 15px; // Menu is absolute
-  }
-}
-
-:host-context(.expanded) {
-  .privacy-concerns {
-    width: 100%;
-    margin-left: -15px;
-  }
-}
-
-.privacy-concerns {
-  position: fixed;
-  bottom: 0;
-  z-index: z(privacymsg);
-
-  padding: 5px 15px;
-
-  display: flex;
-  flex-wrap: nowrap;
-  align-items: center;
-  justify-content: space-between;
-  background-color: rgba(0, 0, 0, 0.9);
-  color: #fff;
-
-  .privacy-concerns-text {
-    margin: 0 5px;
-  }
-
-  a {
-    @include disable-default-a-behaviour;
-
-    color: pvar(--mainColor);
-    transition: color 0.3s;
-
-    &:hover {
-      color: #fff;
-    }
-  }
-
-  .privacy-concerns-button {
-    padding: 5px 8px 5px 7px;
-    margin-left: auto;
-    border-radius: 3px;
-    white-space: nowrap;
-    cursor: pointer;
-    transition: background-color 0.3s;
-    font-weight: $font-semibold;
-
-    &:hover {
-      background-color: #000;
-    }
-  }
-
-  .privacy-concerns-okay {
-    background-color: pvar(--mainColor);
-    margin-left: 10px;
-  }
-}
-
-@media screen and (max-width: 1600px) {
-  .video-bottom .video-info .video-attributes .video-attribute {
-    margin-bottom: 5px;
-  }
-}
-
-@media screen and (max-width: 1300px) {
-  .privacy-concerns {
-    font-size: 12px;
-    padding: 2px 5px;
-
-    .privacy-concerns-text {
-      margin: 0;
-    }
-  }
-}
-
-@media screen and (max-width: 1100px) {
-  #video-wrapper {
-    flex-direction: column;
-    justify-content: center;
-
-    my-video-watch-playlist ::ng-deep .playlist {
-      @include playlist-below-player;
-    }
-  }
-
-  .video-bottom {
-    flex-direction: column;
-
-    ::ng-deep .other-videos {
-      padding-left: 0 !important;
-
-      ::ng-deep .video-miniature  {
-        flex-direction: row;
-        width: auto;
-      }
-    }
-  }
-}
-
-@media screen and (max-width: 600px) {
-  .video-bottom {
-    margin-top: 20px !important;
-    padding-bottom: 20px !important;
-
-    .video-info {
-      padding: 0;
-
-      .video-info-first-row {
-
-        .video-info-name {
-          font-size: 20px;
-          height: auto;
-        }
-      }
-    }
-  }
-
-  ::ng-deep .other-videos .video-miniature  {
-    flex-direction: column;
-  }
-
-  .privacy-concerns {
-    width: 100%;
-
-    strong {
-      display: none;
-    }
-  }
-}
-
-@media screen and (max-width: 450px) {
-  .video-bottom {
-    .action-button .icon-text {
-      display: none !important;
-    }
-
-    .video-info .video-info-first-row {
-      .video-info-name {
-        font-size: 18px;
-      }
-
-      .video-info-date-views {
-        font-size: 14px;
-      }
-
-      .video-actions-rates {
-        margin-top: 10px;
-      }
-    }
-
-    .video-info-description {
-      font-size: 14px !important;
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
deleted file mode 100644 (file)
index 5b0b34c..0000000
+++ /dev/null
@@ -1,782 +0,0 @@
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { forkJoin, Observable, Subscription } from 'rxjs'
-import { catchError } from 'rxjs/operators'
-import { PlatformLocation } from '@angular/common'
-import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { RedirectService } from '@app/core/routing/redirect.service'
-import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers'
-import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
-import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
-import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
-import { MetaService } from '@ngx-meta/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
-import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
-import {
-  CustomizationOptions,
-  P2PMediaLoaderOptions,
-  PeertubePlayerManager,
-  PeertubePlayerManagerOptions,
-  PlayerMode,
-  videojs
-} from '../../../assets/player/peertube-player-manager'
-import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
-import { environment } from '../../../environments/environment'
-import { VideoShareComponent } from './modal/video-share.component'
-import { VideoSupportComponent } from './modal/video-support.component'
-import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
-
-@Component({
-  selector: 'my-video-watch',
-  templateUrl: './video-watch.component.html',
-  styleUrls: [ './video-watch.component.scss' ]
-})
-export class VideoWatchComponent implements OnInit, OnDestroy {
-  private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern'
-
-  @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
-  @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
-  @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent
-  @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
-
-  player: any
-  playerElement: HTMLVideoElement
-  theaterEnabled = false
-  userRating: UserVideoRateType = null
-  descriptionLoading = false
-
-  video: VideoDetails = null
-  videoCaptions: VideoCaption[] = []
-
-  playlist: VideoPlaylist = null
-
-  completeDescriptionShown = false
-  completeVideoDescription: string
-  shortVideoDescription: string
-  videoHTMLDescription = ''
-  likesBarTooltipText = ''
-  hasAlreadyAcceptedPrivacyConcern = false
-  remoteServerDown = false
-  hotkeys: Hotkey[] = []
-
-  tooltipLike = ''
-  tooltipDislike = ''
-  tooltipSupport = ''
-  tooltipSaveToPlaylist = ''
-
-  private nextVideoUuid = ''
-  private nextVideoTitle = ''
-  private currentTime: number
-  private paramsSub: Subscription
-  private queryParamsSub: Subscription
-  private configSub: Subscription
-
-  private serverConfig: ServerConfig
-
-  constructor (
-    private elementRef: ElementRef,
-    private changeDetector: ChangeDetectorRef,
-    private route: ActivatedRoute,
-    private router: Router,
-    private videoService: VideoService,
-    private playlistService: VideoPlaylistService,
-    private confirmService: ConfirmService,
-    private metaService: MetaService,
-    private authService: AuthService,
-    private userService: UserService,
-    private serverService: ServerService,
-    private restExtractor: RestExtractor,
-    private notifier: Notifier,
-    private markdownService: MarkdownService,
-    private zone: NgZone,
-    private redirectService: RedirectService,
-    private videoCaptionService: VideoCaptionService,
-    private i18n: I18n,
-    private hotkeysService: HotkeysService,
-    private hooks: HooksService,
-    private location: PlatformLocation,
-    @Inject(LOCALE_ID) private localeId: string
-  ) {
-    this.tooltipLike = this.i18n('Like this video')
-    this.tooltipDislike = this.i18n('Dislike this video')
-    this.tooltipSupport = this.i18n('Support options for this video')
-    this.tooltipSaveToPlaylist = this.i18n('Save to playlist')
-  }
-
-  get user () {
-    return this.authService.getUser()
-  }
-
-  get anonymousUser () {
-    return this.userService.getAnonymousUser()
-  }
-
-  async ngOnInit () {
-    this.serverConfig = this.serverService.getTmpConfig()
-
-    this.configSub = this.serverService.getConfig()
-        .subscribe(config => {
-          this.serverConfig = config
-
-          if (
-            isWebRTCDisabled() ||
-            this.serverConfig.tracker.enabled === false ||
-            getStoredP2PEnabled() === false ||
-            peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true'
-          ) {
-            this.hasAlreadyAcceptedPrivacyConcern = true
-          }
-        })
-
-    this.paramsSub = this.route.params.subscribe(routeParams => {
-      const videoId = routeParams[ 'videoId' ]
-      if (videoId) this.loadVideo(videoId)
-
-      const playlistId = routeParams[ 'playlistId' ]
-      if (playlistId) this.loadPlaylist(playlistId)
-    })
-
-    this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => {
-      const videoId = queryParams[ 'videoId' ]
-      if (videoId) this.loadVideo(videoId)
-
-      const start = queryParams[ 'start' ]
-      if (this.player && start) this.player.currentTime(parseInt(start, 10))
-    })
-
-    this.initHotkeys()
-
-    this.theaterEnabled = getStoredTheater()
-
-    this.hooks.runAction('action:video-watch.init', 'video-watch')
-  }
-
-  ngOnDestroy () {
-    this.flushPlayer()
-
-    // Unsubscribe subscriptions
-    if (this.paramsSub) this.paramsSub.unsubscribe()
-    if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
-
-    // Unbind hotkeys
-    this.hotkeysService.remove(this.hotkeys)
-  }
-
-  setLike () {
-    if (this.isUserLoggedIn() === false) return
-
-    // Already liked this video
-    if (this.userRating === 'like') this.setRating('none')
-    else this.setRating('like')
-  }
-
-  setDislike () {
-    if (this.isUserLoggedIn() === false) return
-
-    // Already disliked this video
-    if (this.userRating === 'dislike') this.setRating('none')
-    else this.setRating('dislike')
-  }
-
-  getRatePopoverText () {
-    if (this.isUserLoggedIn()) return undefined
-
-    return this.i18n('You need to be connected to rate this content.')
-  }
-
-  showMoreDescription () {
-    if (this.completeVideoDescription === undefined) {
-      return this.loadCompleteDescription()
-    }
-
-    this.updateVideoDescription(this.completeVideoDescription)
-    this.completeDescriptionShown = true
-  }
-
-  showLessDescription () {
-    this.updateVideoDescription(this.shortVideoDescription)
-    this.completeDescriptionShown = false
-  }
-
-  loadCompleteDescription () {
-    this.descriptionLoading = true
-
-    this.videoService.loadCompleteDescription(this.video.descriptionPath)
-        .subscribe(
-          description => {
-            this.completeDescriptionShown = true
-            this.descriptionLoading = false
-
-            this.shortVideoDescription = this.video.description
-            this.completeVideoDescription = description
-
-            this.updateVideoDescription(this.completeVideoDescription)
-          },
-
-          error => {
-            this.descriptionLoading = false
-            this.notifier.error(error.message)
-          }
-        )
-  }
-
-  showSupportModal () {
-    this.pausePlayer()
-
-    this.videoSupportModal.show()
-  }
-
-  showShareModal () {
-    this.pausePlayer()
-
-    this.videoShareModal.show(this.currentTime)
-  }
-
-  isUserLoggedIn () {
-    return this.authService.isLoggedIn()
-  }
-
-  getVideoTags () {
-    if (!this.video || Array.isArray(this.video.tags) === false) return []
-
-    return this.video.tags
-  }
-
-  onRecommendations (videos: Video[]) {
-    if (videos.length > 0) {
-      // The recommended videos's first element should be the next video
-      const video = videos[0]
-      this.nextVideoUuid = video.uuid
-      this.nextVideoTitle = video.name
-    }
-  }
-
-  onModalOpened () {
-    this.pausePlayer()
-  }
-
-  onVideoRemoved () {
-    this.redirectService.redirectToHomepage()
-  }
-
-  declinedPrivacyConcern () {
-    peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false')
-    this.hasAlreadyAcceptedPrivacyConcern = false
-  }
-
-  acceptedPrivacyConcern () {
-    peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true')
-    this.hasAlreadyAcceptedPrivacyConcern = true
-  }
-
-  isVideoToTranscode () {
-    return this.video && this.video.state.id === VideoState.TO_TRANSCODE
-  }
-
-  isVideoToImport () {
-    return this.video && this.video.state.id === VideoState.TO_IMPORT
-  }
-
-  hasVideoScheduledPublication () {
-    return this.video && this.video.scheduledUpdate !== undefined
-  }
-
-  isVideoBlur (video: Video) {
-    return video.isVideoNSFWForUser(this.user, this.serverConfig)
-  }
-
-  isAutoPlayEnabled () {
-    return (
-      (this.user && this.user.autoPlayNextVideo) ||
-      this.anonymousUser.autoPlayNextVideo
-    )
-  }
-
-  handleTimestampClicked (timestamp: number) {
-    if (this.player) this.player.currentTime(timestamp)
-    scrollToTop()
-  }
-
-  isPlaylistAutoPlayEnabled () {
-    return (
-      (this.user && this.user.autoPlayNextVideoPlaylist) ||
-      this.anonymousUser.autoPlayNextVideoPlaylist
-    )
-  }
-
-  private loadVideo (videoId: string) {
-    // Video did not change
-    if (this.video && this.video.uuid === videoId) return
-
-    if (this.player) this.player.pause()
-
-    const videoObs = this.hooks.wrapObsFun(
-      this.videoService.getVideo.bind(this.videoService),
-      { videoId },
-      'video-watch',
-      'filter:api.video-watch.video.get.params',
-      'filter:api.video-watch.video.get.result'
-    )
-
-    // Video did change
-    forkJoin([
-      videoObs,
-      this.videoCaptionService.listCaptions(videoId)
-    ])
-      .pipe(
-        // If 401, the video is private or blocked so redirect to 404
-        catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
-      )
-      .subscribe(([ video, captionsResult ]) => {
-        const queryParams = this.route.snapshot.queryParams
-
-        const urlOptions = {
-          startTime: queryParams.start,
-          stopTime: queryParams.stop,
-
-          muted: queryParams.muted,
-          loop: queryParams.loop,
-          subtitle: queryParams.subtitle,
-
-          playerMode: queryParams.mode,
-          peertubeLink: false
-        }
-
-        this.onVideoFetched(video, captionsResult.data, urlOptions)
-            .catch(err => this.handleError(err))
-      })
-  }
-
-  private loadPlaylist (playlistId: string) {
-    // Playlist did not change
-    if (this.playlist && this.playlist.uuid === playlistId) return
-
-    this.playlistService.getVideoPlaylist(playlistId)
-      .pipe(
-        // If 401, the video is private or blocked so redirect to 404
-        catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ]))
-      )
-      .subscribe(playlist => {
-        this.playlist = playlist
-
-        const videoId = this.route.snapshot.queryParams['videoId']
-        this.videoWatchPlaylist.loadPlaylistElements(playlist, !videoId)
-      })
-  }
-
-  private updateVideoDescription (description: string) {
-    this.video.description = description
-    this.setVideoDescriptionHTML()
-      .catch(err => console.error(err))
-  }
-
-  private async setVideoDescriptionHTML () {
-    const html = await this.markdownService.textMarkdownToHTML(this.video.description)
-    this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
-  }
-
-  private setVideoLikesBarTooltipText () {
-    this.likesBarTooltipText = this.i18n('{{likesNumber}} likes / {{dislikesNumber}} dislikes', {
-      likesNumber: this.video.likes,
-      dislikesNumber: this.video.dislikes
-    })
-  }
-
-  private handleError (err: any) {
-    const errorMessage: string = typeof err === 'string' ? err : err.message
-    if (!errorMessage) return
-
-    // Display a message in the video player instead of a notification
-    if (errorMessage.indexOf('from xs param') !== -1) {
-      this.flushPlayer()
-      this.remoteServerDown = true
-      this.changeDetector.detectChanges()
-
-      return
-    }
-
-    this.notifier.error(errorMessage)
-  }
-
-  private checkUserRating () {
-    // Unlogged users do not have ratings
-    if (this.isUserLoggedIn() === false) return
-
-    this.videoService.getUserVideoRating(this.video.id)
-        .subscribe(
-          ratingObject => {
-            if (ratingObject) {
-              this.userRating = ratingObject.rating
-            }
-          },
-
-          err => this.notifier.error(err.message)
-        )
-  }
-
-  private async onVideoFetched (
-    video: VideoDetails,
-    videoCaptions: VideoCaption[],
-    urlOptions: CustomizationOptions & { playerMode: PlayerMode }
-  ) {
-    this.video = video
-    this.videoCaptions = videoCaptions
-
-    // Re init attributes
-    this.descriptionLoading = false
-    this.completeDescriptionShown = false
-    this.remoteServerDown = false
-    this.currentTime = undefined
-
-    this.videoWatchPlaylist.updatePlaylistIndex(video)
-
-    if (this.isVideoBlur(this.video)) {
-      const res = await this.confirmService.confirm(
-        this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'),
-        this.i18n('Mature or explicit content')
-      )
-      if (res === false) return this.location.back()
-    }
-
-    // Flush old player if needed
-    this.flushPlayer()
-
-    // Build video element, because videojs removes it on dispose
-    const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
-    this.playerElement = document.createElement('video')
-    this.playerElement.className = 'video-js vjs-peertube-skin'
-    this.playerElement.setAttribute('playsinline', 'true')
-    playerElementWrapper.appendChild(this.playerElement)
-
-    const params = {
-      video: this.video,
-      videoCaptions,
-      urlOptions,
-      user: this.user
-    }
-    const { playerMode, playerOptions } = await this.hooks.wrapFun(
-      this.buildPlayerManagerOptions.bind(this),
-      params,
-      'video-watch',
-      'filter:internal.video-watch.player.build-options.params',
-      'filter:internal.video-watch.player.build-options.result'
-    )
-
-    this.zone.runOutsideAngular(async () => {
-      this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player)
-      this.player.focus()
-
-      this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
-
-      this.player.on('timeupdate', () => {
-        this.currentTime = Math.floor(this.player.currentTime())
-      })
-
-      /**
-       * replaces this.player.one('ended')
-       * 'condition()': true to make the upnext functionality trigger,
-       *                false to disable the upnext functionality
-       * go to the next video in 'condition()' if you don't want of the timer.
-       * 'next': function triggered at the end of the timer.
-       * 'suspended': function used at each clic of the timer checking if we need
-       * to reset progress and wait until 'suspended' becomes truthy again.
-       */
-      this.player.upnext({
-        timeout: 10000, // 10s
-        headText: this.i18n('Up Next'),
-        cancelText: this.i18n('Cancel'),
-        suspendedText: this.i18n('Autoplay is suspended'),
-        getTitle: () => this.nextVideoTitle,
-        next: () => this.zone.run(() => this.autoplayNext()),
-        condition: () => {
-          if (this.playlist) {
-            if (this.isPlaylistAutoPlayEnabled()) {
-              // upnext will not trigger, and instead the next video will play immediately
-              this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
-            }
-          } else if (this.isAutoPlayEnabled()) {
-            return true // upnext will trigger
-          }
-          return false // upnext will not trigger, and instead leave the video stopping
-        },
-        suspended: () => {
-          return (
-            !isXPercentInViewport(this.player.el(), 80) ||
-            !document.getElementById('content').contains(document.activeElement)
-          )
-        }
-      })
-
-      this.player.one('stopped', () => {
-        if (this.playlist) {
-          if (this.isPlaylistAutoPlayEnabled()) this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
-        }
-      })
-
-      this.player.on('theaterChange', (_: any, enabled: boolean) => {
-        this.zone.run(() => this.theaterEnabled = enabled)
-      })
-
-      this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { player: this.player })
-    })
-
-    this.setVideoDescriptionHTML()
-    this.setVideoLikesBarTooltipText()
-
-    this.setOpenGraphTags()
-    this.checkUserRating()
-
-    this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', { videojs })
-  }
-
-  private autoplayNext () {
-    if (this.playlist) {
-      this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo())
-    } else if (this.nextVideoUuid) {
-      this.router.navigate([ '/videos/watch', this.nextVideoUuid ])
-    }
-  }
-
-  private setRating (nextRating: UserVideoRateType) {
-    const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable<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)
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts
deleted file mode 100644 (file)
index a1c54f0..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-import { QRCodeModule } from 'angularx-qrcode'
-import { NgModule } from '@angular/core'
-import { SharedFormModule } from '@app/shared/shared-forms'
-import { SharedGlobalIconModule } from '@app/shared/shared-icons'
-import { SharedMainModule } from '@app/shared/shared-main'
-import { SharedModerationModule } from '@app/shared/shared-moderation'
-import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
-import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
-import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
-import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
-import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
-import { VideoCommentAddComponent } from './comment/video-comment-add.component'
-import { VideoCommentComponent } from './comment/video-comment.component'
-import { VideoCommentService } from './comment/video-comment.service'
-import { VideoCommentsComponent } from './comment/video-comments.component'
-import { VideoShareComponent } from './modal/video-share.component'
-import { VideoSupportComponent } from './modal/video-support.component'
-import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
-import { VideoDurationPipe } from './video-duration-formatter.pipe'
-import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
-import { VideoWatchRoutingModule } from './video-watch-routing.module'
-import { VideoWatchComponent } from './video-watch.component'
-
-@NgModule({
-  imports: [
-    VideoWatchRoutingModule,
-    NgbTooltipModule,
-    QRCodeModule,
-    RecommendationsModule,
-
-    SharedMainModule,
-    SharedFormModule,
-    SharedVideoMiniatureModule,
-    SharedVideoPlaylistModule,
-    SharedUserSubscriptionModule,
-    SharedModerationModule,
-    SharedGlobalIconModule
-  ],
-
-  declarations: [
-    VideoWatchComponent,
-    VideoWatchPlaylistComponent,
-
-    VideoShareComponent,
-    VideoSupportComponent,
-    VideoCommentsComponent,
-    VideoCommentAddComponent,
-    VideoCommentComponent,
-
-    TimestampRouteTransformerDirective,
-    VideoDurationPipe,
-    TimestampRouteTransformerDirective
-  ],
-
-  exports: [
-    VideoWatchComponent,
-
-    TimestampRouteTransformerDirective
-  ],
-
-  providers: [
-    VideoCommentService
-  ]
-})
-export class VideoWatchModule { }
diff --git a/client/src/app/videos/index.ts b/client/src/app/videos/index.ts
deleted file mode 100644 (file)
index 028a585..0000000
+++ /dev/null
@@ -1 +0,0 @@
-export * from './videos.module'
diff --git a/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts b/client/src/app/videos/recommendations/recent-videos-recommendation.service.ts
deleted file mode 100644 (file)
index a376453..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-import { Observable, of } from 'rxjs'
-import { map, switchMap } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { ServerService, UserService } from '@app/core'
-import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { SearchService } from '@app/search/search.service'
-import { Video, VideoService } from '@app/shared/shared-main'
-import { ServerConfig } from '@shared/models'
-import { RecommendationInfo } from './recommendation-info.model'
-import { RecommendationService } from './recommendations.service'
-
-/**
- * Provides "recommendations" by providing the most recently uploaded videos.
- */
-@Injectable()
-export class RecentVideosRecommendationService implements RecommendationService {
-  readonly pageSize = 5
-
-  private config: ServerConfig
-
-  constructor (
-    private videos: VideoService,
-    private searchService: SearchService,
-    private userService: UserService,
-    private serverService: ServerService
-  ) {
-    this.config = this.serverService.getTmpConfig()
-
-    this.serverService.getConfig()
-     .subscribe(config => this.config = config)
-  }
-
-  getRecommendations (recommendation: RecommendationInfo): Observable<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)
-        })
-      )
-  }
-}
diff --git a/client/src/app/videos/recommendations/recommendation-info.model.ts b/client/src/app/videos/recommendations/recommendation-info.model.ts
deleted file mode 100644 (file)
index 0233563..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface RecommendationInfo {
-  uuid: string
-  tags?: string[]
-}
diff --git a/client/src/app/videos/recommendations/recommendations.module.ts b/client/src/app/videos/recommendations/recommendations.module.ts
deleted file mode 100644 (file)
index 03cc272..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-import { InputSwitchModule } from 'primeng/inputswitch'
-import { CommonModule } from '@angular/common'
-import { NgModule } from '@angular/core'
-import { SharedMainModule } from '@app/shared/shared-main'
-import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
-import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
-import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
-import { RecommendedVideosComponent } from './recommended-videos.component'
-import { RecommendedVideosStore } from './recommended-videos.store'
-
-@NgModule({
-  imports: [
-    CommonModule,
-    InputSwitchModule,
-
-    SharedMainModule,
-    SharedVideoPlaylistModule,
-    SharedVideoMiniatureModule
-  ],
-  declarations: [
-    RecommendedVideosComponent
-  ],
-  exports: [
-    RecommendedVideosComponent
-  ],
-  providers: [
-    RecommendedVideosStore,
-    RecentVideosRecommendationService
-  ]
-})
-export class RecommendationsModule {
-}
diff --git a/client/src/app/videos/recommendations/recommendations.service.ts b/client/src/app/videos/recommendations/recommendations.service.ts
deleted file mode 100644 (file)
index 1d79d35..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Observable } from 'rxjs'
-import { Video } from '@app/shared/shared-main'
-import { RecommendationInfo } from './recommendation-info.model'
-
-export interface RecommendationService {
-  getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
-}
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.html b/client/src/app/videos/recommendations/recommended-videos.component.html
deleted file mode 100644 (file)
index 0467cab..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<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>
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.scss b/client/src/app/videos/recommendations/recommended-videos.component.scss
deleted file mode 100644 (file)
index b278c96..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-.title-page-container {
-  display: flex;
-  justify-content: space-between;
-  align-items: baseline;
-  margin-bottom: 25px;
-  flex-wrap: wrap-reverse;
-
-  .title-page.active, .title-page.title-page-single {
-    margin-bottom: unset;
-    margin-right: .5rem !important;
-  }
-}
-
-.title-page-autoplay {
-  display: flex;
-  width: max-content;
-  height: max-content;
-  align-items: center;
-  margin-left: auto;
-
-  span {
-    margin-right: 0.3rem;
-    text-transform: uppercase;
-    font-size: 85%;
-    font-weight: 600;
-  }
-}
-
-hr {
-  margin-top: 0;
-}
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.ts b/client/src/app/videos/recommendations/recommended-videos.component.ts
deleted file mode 100644 (file)
index 0169753..0000000
+++ /dev/null
@@ -1,91 +0,0 @@
-import { Observable } from 'rxjs'
-import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
-import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core'
-import { Video } from '@app/shared/shared-main'
-import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
-import { VideoPlaylist } from '@app/shared/shared-video-playlist'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { RecommendationInfo } from './recommendation-info.model'
-import { RecommendedVideosStore } from './recommended-videos.store'
-
-@Component({
-  selector: 'my-recommended-videos',
-  templateUrl: './recommended-videos.component.html',
-  styleUrls: [ './recommended-videos.component.scss' ]
-})
-export class RecommendedVideosComponent implements OnInit, OnChanges {
-  @Input() inputRecommendation: RecommendationInfo
-  @Input() playlist: VideoPlaylist
-  @Output() gotRecommendations = new EventEmitter<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)
-      )
-    }
-  }
-}
diff --git a/client/src/app/videos/recommendations/recommended-videos.store.ts b/client/src/app/videos/recommendations/recommended-videos.store.ts
deleted file mode 100644 (file)
index 8c3fb64..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Observable, ReplaySubject } from 'rxjs'
-import { map, shareReplay, switchMap, take } from 'rxjs/operators'
-import { Inject, Injectable } from '@angular/core'
-import { Video } from '@app/shared/shared-main'
-import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
-import { RecommendationInfo } from './recommendation-info.model'
-import { RecommendationService } from './recommendations.service'
-
-/**
- * This store is intended to provide data for the RecommendedVideosComponent.
- */
-@Injectable()
-export class RecommendedVideosStore {
-  public readonly recommendations$: Observable<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)
-  }
-}
diff --git a/client/src/app/videos/video-list/index.ts b/client/src/app/videos/video-list/index.ts
deleted file mode 100644 (file)
index af1bd58..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from './overview'
-export * from './video-local.component'
-export * from './video-recently-added.component'
-export * from './video-trending.component'
-export * from './video-most-liked.component'
diff --git a/client/src/app/videos/video-list/overview/index.ts b/client/src/app/videos/video-list/overview/index.ts
deleted file mode 100644 (file)
index e6cfa48..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './overview.service'
-export * from './video-overview.component'
-export * from './videos-overview.model'
diff --git a/client/src/app/videos/video-list/overview/overview.service.ts b/client/src/app/videos/video-list/overview/overview.service.ts
deleted file mode 100644 (file)
index 4458454..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-import { forkJoin, Observable, of } from 'rxjs'
-import { catchError, map, switchMap, tap } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { RestExtractor, ServerService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { peertubeTranslate, VideosOverview as VideosOverviewServer } from '@shared/models'
-import { environment } from '../../../../environments/environment'
-import { VideosOverview } from './videos-overview.model'
-
-@Injectable()
-export class OverviewService {
-  static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/'
-
-  constructor (
-    private authHttp: HttpClient,
-    private restExtractor: RestExtractor,
-    private videosService: VideoService,
-    private serverService: ServerService
-  ) {}
-
-  getVideosOverview (page: number): Observable<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)
-      )
-  }
-
-}
diff --git a/client/src/app/videos/video-list/overview/video-overview.component.html b/client/src/app/videos/video-list/overview/video-overview.component.html
deleted file mode 100644 (file)
index ca986c6..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-<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>
diff --git a/client/src/app/videos/video-list/overview/video-overview.component.scss b/client/src/app/videos/video-list/overview/video-overview.component.scss
deleted file mode 100644 (file)
index c1d1018..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.section-title {
-  // make the element span a full grid row within .videos grid
-  grid-column: 1 / -1;
-}
-
-.margin-content {
-  @include fluid-videos-miniature-layout;
-}
-
-.section {
-  @include miniature-rows;
-}
diff --git a/client/src/app/videos/video-list/overview/video-overview.component.ts b/client/src/app/videos/video-list/overview/video-overview.component.ts
deleted file mode 100644 (file)
index b3be1d7..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Subject } from 'rxjs'
-import { Component, OnInit } from '@angular/core'
-import { Notifier, ScreenService, User, UserService } from '@app/core'
-import { Video } from '@app/shared/shared-main'
-import { OverviewService } from './overview.service'
-import { VideosOverview } from './videos-overview.model'
-
-@Component({
-  selector: 'my-video-overview',
-  templateUrl: './video-overview.component.html',
-  styleUrls: [ './video-overview.component.scss' ]
-})
-export class VideoOverviewComponent implements OnInit {
-  onDataSubject = new Subject<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
-          }
-        )
-  }
-}
diff --git a/client/src/app/videos/video-list/overview/videos-overview.model.ts b/client/src/app/videos/video-list/overview/videos-overview.model.ts
deleted file mode 100644 (file)
index 6765ad9..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Video } from '@app/shared/shared-main'
-import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '@shared/models'
-
-export class VideosOverview implements VideosOverviewServer {
-  channels: {
-    channel: VideoChannelSummary
-    videos: Video[]
-  }[]
-
-  categories: {
-    category: VideoConstant<number>
-    videos: Video[]
-  }[]
-
-  tags: {
-    tag: string
-    videos: Video[]
-  }[]
-  [key: string]: any
-}
diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts
deleted file mode 100644 (file)
index b4c71ac..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { UserRight, VideoFilter, VideoSortField } from '@shared/models'
-
-@Component({
-  selector: 'my-videos-local',
-  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  titlePage: string
-  sort = '-publishedAt' as VideoSortField
-  filter: VideoFilter = 'local'
-
-  useUserVideoPreferences = true
-
-  constructor (
-    protected i18n: I18n,
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    private videoService: VideoService,
-    private hooks: HooksService
-  ) {
-    super()
-
-    this.titlePage = i18n('Local videos')
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    if (this.authService.isLoggedIn()) {
-      const user = this.authService.getUser()
-      this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS)
-    }
-
-    this.generateSyndicationList()
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    const params = {
-      videoPagination: newPagination,
-      sort: this.sort,
-      filter: this.filter,
-      categoryOneOf: this.categoryOneOf,
-      languageOneOf: this.languageOneOf,
-      nsfwPolicy: this.nsfwPolicy,
-      skipCount: true
-    }
-
-    return this.hooks.wrapObsFun(
-      this.videoService.getVideos.bind(this.videoService),
-      params,
-      'common',
-      'filter:api.local-videos.videos.list.params',
-      'filter:api.local-videos.videos.list.result'
-    )
-  }
-
-  generateSyndicationList () {
-    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf)
-  }
-
-  toggleModerationDisplay () {
-    this.filter = this.filter === 'local' ? 'all-local' as 'all-local' : 'local' as 'local'
-
-    this.reloadVideos()
-  }
-}
diff --git a/client/src/app/videos/video-list/video-most-liked.component.ts b/client/src/app/videos/video-list/video-most-liked.component.ts
deleted file mode 100644 (file)
index ca14851..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Component, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoSortField } from '@shared/models'
-
-@Component({
-  selector: 'my-videos-most-liked',
-  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoMostLikedComponent extends AbstractVideoList implements OnInit {
-  titlePage: string
-  defaultSort: VideoSortField = '-likes'
-
-  useUserVideoPreferences = true
-
-  constructor (
-    protected i18n: I18n,
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    private videoService: VideoService,
-    private hooks: HooksService
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    this.generateSyndicationList()
-
-    this.titlePage = this.i18n('Most liked videos')
-    this.titleTooltip = this.i18n('Videos that have the higher number of likes.')
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    const params = {
-      videoPagination: newPagination,
-      sort: this.sort,
-      categoryOneOf: this.categoryOneOf,
-      languageOneOf: this.languageOneOf,
-      nsfwPolicy: this.nsfwPolicy,
-      skipCount: true
-    }
-
-    return this.hooks.wrapObsFun(
-      this.videoService.getVideos.bind(this.videoService),
-      params,
-      'common',
-      'filter:api.most-liked-videos.videos.list.params',
-      'filter:api.most-liked-videos.videos.list.result'
-    )
-  }
-
-  generateSyndicationList () {
-    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
-  }
-}
diff --git a/client/src/app/videos/video-list/video-recently-added.component.ts b/client/src/app/videos/video-list/video-recently-added.component.ts
deleted file mode 100644 (file)
index c939513..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoSortField } from '@shared/models'
-
-@Component({
-  selector: 'my-videos-recently-added',
-  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  titlePage: string
-  sort: VideoSortField = '-publishedAt'
-  groupByDate = true
-
-  useUserVideoPreferences = true
-
-  constructor (
-    protected i18n: I18n,
-    protected route: ActivatedRoute,
-    protected serverService: ServerService,
-    protected router: Router,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    private videoService: VideoService,
-    private hooks: HooksService
-  ) {
-    super()
-
-    this.titlePage = i18n('Recently added')
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    this.generateSyndicationList()
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    const params = {
-      videoPagination: newPagination,
-      sort: this.sort,
-      categoryOneOf: this.categoryOneOf,
-      languageOneOf: this.languageOneOf,
-      nsfwPolicy: this.nsfwPolicy,
-      skipCount: true
-    }
-
-    return this.hooks.wrapObsFun(
-      this.videoService.getVideos.bind(this.videoService),
-      params,
-      'common',
-      'filter:api.recently-added-videos.videos.list.params',
-      'filter:api.recently-added-videos.videos.list.result'
-    )
-  }
-
-  generateSyndicationList () {
-    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
-  }
-}
diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts
deleted file mode 100644 (file)
index 10eab18..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoSortField } from '@shared/models'
-
-@Component({
-  selector: 'my-videos-trending',
-  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  titlePage: string
-  defaultSort: VideoSortField = '-trending'
-
-  useUserVideoPreferences = true
-
-  constructor (
-    protected i18n: I18n,
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    private videoService: VideoService,
-    private hooks: HooksService
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    this.generateSyndicationList()
-
-    this.serverService.getConfig().subscribe(
-      config => {
-        const trendingDays = config.trending.videos.intervalDays
-
-        if (trendingDays === 1) {
-          this.titlePage = this.i18n('Trending for the last 24 hours')
-          this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours')
-        } else {
-          this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays })
-          this.titleTooltip = this.i18n(
-            'Trending videos are those totalizing the greatest number of views during the last {{days}} days',
-            { days: trendingDays }
-          )
-        }
-      })
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    const params = {
-      videoPagination: newPagination,
-      sort: this.sort,
-      categoryOneOf: this.categoryOneOf,
-      languageOneOf: this.languageOneOf,
-      nsfwPolicy: this.nsfwPolicy,
-      skipCount: true
-    }
-
-    return this.hooks.wrapObsFun(
-      this.videoService.getVideos.bind(this.videoService),
-      params,
-      'common',
-      'filter:api.trending-videos.videos.list.params',
-      'filter:api.trending-videos.videos.list.result'
-    )
-  }
-
-  generateSyndicationList () {
-    this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
-  }
-}
diff --git a/client/src/app/videos/video-list/video-user-subscriptions.component.ts b/client/src/app/videos/video-list/video-user-subscriptions.component.ts
deleted file mode 100644 (file)
index 41ad9b2..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
-import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoSortField } from '@shared/models'
-
-@Component({
-  selector: 'my-videos-user-subscriptions',
-  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  titlePage: string
-  sort = '-publishedAt' as VideoSortField
-  ownerDisplayType: OwnerDisplayType = 'auto'
-  groupByDate = true
-
-  constructor (
-    protected i18n: I18n,
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    private userSubscription: UserSubscriptionService,
-    private videoService: VideoService,
-    private hooks: HooksService
-  ) {
-    super()
-
-    this.titlePage = i18n('Videos from your subscriptions')
-    this.actions.push({
-      routerLink: '/my-account/subscriptions',
-      label: i18n('Subscriptions'),
-      iconName: 'cog'
-    })
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    const params = {
-      videoPagination: newPagination,
-      sort: this.sort,
-      skipCount: true
-    }
-
-    return this.hooks.wrapObsFun(
-      this.userSubscription.getUserSubscriptionVideos.bind(this.userSubscription),
-      params,
-      'common',
-      'filter:api.user-subscriptions-videos.videos.list.params',
-      'filter:api.user-subscriptions-videos.videos.list.result'
-    )
-  }
-
-  generateSyndicationList () {
-    // not implemented yet
-  }
-}
diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts
deleted file mode 100644 (file)
index 16b65be..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
-import { MetaGuard } from '@ngx-meta/core'
-import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
-import { VideoTrendingComponent } from './video-list/video-trending.component'
-import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
-import { VideosComponent } from './videos.component'
-import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
-import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
-
-const videosRoutes: Routes = [
-  {
-    path: 'videos',
-    component: VideosComponent,
-    canActivateChild: [ MetaGuard ],
-    children: [
-      {
-        path: 'overview',
-        component: VideoOverviewComponent,
-        data: {
-          meta: {
-            title: 'Discover videos'
-          }
-        }
-      },
-      {
-        path: 'trending',
-        component: VideoTrendingComponent,
-        data: {
-          meta: {
-            title: 'Trending videos'
-          },
-          reuse: {
-            enabled: true,
-            key: 'trending-videos-list'
-          }
-        }
-      },
-      {
-        path: 'most-liked',
-        component: VideoMostLikedComponent,
-        data: {
-          meta: {
-            title: 'Most liked videos'
-          },
-          reuse: {
-            enabled: true,
-            key: 'most-liked-videos-list'
-          }
-        }
-      },
-      {
-        path: 'recently-added',
-        component: VideoRecentlyAddedComponent,
-        data: {
-          meta: {
-            title: 'Recently added videos'
-          },
-          reuse: {
-            enabled: true,
-            key: 'recently-added-videos-list'
-          }
-        }
-      },
-      {
-        path: 'subscriptions',
-        component: VideoUserSubscriptionsComponent,
-        data: {
-          meta: {
-            title: 'Subscriptions'
-          },
-          reuse: {
-            enabled: true,
-            key: 'subscription-videos-list'
-          }
-        }
-      },
-      {
-        path: 'local',
-        component: VideoLocalComponent,
-        data: {
-          meta: {
-            title: 'Local videos'
-          },
-          reuse: {
-            enabled: true,
-            key: 'local-videos-list'
-          }
-        }
-      },
-      {
-        path: 'upload',
-        loadChildren: () => import('@app/videos/+video-edit/video-add.module').then(m => m.VideoAddModule),
-        data: {
-          meta: {
-            title: 'Upload a video'
-          }
-        }
-      },
-      {
-        path: 'update/:uuid',
-        loadChildren: () => import('@app/videos/+video-edit/video-update.module').then(m => m.VideoUpdateModule),
-        data: {
-          meta: {
-            title: 'Edit a video'
-          }
-        }
-      },
-      {
-        path: 'watch',
-        loadChildren: () => import('@app/videos/+video-watch/video-watch.module').then(m => m.VideoWatchModule),
-        data: {
-          preload: 3000
-        }
-      }
-    ]
-  }
-]
-
-@NgModule({
-  imports: [ RouterModule.forChild(videosRoutes) ],
-  exports: [ RouterModule ]
-})
-export class VideosRoutingModule {}
diff --git a/client/src/app/videos/videos.component.ts b/client/src/app/videos/videos.component.ts
deleted file mode 100644 (file)
index 585a3ad..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-import { Component } from '@angular/core'
-
-@Component({
-  template: '<router-outlet></router-outlet>'
-})
-export class VideosComponent {}
diff --git a/client/src/app/videos/videos.module.ts b/client/src/app/videos/videos.module.ts
deleted file mode 100644 (file)
index 217e5bb..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-import { NgModule } from '@angular/core'
-import { SharedFormModule } from '@app/shared/shared-forms'
-import { SharedGlobalIconModule } from '@app/shared/shared-icons'
-import { SharedMainModule } from '@app/shared/shared-main'
-import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
-import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
-import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
-import { VideoLocalComponent } from './video-list/video-local.component'
-import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
-import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
-import { VideoTrendingComponent } from './video-list/video-trending.component'
-import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
-import { VideosRoutingModule } from './videos-routing.module'
-import { VideosComponent } from './videos.component'
-
-@NgModule({
-  imports: [
-    VideosRoutingModule,
-
-    SharedMainModule,
-    SharedFormModule,
-    SharedVideoMiniatureModule,
-    SharedUserSubscriptionModule,
-    SharedGlobalIconModule
-  ],
-
-  declarations: [
-    VideosComponent,
-
-    VideoTrendingComponent,
-    VideoMostLikedComponent,
-    VideoRecentlyAddedComponent,
-    VideoLocalComponent,
-    VideoUserSubscriptionsComponent,
-    VideoOverviewComponent
-  ],
-
-  exports: [
-    VideosComponent
-  ],
-
-  providers: []
-})
-export class VideosModule { }
index bdeef1dad89abcf4abcb2656ecda39e751157267..f70bc736fbabe7503fef2765ab415ab6e70b6884 100644 (file)
@@ -1,4 +1,5 @@
 export * from './video-abuse-create.model'
+export * from './video-abuse-reason.model'
 export * from './video-abuse-state.model'
 export * from './video-abuse-update.model'
 export * from './video-abuse-video-is.type'