From 830b4faff15fb9c81d88e8e69fcdf94aad32bef8 Mon Sep 17 00:00:00 2001
From: Chocobozzz <me@florianbigard.com>
Date: Wed, 6 Mar 2019 15:36:44 +0100
Subject: Add/update/delete/list my playlists

---
 .../app/+my-account/my-account-routing.module.ts   |  37 ++++++
 .../my-account-subscriptions.component.ts          |   4 +-
 .../my-account-video-channels.component.html       |   2 +-
 .../my-account-video-playlist-create.component.ts  |  89 ++++++++++++++
 .../my-account-video-playlist-edit.component.html  |  64 ++++++++++
 .../my-account-video-playlist-edit.component.scss  |  27 +++++
 .../my-account-video-playlist-edit.ts              |  13 ++
 .../my-account-video-playlist-update.component.ts  | 132 ++++++++++++++++++++
 .../my-account-video-playlists.component.html      |  20 ++++
 .../my-account-video-playlists.component.scss      |  50 ++++++++
 .../my-account-video-playlists.component.ts        |  85 +++++++++++++
 client/src/app/+my-account/my-account.component.ts |   4 +
 client/src/app/+my-account/my-account.module.ts    |  13 +-
 client/src/app/app.component.ts                    |   1 +
 client/src/app/core/server/server.service.ts       |  30 ++++-
 client/src/app/shared/buttons/button.component.ts  |   2 +-
 .../src/app/shared/forms/form-validators/index.ts  |   1 +
 .../video-playlist-validators.service.ts           |  52 ++++++++
 .../app/shared/icons/global-icon.component.html    |   0
 .../app/shared/icons/global-icon.component.scss    |   4 -
 .../src/app/shared/icons/global-icon.component.ts  |  48 --------
 .../app/shared/images/global-icon.component.html   |   0
 .../app/shared/images/global-icon.component.scss   |   4 +
 .../src/app/shared/images/global-icon.component.ts |  48 ++++++++
 .../app/shared/images/image-upload.component.html  |   9 ++
 .../app/shared/images/image-upload.component.scss  |  18 +++
 .../app/shared/images/image-upload.component.ts    |  69 +++++++++++
 client/src/app/shared/misc/utils.ts                |   2 +-
 client/src/app/shared/shared.module.ts             |  20 +++-
 .../video-playlist-miniature.component.html        |  22 ++++
 .../video-playlist-miniature.component.scss        |  34 ++++++
 .../video-playlist-miniature.component.ts          |  11 ++
 .../shared/video-playlist/video-playlist.model.ts  |  74 ++++++++++++
 .../video-playlist/video-playlist.service.ts       | 108 +++++++++++++++++
 .../src/app/shared/video/abstract-video-list.scss  |   1 +
 .../shared/video/video-miniature.component.scss    |  22 +---
 .../shared/video/video-thumbnail.component.html    |   6 +-
 .../shared/video/video-thumbnail.component.scss    |  72 ++---------
 client/src/app/shared/video/video.model.ts         |   5 +-
 .../+video-edit/shared/video-edit.component.html   |   8 +-
 .../videos/+video-edit/shared/video-edit.module.ts |   2 -
 .../+video-edit/shared/video-image.component.html  |   9 --
 .../+video-edit/shared/video-image.component.scss  |  18 ---
 .../+video-edit/shared/video-image.component.ts    |  69 -----------
 .../video-list/video-overview.component.scss       |   3 +-
 client/src/sass/include/_miniature.scss            | 133 +++++++++++++++++++++
 client/src/sass/include/_mixins.scss               |  28 -----
 client/tsconfig.json                               |  12 +-
 48 files changed, 1193 insertions(+), 292 deletions(-)
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
 create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
 create mode 100644 client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts
 delete mode 100644 client/src/app/shared/icons/global-icon.component.html
 delete mode 100644 client/src/app/shared/icons/global-icon.component.scss
 delete mode 100644 client/src/app/shared/icons/global-icon.component.ts
 create mode 100644 client/src/app/shared/images/global-icon.component.html
 create mode 100644 client/src/app/shared/images/global-icon.component.scss
 create mode 100644 client/src/app/shared/images/global-icon.component.ts
 create mode 100644 client/src/app/shared/images/image-upload.component.html
 create mode 100644 client/src/app/shared/images/image-upload.component.scss
 create mode 100644 client/src/app/shared/images/image-upload.component.ts
 create mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.html
 create mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
 create mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
 create mode 100644 client/src/app/shared/video-playlist/video-playlist.model.ts
 create mode 100644 client/src/app/shared/video-playlist/video-playlist.service.ts
 delete mode 100644 client/src/app/videos/+video-edit/shared/video-image.component.html
 delete mode 100644 client/src/app/videos/+video-edit/shared/video-image.component.scss
 delete mode 100644 client/src/app/videos/+video-edit/shared/video-image.component.ts
 create mode 100644 client/src/sass/include/_miniature.scss

(limited to 'client')

diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index 9996218ca..0193afff7 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -15,6 +15,13 @@ import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blockli
 import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
 import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
 import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
+import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
+import {
+  MyAccountVideoPlaylistCreateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
+import {
+  MyAccountVideoPlaylistUpdateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
 
 const myAccountRoutes: Routes = [
   {
@@ -36,6 +43,7 @@ const myAccountRoutes: Routes = [
           }
         }
       },
+
       {
         path: 'video-channels',
         component: MyAccountVideoChannelsComponent,
@@ -63,6 +71,35 @@ const myAccountRoutes: Routes = [
           }
         }
       },
+
+      {
+        path: 'video-playlists',
+        component: MyAccountVideoPlaylistsComponent,
+        data: {
+          meta: {
+            title: 'Account playlists'
+          }
+        }
+      },
+      {
+        path: 'video-playlists/create',
+        component: MyAccountVideoPlaylistCreateComponent,
+        data: {
+          meta: {
+            title: 'Create new playlist'
+          }
+        }
+      },
+      {
+        path: 'video-playlists/update/:videoPlaylistId',
+        component: MyAccountVideoPlaylistUpdateComponent,
+        data: {
+          meta: {
+            title: 'Update playlist'
+          }
+        }
+      },
+
       {
         path: 'videos',
         component: MyAccountVideosComponent,
diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
index 9d2dccdf0..6ce22989b 100644
--- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
+++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
@@ -1,7 +1,6 @@
 import { Component, OnInit } from '@angular/core'
 import { Notifier } from '@app/core'
 import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
 import { UserSubscriptionService } from '@app/shared/user-subscription'
 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
 
@@ -21,8 +20,7 @@ export class MyAccountSubscriptionsComponent implements OnInit {
 
   constructor (
     private userSubscriptionService: UserSubscriptionService,
-    private notifier: Notifier,
-    private i18n: I18n
+    private notifier: Notifier
   ) {}
 
   ngOnInit () {
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
index 51db2e75d..11e87ba79 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
@@ -1,7 +1,7 @@
 <div class="video-channels-header">
   <a class="create-button" routerLink="create">
     <my-global-icon iconName="add"></my-global-icon>
-    <ng-container i18n>Create another video channel</ng-container>
+    <ng-container i18n>Create a new video channel</ng-container>
   </a>
 </div>
 
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
new file mode 100644
index 000000000..61b61e221
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts
@@ -0,0 +1,89 @@
+import { Component, OnInit } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { VideoPlaylistValidatorsService } from '@app/shared'
+import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { VideoConstant } from '@shared/models'
+import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
+import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
+
+@Component({
+  selector: 'my-account-video-playlist-create',
+  templateUrl: './my-account-video-playlist-edit.component.html',
+  styleUrls: [ './my-account-video-playlist-edit.component.scss' ]
+})
+export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylistEdit implements OnInit {
+  error: string
+  videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private authService: AuthService,
+    private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
+    private notifier: Notifier,
+    private router: Router,
+    private videoPlaylistService: VideoPlaylistService,
+    private serverService: ServerService,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME,
+      privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY,
+      description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION,
+      videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID,
+      thumbnailfile: null
+    })
+
+    populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
+
+    this.serverService.videoPlaylistPrivaciesLoaded.subscribe(
+      () => {
+        this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies()
+
+        this.form.patchValue({
+          privacy: VideoPlaylistPrivacy.PRIVATE
+        })
+      }
+    )
+  }
+
+  formValidated () {
+    this.error = undefined
+
+    const body = this.form.value
+    const videoPlaylistCreate: VideoPlaylistCreate = {
+      displayName: body['display-name'],
+      privacy: body.privacy,
+      description: body.description || null,
+      videoChannelId: body.videoChannelId || null,
+      thumbnailfile: body.thumbnailfile || null
+    }
+
+    this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
+      () => {
+        this.notifier.success(
+          this.i18n('Playlist {{playlistName}} created.', { playlistName: videoPlaylistCreate.displayName })
+        )
+        this.router.navigate([ '/my-account', 'video-playlists' ])
+      },
+
+      err => this.error = err.message
+    )
+  }
+
+  isCreation () {
+    return true
+  }
+
+  getFormButtonTitle () {
+    return this.i18n('Create')
+  }
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
new file mode 100644
index 000000000..b76488c78
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
@@ -0,0 +1,64 @@
+<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a new playlist</div>
+
+<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
+
+<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
+  <div class="row">
+    <div class="col-md-12 col-xl-6">
+      <div class="form-group">
+        <label i18n for="display-name">Display name</label>
+        <input
+          type="text" id="display-name"
+          formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
+        >
+        <div *ngIf="formErrors['display-name']" class="form-error">
+          {{ formErrors['display-name'] }}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="description">Description</label>
+        <textarea
+          id="description" formControlName="description"
+          [ngClass]="{ 'input-error': formErrors['description'] }"
+        ></textarea>
+        <div *ngIf="formErrors.description" class="form-error">
+          {{ formErrors.description }}
+        </div>
+      </div>
+    </div>
+
+    <div class="col-md-12 col-xl-6">
+      <div class="form-group">
+        <label i18n for="privacy">Privacy</label>
+        <div class="peertube-select-container">
+          <select id="privacy" formControlName="privacy">
+            <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+          </select>
+        </div>
+
+        <div *ngIf="formErrors.privacy" class="form-error">
+          {{ formErrors.privacy }}
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label i18n>Channel</label>
+        <div class="peertube-select-container">
+          <select formControlName="videoChannelId">
+            <option></option>
+            <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+          </select>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <my-image-upload
+          i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
+          previewWidth="200px" previewHeight="110px"
+        ></my-image-upload>
+      </div>
+    </div>
+  </div>
+  <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
+</form>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
new file mode 100644
index 000000000..5af846d8e
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss
@@ -0,0 +1,27 @@
+@import '_variables';
+@import '_mixins';
+
+.form-sub-title {
+  margin-bottom: 20px;
+}
+
+input[type=text] {
+  @include peertube-input-text(340px);
+
+  display: block;
+}
+
+textarea {
+  @include peertube-textarea(500px, 150px);
+
+  display: block;
+}
+
+.peertube-select-container {
+  @include peertube-select-container(340px);
+}
+
+input[type=submit] {
+  @include peertube-button;
+  @include orange-button;
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
new file mode 100644
index 000000000..fbfb4c8f7
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
@@ -0,0 +1,13 @@
+import { FormReactive } from '@app/shared'
+import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { ServerService } from '@app/core'
+import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
+
+export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
+  // Declare it here to avoid errors in create template
+  videoPlaylistToUpdate: VideoPlaylist
+  userVideoChannels: { id: number, label: string }[] = []
+
+  abstract isCreation (): boolean
+  abstract getFormButtonTitle (): string
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
new file mode 100644
index 000000000..167d7dd09
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts
@@ -0,0 +1,132 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { Subscription } from 'rxjs'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { MyAccountVideoPlaylistEdit } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-edit'
+import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { VideoPlaylistValidatorsService } from '@app/shared'
+import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
+import { VideoConstant } from '@shared/models'
+import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+
+@Component({
+  selector: 'my-account-video-playlist-update',
+  templateUrl: './my-account-video-playlist-edit.component.html',
+  styleUrls: [ './my-account-video-playlist-edit.component.scss' ]
+})
+export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylistEdit implements OnInit, OnDestroy {
+  error: string
+  videoPlaylistToUpdate: VideoPlaylist
+  videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
+
+  private paramsSub: Subscription
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private authService: AuthService,
+    private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
+    private notifier: Notifier,
+    private router: Router,
+    private route: ActivatedRoute,
+    private videoPlaylistService: VideoPlaylistService,
+    private i18n: I18n,
+    private serverService: ServerService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME,
+      privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY,
+      description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION,
+      videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID,
+      thumbnailfile: null
+    })
+
+    populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
+
+    this.paramsSub = this.route.params.subscribe(routeParams => {
+      const videoPlaylistId = routeParams['videoPlaylistId']
+
+      this.videoPlaylistService.getVideoPlaylist(videoPlaylistId).subscribe(
+        videoPlaylistToUpdate => {
+          this.videoPlaylistToUpdate = videoPlaylistToUpdate
+
+          this.hydrateFormFromPlaylist()
+
+          this.serverService.videoPlaylistPrivaciesLoaded.subscribe(
+            () => {
+              this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies()
+                .filter(p => {
+                  // If the playlist is not private, we cannot put it in private anymore
+                  return this.videoPlaylistToUpdate.privacy.id === VideoPlaylistPrivacy.PRIVATE ||
+                    p.id !== VideoPlaylistPrivacy.PRIVATE
+                })
+            }
+          )
+        },
+
+        err => this.error = err.message
+      )
+    })
+  }
+
+  ngOnDestroy () {
+    if (this.paramsSub) this.paramsSub.unsubscribe()
+  }
+
+  formValidated () {
+    this.error = undefined
+
+    const body = this.form.value
+    const videoPlaylistUpdate: VideoPlaylistUpdate = {
+      displayName: body['display-name'],
+      privacy: body['privacy'],
+      description: body.description || null,
+      videoChannelId: body.videoChannelId || null,
+      thumbnailfile: body.thumbnailfile || undefined
+    }
+
+    this.videoPlaylistService.updateVideoPlaylist(this.videoPlaylistToUpdate, videoPlaylistUpdate).subscribe(
+      () => {
+        this.notifier.success(
+          this.i18n('Playlist {{videoPlaylistName}} updated.', { videoPlaylistName: videoPlaylistUpdate.displayName })
+        )
+
+        this.router.navigate([ '/my-account', 'video-playlists' ])
+      },
+
+      err => this.error = err.message
+    )
+  }
+
+  isCreation () {
+    return false
+  }
+
+  getFormButtonTitle () {
+    return this.i18n('Update')
+  }
+
+  private hydrateFormFromPlaylist () {
+    this.form.patchValue({
+      'display-name': this.videoPlaylistToUpdate.displayName,
+      privacy: this.videoPlaylistToUpdate.privacy.id,
+      description: this.videoPlaylistToUpdate.description,
+      videoChannelId: this.videoPlaylistToUpdate.videoChannel ? this.videoPlaylistToUpdate.videoChannel.id : null
+    })
+
+    fetch(this.videoPlaylistToUpdate.thumbnailUrl)
+      .then(response => response.blob())
+      .then(data => {
+        this.form.patchValue({
+          thumbnailfile: data
+        })
+      })
+  }
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
new file mode 100644
index 000000000..ab5d9cc5a
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
@@ -0,0 +1,20 @@
+<div class="video-playlists-header">
+  <a class="create-button" routerLink="create">
+    <my-global-icon iconName="add"></my-global-icon>
+    <ng-container i18n>Create a new playlist</ng-container>
+  </a>
+</div>
+
+<div class="video-playlists">
+  <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
+    <div class="miniature-wrapper">
+      <my-video-playlist-miniature [playlist]="playlist"></my-video-playlist-miniature>
+    </div>
+
+    <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
+      <my-delete-button (click)="deleteVideoPlaylist(playlist)"></my-delete-button>
+
+      <my-edit-button [routerLink]="[ 'update', playlist.uuid ]"></my-edit-button>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
new file mode 100644
index 000000000..88fba5b05
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
@@ -0,0 +1,50 @@
+@import '_variables';
+@import '_mixins';
+
+.create-button {
+  @include create-button;
+}
+
+/deep/ .action-button {
+  &.action-button-delete {
+    margin-right: 10px;
+  }
+}
+
+.video-playlist {
+  @include row-blocks;
+
+  .miniature-wrapper {
+    flex-grow: 1;
+
+    /deep/ .miniature {
+      display: flex;
+
+      .miniature-bottom {
+        margin-left: 10px;
+      }
+    }
+  }
+
+  .video-playlist-buttons {
+    min-width: 190px;
+  }
+}
+
+.video-playlists-header {
+  text-align: right;
+  margin: 20px 0 50px;
+}
+
+@media screen and (max-width: 800px) {
+  .video-playlists-header {
+    text-align: center;
+  }
+
+  .video-playlist {
+
+    .video-playlist-buttons {
+      margin-top: 10px;
+    }
+  }
+}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
new file mode 100644
index 000000000..761ce90e8
--- /dev/null
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
@@ -0,0 +1,85 @@
+import { Component, OnInit } from '@angular/core'
+import { Notifier } from '@app/core'
+import { AuthService } from '../../core/auth'
+import { ConfirmService } from '../../core/confirm'
+import { User } from '@app/shared'
+import { flatMap } from 'rxjs/operators'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { VideoPlaylistType } from '@shared/models'
+
+@Component({
+  selector: 'my-account-video-playlists',
+  templateUrl: './my-account-video-playlists.component.html',
+  styleUrls: [ './my-account-video-playlists.component.scss' ]
+})
+export class MyAccountVideoPlaylistsComponent implements OnInit {
+  videoPlaylists: VideoPlaylist[] = []
+
+  pagination: ComponentPagination = {
+    currentPage: 1,
+    itemsPerPage: 10,
+    totalItems: null
+  }
+
+  private user: User
+
+  constructor (
+    private authService: AuthService,
+    private notifier: Notifier,
+    private confirmService: ConfirmService,
+    private videoPlaylistService: VideoPlaylistService,
+    private i18n: I18n
+  ) {}
+
+  ngOnInit () {
+    this.user = this.authService.getUser()
+
+    this.loadVideoPlaylists()
+  }
+
+  async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) {
+    const res = await this.confirmService.confirm(
+      this.i18n(
+        'Do you really want to delete {{playlistDisplayName}}?',
+        { playlistDisplayName: videoPlaylist.displayName }
+      ),
+      this.i18n('Delete')
+    )
+    if (res === false) return
+
+    this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
+      .subscribe(
+        () => {
+          this.videoPlaylists = this.videoPlaylists
+                                    .filter(p => p.id !== videoPlaylist.id)
+
+          this.notifier.success(
+            this.i18n('Playlist {{playlistDisplayName}} deleted.', { playlistDisplayName: videoPlaylist.displayName })
+          )
+        },
+
+        error => this.notifier.error(error.message)
+      )
+  }
+
+  isRegularPlaylist (playlist: VideoPlaylist) {
+    return playlist.type.id === VideoPlaylistType.REGULAR
+  }
+
+  private loadVideoPlaylists () {
+    this.authService.userInformationLoaded
+        .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account)))
+        .subscribe(res => this.videoPlaylists = res.data)
+  }
+
+  private ofNearOfBottom () {
+    // Last page
+    if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
+
+    this.pagination.currentPage += 1
+    this.loadVideoPlaylists()
+  }
+}
diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts
index 8a4102d80..f624ff505 100644
--- a/client/src/app/+my-account/my-account.component.ts
+++ b/client/src/app/+my-account/my-account.component.ts
@@ -27,6 +27,10 @@ export class MyAccountComponent {
           label: this.i18n('My videos'),
           routerLink: '/my-account/videos'
         },
+        {
+          label: this.i18n('My playlists'),
+          routerLink: '/my-account/video-playlists'
+        },
         {
           label: this.i18n('My subscriptions'),
           routerLink: '/my-account/subscriptions'
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 18f51f171..3dbce2b92 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -25,6 +25,13 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b
 import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
 import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
 import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
+import {
+  MyAccountVideoPlaylistCreateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
+import {
+  MyAccountVideoPlaylistUpdateComponent
+} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
+import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
 
 @NgModule({
   imports: [
@@ -57,7 +64,11 @@ import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-a
     MyAccountServerBlocklistComponent,
     MyAccountHistoryComponent,
     MyAccountNotificationsComponent,
-    MyAccountNotificationPreferencesComponent
+    MyAccountNotificationPreferencesComponent,
+
+    MyAccountVideoPlaylistCreateComponent,
+    MyAccountVideoPlaylistUpdateComponent,
+    MyAccountVideoPlaylistsComponent
   ],
 
   exports: [
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 7583fdee8..c5c5a8f66 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -74,6 +74,7 @@ export class AppComponent implements OnInit {
     this.serverService.loadVideoLanguages()
     this.serverService.loadVideoLicences()
     this.serverService.loadVideoPrivacies()
+    this.serverService.loadVideoPlaylistPrivacies()
 
     // Do not display menu on small screens
     if (this.screenService.isInSmallView()) {
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 10acf6e72..acaca8a01 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -9,17 +9,20 @@ import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos
 import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
 import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
 import { sortBy } from '@app/shared/misc/utils'
+import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
 
 @Injectable()
 export class ServerService {
   private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/'
   private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/'
   private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
+  private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
   private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
   private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
 
   configLoaded = new ReplaySubject<boolean>(1)
   videoPrivaciesLoaded = new ReplaySubject<boolean>(1)
+  videoPlaylistPrivaciesLoaded = new ReplaySubject<boolean>(1)
   videoCategoriesLoaded = new ReplaySubject<boolean>(1)
   videoLicencesLoaded = new ReplaySubject<boolean>(1)
   videoLanguagesLoaded = new ReplaySubject<boolean>(1)
@@ -101,6 +104,7 @@ export class ServerService {
   private videoLicences: Array<VideoConstant<number>> = []
   private videoLanguages: Array<VideoConstant<string>> = []
   private videoPrivacies: Array<VideoConstant<VideoPrivacy>> = []
+  private videoPlaylistPrivacies: Array<VideoConstant<VideoPlaylistPrivacy>> = []
 
   constructor (
     private http: HttpClient,
@@ -121,19 +125,28 @@ export class ServerService {
   }
 
   loadVideoCategories () {
-    return this.loadVideoAttributeEnum('categories', this.videoCategories, this.videoCategoriesLoaded, true)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'categories', this.videoCategories, this.videoCategoriesLoaded, true)
   }
 
   loadVideoLicences () {
-    return this.loadVideoAttributeEnum('licences', this.videoLicences, this.videoLicencesLoaded)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'licences', this.videoLicences, this.videoLicencesLoaded)
   }
 
   loadVideoLanguages () {
-    return this.loadVideoAttributeEnum('languages', this.videoLanguages, this.videoLanguagesLoaded, true)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'languages', this.videoLanguages, this.videoLanguagesLoaded, true)
   }
 
   loadVideoPrivacies () {
-    return this.loadVideoAttributeEnum('privacies', this.videoPrivacies, this.videoPrivaciesLoaded)
+    return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'privacies', this.videoPrivacies, this.videoPrivaciesLoaded)
+  }
+
+  loadVideoPlaylistPrivacies () {
+    return this.loadAttributeEnum(
+      ServerService.BASE_VIDEO_PLAYLIST_URL,
+      'privacies',
+      this.videoPlaylistPrivacies,
+      this.videoPlaylistPrivaciesLoaded
+    )
   }
 
   getConfig () {
@@ -156,7 +169,12 @@ export class ServerService {
     return this.videoPrivacies
   }
 
-  private loadVideoAttributeEnum (
+  getVideoPlaylistPrivacies () {
+    return this.videoPlaylistPrivacies
+  }
+
+  private loadAttributeEnum (
+    baseUrl: string,
     attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
     hashToPopulate: VideoConstant<string | number>[],
     notifier: ReplaySubject<boolean>,
@@ -165,7 +183,7 @@ export class ServerService {
     this.localeObservable
         .pipe(
           switchMap(translations => {
-            return this.http.get<{ [id: string]: string }>(ServerService.BASE_VIDEO_URL + attributeName)
+            return this.http.get<{ [id: string]: string }>(baseUrl + attributeName)
                        .pipe(map(data => ({ data, translations })))
           })
         )
diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts
index a91e9c7eb..c2b69d31a 100644
--- a/client/src/app/shared/buttons/button.component.ts
+++ b/client/src/app/shared/buttons/button.component.ts
@@ -1,5 +1,5 @@
 import { Component, Input } from '@angular/core'
-import { GlobalIconName } from '@app/shared/icons/global-icon.component'
+import { GlobalIconName } from '@app/shared/images/global-icon.component'
 
 @Component({
   selector: 'my-button',
diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts
index fdcbedb71..e3de3ae13 100644
--- a/client/src/app/shared/forms/form-validators/index.ts
+++ b/client/src/app/shared/forms/form-validators/index.ts
@@ -10,6 +10,7 @@ export * from './video-blacklist-validators.service'
 export * from './video-channel-validators.service'
 export * from './video-comment-validators.service'
 export * from './video-validators.service'
+export * from './video-playlist-validators.service'
 export * from './video-captions-validators.service'
 export * from './video-change-ownership-validators.service'
 export * from './video-accept-ownership-validators.service'
diff --git a/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts
new file mode 100644
index 000000000..726084b47
--- /dev/null
+++ b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts
@@ -0,0 +1,52 @@
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from '@app/shared'
+
+@Injectable()
+export class VideoPlaylistValidatorsService {
+  readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator
+  readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator
+  readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator
+  readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator
+
+  constructor (private i18n: I18n) {
+    this.VIDEO_PLAYLIST_DISPLAY_NAME = {
+      VALIDATORS: [
+        Validators.required,
+        Validators.minLength(1),
+        Validators.maxLength(120)
+      ],
+      MESSAGES: {
+        'required': this.i18n('Display name is required.'),
+        'minlength': this.i18n('Display name must be at least 1 character long.'),
+        'maxlength': this.i18n('Display name cannot be more than 120 characters long.')
+      }
+    }
+
+    this.VIDEO_PLAYLIST_PRIVACY = {
+      VALIDATORS: [
+        Validators.required
+      ],
+      MESSAGES: {
+        'required': this.i18n('Privacy is required.')
+      }
+    }
+
+    this.VIDEO_PLAYLIST_DESCRIPTION = {
+      VALIDATORS: [
+        Validators.minLength(3),
+        Validators.maxLength(1000)
+      ],
+      MESSAGES: {
+        'minlength': i18n('Description must be at least 3 characters long.'),
+        'maxlength': i18n('Description cannot be more than 1000 characters long.')
+      }
+    }
+
+    this.VIDEO_PLAYLIST_CHANNEL_ID = {
+      VALIDATORS: [ ],
+      MESSAGES: { }
+    }
+  }
+}
diff --git a/client/src/app/shared/icons/global-icon.component.html b/client/src/app/shared/icons/global-icon.component.html
deleted file mode 100644
index e69de29bb..000000000
diff --git a/client/src/app/shared/icons/global-icon.component.scss b/client/src/app/shared/icons/global-icon.component.scss
deleted file mode 100644
index 6805fb6f7..000000000
--- a/client/src/app/shared/icons/global-icon.component.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-/deep/ svg {
-  width: inherit;
-  height: inherit;
-}
diff --git a/client/src/app/shared/icons/global-icon.component.ts b/client/src/app/shared/icons/global-icon.component.ts
deleted file mode 100644
index e8ada0324..000000000
--- a/client/src/app/shared/icons/global-icon.component.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Component, ElementRef, Input, OnInit } from '@angular/core'
-
-const icons = {
-  'add': require('../../../assets/images/global/add.html'),
-  'syndication': require('../../../assets/images/global/syndication.html'),
-  'help': require('../../../assets/images/global/help.html'),
-  'sparkle': require('../../../assets/images/global/sparkle.html'),
-  'alert': require('../../../assets/images/global/alert.html'),
-  'cloud-error': require('../../../assets/images/global/cloud-error.html'),
-  'user-add': require('../../../assets/images/global/user-add.html'),
-  'no': require('../../../assets/images/global/no.html'),
-  'cloud-download': require('../../../assets/images/global/cloud-download.html'),
-  'undo': require('../../../assets/images/global/undo.html'),
-  'circle-tick': require('../../../assets/images/global/circle-tick.html'),
-  'cog': require('../../../assets/images/global/cog.html'),
-  'download': require('../../../assets/images/global/download.html'),
-  'edit': require('../../../assets/images/global/edit.html'),
-  'im-with-her': require('../../../assets/images/global/im-with-her.html'),
-  'delete': require('../../../assets/images/global/delete.html'),
-  'cross': require('../../../assets/images/global/cross.html'),
-  'validate': require('../../../assets/images/global/validate.html'),
-  'tick': require('../../../assets/images/global/tick.html'),
-  'dislike': require('../../../assets/images/video/dislike.html'),
-  'heart': require('../../../assets/images/video/heart.html'),
-  'like': require('../../../assets/images/video/like.html'),
-  'more': require('../../../assets/images/video/more.html'),
-  'share': require('../../../assets/images/video/share.html'),
-  'upload': require('../../../assets/images/video/upload.html')
-}
-
-export type GlobalIconName = keyof typeof icons
-
-@Component({
-  selector: 'my-global-icon',
-  template: '',
-  styleUrls: [ './global-icon.component.scss' ]
-})
-export class GlobalIconComponent implements OnInit {
-  @Input() iconName: GlobalIconName
-
-  constructor (private el: ElementRef) {}
-
-  ngOnInit () {
-    const nativeElement = this.el.nativeElement
-
-    nativeElement.innerHTML = icons[this.iconName]
-  }
-}
diff --git a/client/src/app/shared/images/global-icon.component.html b/client/src/app/shared/images/global-icon.component.html
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/src/app/shared/images/global-icon.component.scss b/client/src/app/shared/images/global-icon.component.scss
new file mode 100644
index 000000000..6805fb6f7
--- /dev/null
+++ b/client/src/app/shared/images/global-icon.component.scss
@@ -0,0 +1,4 @@
+/deep/ svg {
+  width: inherit;
+  height: inherit;
+}
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts
new file mode 100644
index 000000000..e8ada0324
--- /dev/null
+++ b/client/src/app/shared/images/global-icon.component.ts
@@ -0,0 +1,48 @@
+import { Component, ElementRef, Input, OnInit } from '@angular/core'
+
+const icons = {
+  'add': require('../../../assets/images/global/add.html'),
+  'syndication': require('../../../assets/images/global/syndication.html'),
+  'help': require('../../../assets/images/global/help.html'),
+  'sparkle': require('../../../assets/images/global/sparkle.html'),
+  'alert': require('../../../assets/images/global/alert.html'),
+  'cloud-error': require('../../../assets/images/global/cloud-error.html'),
+  'user-add': require('../../../assets/images/global/user-add.html'),
+  'no': require('../../../assets/images/global/no.html'),
+  'cloud-download': require('../../../assets/images/global/cloud-download.html'),
+  'undo': require('../../../assets/images/global/undo.html'),
+  'circle-tick': require('../../../assets/images/global/circle-tick.html'),
+  'cog': require('../../../assets/images/global/cog.html'),
+  'download': require('../../../assets/images/global/download.html'),
+  'edit': require('../../../assets/images/global/edit.html'),
+  'im-with-her': require('../../../assets/images/global/im-with-her.html'),
+  'delete': require('../../../assets/images/global/delete.html'),
+  'cross': require('../../../assets/images/global/cross.html'),
+  'validate': require('../../../assets/images/global/validate.html'),
+  'tick': require('../../../assets/images/global/tick.html'),
+  'dislike': require('../../../assets/images/video/dislike.html'),
+  'heart': require('../../../assets/images/video/heart.html'),
+  'like': require('../../../assets/images/video/like.html'),
+  'more': require('../../../assets/images/video/more.html'),
+  'share': require('../../../assets/images/video/share.html'),
+  'upload': require('../../../assets/images/video/upload.html')
+}
+
+export type GlobalIconName = keyof typeof icons
+
+@Component({
+  selector: 'my-global-icon',
+  template: '',
+  styleUrls: [ './global-icon.component.scss' ]
+})
+export class GlobalIconComponent implements OnInit {
+  @Input() iconName: GlobalIconName
+
+  constructor (private el: ElementRef) {}
+
+  ngOnInit () {
+    const nativeElement = this.el.nativeElement
+
+    nativeElement.innerHTML = icons[this.iconName]
+  }
+}
diff --git a/client/src/app/shared/images/image-upload.component.html b/client/src/app/shared/images/image-upload.component.html
new file mode 100644
index 000000000..c09c862c4
--- /dev/null
+++ b/client/src/app/shared/images/image-upload.component.html
@@ -0,0 +1,9 @@
+<div class="root">
+  <my-reactive-file
+    [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
+    (fileChanged)="onFileChanged($event)"
+  ></my-reactive-file>
+
+  <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
+  <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
+</div>
diff --git a/client/src/app/shared/images/image-upload.component.scss b/client/src/app/shared/images/image-upload.component.scss
new file mode 100644
index 000000000..b63963bca
--- /dev/null
+++ b/client/src/app/shared/images/image-upload.component.scss
@@ -0,0 +1,18 @@
+@import '_variables';
+@import '_mixins';
+
+.root {
+  height: auto;
+  display: flex;
+  align-items: center;
+
+  .preview {
+    border: 2px solid grey;
+    border-radius: 4px;
+    margin-left: 50px;
+
+    &.no-image {
+      background-color: #ececec;
+    }
+  }
+}
diff --git a/client/src/app/shared/images/image-upload.component.ts b/client/src/app/shared/images/image-upload.component.ts
new file mode 100644
index 000000000..2da1592ff
--- /dev/null
+++ b/client/src/app/shared/images/image-upload.component.ts
@@ -0,0 +1,69 @@
+import { Component, forwardRef, Input } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
+import { ServerService } from '@app/core'
+
+@Component({
+  selector: 'my-image-upload',
+  styleUrls: [ './image-upload.component.scss' ],
+  templateUrl: './image-upload.component.html',
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => ImageUploadComponent),
+      multi: true
+    }
+  ]
+})
+export class ImageUploadComponent implements ControlValueAccessor {
+  @Input() inputLabel: string
+  @Input() inputName: string
+  @Input() previewWidth: string
+  @Input() previewHeight: string
+
+  imageSrc: SafeResourceUrl
+
+  private file: File
+
+  constructor (
+    private sanitizer: DomSanitizer,
+    private serverService: ServerService
+  ) {}
+
+  get videoImageExtensions () {
+    return this.serverService.getConfig().video.image.extensions
+  }
+
+  get maxVideoImageSize () {
+    return this.serverService.getConfig().video.image.size.max
+  }
+
+  onFileChanged (file: File) {
+    this.file = file
+
+    this.propagateChange(this.file)
+    this.updatePreview()
+  }
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (file: any) {
+    this.file = file
+    this.updatePreview()
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  private updatePreview () {
+    if (this.file) {
+      const url = URL.createObjectURL(this.file)
+      this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
+    }
+  }
+}
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts
index 7cc6055c2..8a1d342c9 100644
--- a/client/src/app/shared/misc/utils.ts
+++ b/client/src/app/shared/misc/utils.ts
@@ -17,7 +17,7 @@ function getParameterByName (name: string, url: string) {
   return decodeURIComponent(results[2].replace(/\+/g, ' '))
 }
 
-function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support: string }[]) {
+function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) {
   return new Promise(res => {
     authService.userInformationLoaded
       .subscribe(
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 1c4e3df1a..60a7bd6e2 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -45,6 +45,7 @@ import {
   VideoChangeOwnershipValidatorsService,
   VideoChannelValidatorsService,
   VideoCommentValidatorsService,
+  VideoPlaylistValidatorsService,
   VideoValidatorsService
 } from '@app/shared/forms'
 import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
@@ -68,8 +69,11 @@ import { UserNotificationsComponent } from '@app/shared/users/user-notifications
 import { InstanceService } from '@app/shared/instance/instance.service'
 import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer'
 import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
-import { GlobalIconComponent } from '@app/shared/icons/global-icon.component'
 import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { ImageUploadComponent } from '@app/shared/images/image-upload.component'
+import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
+import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
 
 @NgModule({
   imports: [
@@ -92,8 +96,11 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
   declarations: [
     LoaderComponent,
     SmallLoaderComponent,
+
     VideoThumbnailComponent,
     VideoMiniatureComponent,
+    VideoPlaylistMiniatureComponent,
+
     FeedComponent,
     ButtonComponent,
     DeleteButtonComponent,
@@ -116,7 +123,9 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     TopMenuDropdownComponent,
     UserNotificationsComponent,
     ConfirmComponent,
-    GlobalIconComponent
+
+    GlobalIconComponent,
+    ImageUploadComponent
   ],
 
   exports: [
@@ -138,8 +147,11 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
 
     LoaderComponent,
     SmallLoaderComponent,
+
     VideoThumbnailComponent,
     VideoMiniatureComponent,
+    VideoPlaylistMiniatureComponent,
+
     FeedComponent,
     ButtonComponent,
     DeleteButtonComponent,
@@ -159,7 +171,9 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     TopMenuDropdownComponent,
     UserNotificationsComponent,
     ConfirmComponent,
+
     GlobalIconComponent,
+    ImageUploadComponent,
 
     NumberFormatterPipe,
     ObjectLengthPipe,
@@ -177,6 +191,7 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     VideoService,
     AccountService,
     VideoChannelService,
+    VideoPlaylistService,
     VideoCaptionService,
     VideoImportService,
     UserSubscriptionService,
@@ -186,6 +201,7 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
     LoginValidatorsService,
     ResetPasswordValidatorsService,
     UserValidatorsService,
+    VideoPlaylistValidatorsService,
     VideoAbuseValidatorsService,
     VideoChannelValidatorsService,
     VideoCommentValidatorsService,
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
new file mode 100644
index 000000000..1a39f5fe5
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html
@@ -0,0 +1,22 @@
+<div class="miniature">
+  <a
+    [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName"
+    class="miniature-thumbnail"
+  >
+    <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
+
+    <div class="miniature-playlist-info-overlay">
+      <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{playlist.videosLength}} videos}}</ng-container>
+    </div>
+
+    <div class="play-overlay">
+      <div class="icon"></div>
+    </div>
+  </a>
+
+  <div class="miniature-bottom">
+    <a tabindex="-1" class="miniature-name" [routerLink]="[ '/videos/watch' ]" [attr.title]="playlist.displayName">
+      {{ playlist.displayName }}
+    </a>
+  </div>
+</div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
new file mode 100644
index 000000000..a47206577
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss
@@ -0,0 +1,34 @@
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.miniature {
+  display: inline-block;
+
+  .miniature-thumbnail {
+    @include miniature-thumbnail;
+
+    .miniature-playlist-info-overlay {
+      @include static-thumbnail-overlay;
+
+      position: absolute;
+      right: 0;
+      bottom: 0;
+      height: $video-thumbnail-height;
+      padding: 0 10px;
+      display: flex;
+      align-items: center;
+      font-size: 15px;
+    }
+  }
+
+  .miniature-bottom {
+    width: 200px;
+    margin-top: 2px;
+    line-height: normal;
+
+    .miniature-name {
+      @include miniature-name;
+    }
+  }
+}
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
new file mode 100644
index 000000000..b3bba7c87
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts
@@ -0,0 +1,11 @@
+import { Component, Input } from '@angular/core'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+
+@Component({
+  selector: 'my-video-playlist-miniature',
+  styleUrls: [ './video-playlist-miniature.component.scss' ],
+  templateUrl: './video-playlist-miniature.component.html'
+})
+export class VideoPlaylistMiniatureComponent {
+  @Input() playlist: VideoPlaylist
+}
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
new file mode 100644
index 000000000..9d0b02789
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist.model.ts
@@ -0,0 +1,74 @@
+import {
+  VideoChannelSummary,
+  VideoConstant,
+  VideoPlaylist as ServerVideoPlaylist,
+  VideoPlaylistPrivacy,
+  VideoPlaylistType
+} from '../../../../../shared/models/videos'
+import { AccountSummary, peertubeTranslate } from '@shared/models'
+import { Actor } from '@app/shared/actor/actor.model'
+import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
+
+export class VideoPlaylist implements ServerVideoPlaylist {
+  id: number
+  uuid: string
+  isLocal: boolean
+
+  displayName: string
+  description: string
+  privacy: VideoConstant<VideoPlaylistPrivacy>
+
+  thumbnailPath: string
+
+  videosLength: number
+
+  type: VideoConstant<VideoPlaylistType>
+
+  createdAt: Date | string
+  updatedAt: Date | string
+
+  ownerAccount: AccountSummary
+  videoChannel?: VideoChannelSummary
+
+  thumbnailUrl: string
+
+  ownerBy: string
+  ownerAvatarUrl: string
+
+  videoChannelBy?: string
+  videoChannelAvatarUrl?: string
+
+  constructor (hash: ServerVideoPlaylist, translations: {}) {
+    const absoluteAPIUrl = getAbsoluteAPIUrl()
+
+    this.id = hash.id
+    this.uuid = hash.uuid
+    this.isLocal = hash.isLocal
+
+    this.displayName = hash.displayName
+    this.description = hash.description
+    this.privacy = hash.privacy
+
+    this.thumbnailPath = hash.thumbnailPath
+    this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
+
+    this.videosLength = hash.videosLength
+
+    this.type = hash.type
+
+    this.createdAt = new Date(hash.createdAt)
+    this.updatedAt = new Date(hash.updatedAt)
+
+    this.ownerAccount = hash.ownerAccount
+    this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
+    this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
+
+    if (hash.videoChannel) {
+      this.videoChannel = hash.videoChannel
+      this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
+      this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
+    }
+
+    this.privacy.label = peertubeTranslate(this.privacy.label, translations)
+  }
+}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
new file mode 100644
index 000000000..8b66e122c
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist.service.ts
@@ -0,0 +1,108 @@
+import { catchError, map, switchMap } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { Observable } from 'rxjs'
+import { RestExtractor } from '../rest/rest-extractor.service'
+import { HttpClient } from '@angular/common/http'
+import { ResultList } from '../../../../../shared'
+import { environment } from '../../../environments/environment'
+import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
+import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
+import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
+import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
+import { objectToFormData } from '@app/shared/misc/utils'
+import { ServerService } from '@app/core'
+import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+import { AccountService } from '@app/shared/account/account.service'
+import { Account } from '@app/shared/account/account.model'
+
+@Injectable()
+export class VideoPlaylistService {
+  static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private serverService: ServerService,
+    private restExtractor: RestExtractor
+  ) { }
+
+  listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> {
+    const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
+
+    return this.authHttp.get<ResultList<VideoPlaylist>>(url)
+               .pipe(
+                 switchMap(res => this.extractPlaylists(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  listAccountPlaylists (account: Account): Observable<ResultList<VideoPlaylist>> {
+    const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
+
+    return this.authHttp.get<ResultList<VideoPlaylist>>(url)
+               .pipe(
+                 switchMap(res => this.extractPlaylists(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  getVideoPlaylist (id: string | number) {
+    const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
+
+    return this.authHttp.get<VideoPlaylist>(url)
+               .pipe(
+                 switchMap(res => this.extractPlaylist(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  createVideoPlaylist (body: VideoPlaylistCreate) {
+    const data = objectToFormData(body)
+
+    return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
+    const data = objectToFormData(body)
+
+    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
+    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
+               .pipe(
+                 map(this.restExtractor.extractDataBool),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
+  extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
+    return this.serverService.localeObservable
+               .pipe(
+                 map(translations => {
+                   const playlistsJSON = result.data
+                   const total = result.total
+                   const playlists: VideoPlaylist[] = []
+
+                   for (const playlistJSON of playlistsJSON) {
+                     playlists.push(new VideoPlaylist(playlistJSON, translations))
+                   }
+
+                   return { data: playlists, total }
+                 })
+               )
+  }
+
+  extractPlaylist (playlist: VideoPlaylistServerModel) {
+    return this.serverService.localeObservable
+               .pipe(map(translations => new VideoPlaylist(playlist, translations)))
+  }
+}
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 292ede698..65842af35 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -1,4 +1,5 @@
 @import '_mixins';
+@import '_miniature';
 
 .videos {
   text-align: center;
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
index c118fc3a1..7d857a74e 100644
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ b/client/src/app/shared/video/video-miniature.component.scss
@@ -1,5 +1,6 @@
 @import '_variables';
 @import '_mixins';
+@import '_miniature';
 
 .video-miniature {
   display: inline-block;
@@ -14,26 +15,7 @@
     line-height: normal;
 
     .video-miniature-name {
-      @include ellipsis-multiline(
-        $font-size: 1rem,
-        $line-height: 1,
-        $lines-to-show: 2
-      );
-      transition: color 0.2s;
-      font-size: 16px;
-      font-weight: $font-semibold;
-      color: var(--mainForegroundColor);
-      margin-top: 5px;
-      margin-bottom: 5px;
-
-      &:hover {
-        text-decoration: none;
-      }
-
-      &.blur-filter {
-        filter: blur(3px);
-        padding-left: 4px;
-      }
+      @include miniature-name;
     }
 
     .video-miniature-created-at-views {
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html
index a15df725e..a6757fc4a 100644
--- a/client/src/app/shared/video/video-thumbnail.component.html
+++ b/client/src/app/shared/video/video-thumbnail.component.html
@@ -4,9 +4,11 @@
 >
   <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
 
-  <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div>
+  <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
 
-  <div class="play-overlay"></div>
+  <div class="play-overlay">
+    <div class="icon"></div>
+  </div>
 
   <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
     <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss
index b9fd9182f..0113427a3 100644
--- a/client/src/app/shared/video/video-thumbnail.component.scss
+++ b/client/src/app/shared/video/video-thumbnail.component.scss
@@ -1,66 +1,9 @@
 @import '_variables';
 @import '_mixins';
-
-$play-overlay-transition: 0.2s ease;
-$play-overlay-height: 26px;
-$play-overlay-width: 18px;
+@import '_miniature';
 
 .video-thumbnail {
-  @include disable-outline;
-
-  display: inline-block;
-  position: relative;
-  border-radius: 3px;
-  overflow: hidden;
-  width: $video-thumbnail-width;
-  height: $video-thumbnail-height;
-  background-color: #ececec;
-  transition: filter $play-overlay-transition;
-
-  &:hover {
-    text-decoration: none !important;
-
-    filter: brightness(85%);
-
-    .play-overlay {
-      opacity: 1;
-
-      transform: translate(-50%, -50%) scale(1);
-    }
-  }
-
-  &.focus-visible {
-    box-shadow: 0 0 0 2px var(--mainColor);
-  }
-
-  img {
-    width: $video-thumbnail-width;
-    height: $video-thumbnail-height;
-
-    &.blur-filter {
-      filter: blur(5px);
-      transform : scale(1.03);
-    }
-  }
-
-  .play-overlay {
-    width: 0;
-    height: 0;
-
-    position: absolute;
-    left: 50%;
-    top: 50%;
-    transform: translate(-50%, -50%) scale(0.5);
-
-    transition: all $play-overlay-transition;
-
-    border-top: ($play-overlay-height / 2) solid transparent;
-    border-bottom: ($play-overlay-height / 2) solid transparent;
-
-    border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95);
-
-    opacity: 0;
-  }
+  @include miniature-thumbnail;
 
   .progress-bar {
     height: 3px;
@@ -75,16 +18,15 @@ $play-overlay-width: 18px;
     }
   }
 
-  .video-thumbnail-overlay {
+  .video-thumbnail-duration-overlay {
+    @include static-thumbnail-overlay;
+
     position: absolute;
     right: 5px;
     bottom: 5px;
-    display: inline-block;
-    background-color: rgba(0, 0, 0, 0.7);
-    color: #fff;
+    padding: 0 5px;
+    border-radius: 3px;
     font-size: 12px;
     font-weight: $font-bold;
-    border-radius: 3px;
-    padding: 0 5px;
   }
 }
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 460c09258..c936a8207 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -117,9 +117,8 @@ export class Video implements VideoServerModel {
     this.privacy.label = peertubeTranslate(this.privacy.label, translations)
 
     this.scheduledUpdate = hash.scheduledUpdate
-    this.originallyPublishedAt = hash.originallyPublishedAt ?
-    new Date(hash.originallyPublishedAt.toString())
-    : null
+    this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
+
     if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
 
     this.blacklisted = hash.blacklisted
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
index 1be1084ad..99695204d 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html
@@ -188,17 +188,17 @@
         <div class="row advanced-settings">
           <div class="col-md-12 col-xl-8">
             <div class="form-group">
-              <my-video-image
+              <my-image-upload
                 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
                 previewWidth="200px" previewHeight="110px"
-              ></my-video-image>
+              ></my-image-upload>
             </div>
 
             <div class="form-group">
-              <my-video-image
+              <my-image-upload
                 i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile"
                 previewWidth="360px" previewHeight="200px"
-              ></my-video-image>
+              ></my-image-upload>
             </div>
 
             <div class="form-group">
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
index f441d3fde..39b6daa93 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.module.ts
+++ b/client/src/app/videos/+video-edit/shared/video-edit.module.ts
@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'
 import { TagInputModule } from 'ngx-chips'
 import { SharedModule } from '../../../shared/'
 import { VideoEditComponent } from './video-edit.component'
-import { VideoImageComponent } from './video-image.component'
 import { CalendarModule } from 'primeng/components/calendar/calendar'
 import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
 
@@ -16,7 +15,6 @@ import { VideoCaptionAddModalComponent } from './video-caption-add-modal.compone
 
   declarations: [
     VideoEditComponent,
-    VideoImageComponent,
     VideoCaptionAddModalComponent
   ],
 
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.html b/client/src/app/videos/+video-edit/shared/video-image.component.html
deleted file mode 100644
index c09c862c4..000000000
--- a/client/src/app/videos/+video-edit/shared/video-image.component.html
+++ /dev/null
@@ -1,9 +0,0 @@
-<div class="root">
-  <my-reactive-file
-    [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
-    (fileChanged)="onFileChanged($event)"
-  ></my-reactive-file>
-
-  <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
-  <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
-</div>
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.scss b/client/src/app/videos/+video-edit/shared/video-image.component.scss
deleted file mode 100644
index b63963bca..000000000
--- a/client/src/app/videos/+video-edit/shared/video-image.component.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-.root {
-  height: auto;
-  display: flex;
-  align-items: center;
-
-  .preview {
-    border: 2px solid grey;
-    border-radius: 4px;
-    margin-left: 50px;
-
-    &.no-image {
-      background-color: #ececec;
-    }
-  }
-}
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.ts b/client/src/app/videos/+video-edit/shared/video-image.component.ts
deleted file mode 100644
index a604cde90..000000000
--- a/client/src/app/videos/+video-edit/shared/video-image.component.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Component, forwardRef, Input } from '@angular/core'
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
-import { ServerService } from '@app/core'
-
-@Component({
-  selector: 'my-video-image',
-  styleUrls: [ './video-image.component.scss' ],
-  templateUrl: './video-image.component.html',
-  providers: [
-    {
-      provide: NG_VALUE_ACCESSOR,
-      useExisting: forwardRef(() => VideoImageComponent),
-      multi: true
-    }
-  ]
-})
-export class VideoImageComponent implements ControlValueAccessor {
-  @Input() inputLabel: string
-  @Input() inputName: string
-  @Input() previewWidth: string
-  @Input() previewHeight: string
-
-  imageSrc: SafeResourceUrl
-
-  private file: File
-
-  constructor (
-    private sanitizer: DomSanitizer,
-    private serverService: ServerService
-  ) {}
-
-  get videoImageExtensions () {
-    return this.serverService.getConfig().video.image.extensions
-  }
-
-  get maxVideoImageSize () {
-    return this.serverService.getConfig().video.image.size.max
-  }
-
-  onFileChanged (file: File) {
-    this.file = file
-
-    this.propagateChange(this.file)
-    this.updatePreview()
-  }
-
-  propagateChange = (_: any) => { /* empty */ }
-
-  writeValue (file: any) {
-    this.file = file
-    this.updatePreview()
-  }
-
-  registerOnChange (fn: (_: any) => void) {
-    this.propagateChange = fn
-  }
-
-  registerOnTouched () {
-    // Unused
-  }
-
-  private updatePreview () {
-    if (this.file) {
-      const url = URL.createObjectURL(this.file)
-      this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
-    }
-  }
-}
diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss
index aff45c072..42b542233 100644
--- a/client/src/app/videos/video-list/video-overview.component.scss
+++ b/client/src/app/videos/video-list/video-overview.component.scss
@@ -1,5 +1,6 @@
 @import '_variables';
 @import '_mixins';
+@import '_miniature';
 
 .section {
   padding-top: 10px;
@@ -50,4 +51,4 @@
   .section {
     @include video-miniature-small-screen;
   }
-}
\ No newline at end of file
+}
diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss
new file mode 100644
index 000000000..36d4e84d3
--- /dev/null
+++ b/client/src/sass/include/_miniature.scss
@@ -0,0 +1,133 @@
+@import '_variables';
+@import '_mixins';
+
+@mixin miniature-name {
+  @include ellipsis-multiline(
+    $font-size: 1rem,
+    $line-height: 1,
+    $lines-to-show: 2
+  );
+  transition: color 0.2s;
+  font-size: 16px;
+  font-weight: $font-semibold;
+  color: var(--mainForegroundColor);
+  margin-top: 5px;
+  margin-bottom: 5px;
+
+  &:hover {
+    text-decoration: none;
+  }
+
+  &.blur-filter {
+    filter: blur(3px);
+    padding-left: 4px;
+  }
+}
+
+$play-overlay-transition: 0.2s ease;
+$play-overlay-height: 26px;
+$play-overlay-width: 18px;
+
+@mixin miniature-thumbnail {
+  @include disable-outline;
+
+  display: inline-block;
+  position: relative;
+  border-radius: 3px;
+  overflow: hidden;
+  width: $video-thumbnail-width;
+  height: $video-thumbnail-height;
+  background-color: #ececec;
+  transition: filter $play-overlay-transition;
+
+  .play-overlay {
+    position: absolute;
+    right: 0;
+    bottom: 0;
+
+    width: $video-thumbnail-width;
+    height: $video-thumbnail-height;
+    opacity: 0;
+    background-color: rgba(0, 0, 0, 0.7);
+
+    &, .icon {
+      transition: all $play-overlay-transition;
+    }
+
+    .icon {
+      width: 0;
+      height: 0;
+
+      position: absolute;
+      left: 50%;
+      top: 50%;
+      transform: translate(-50%, -50%) scale(0.5);
+
+      border-top: ($play-overlay-height / 2) solid transparent;
+      border-bottom: ($play-overlay-height / 2) solid transparent;
+
+      border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95);
+    }
+  }
+
+  &:hover {
+    text-decoration: none !important;
+
+    .play-overlay {
+      opacity: 1;
+
+      .icon {
+        transform: translate(-50%, -50%) scale(1);
+      }
+    }
+  }
+
+  &.focus-visible {
+    box-shadow: 0 0 0 2px var(--mainColor);
+  }
+
+  img {
+    width: $video-thumbnail-width;
+    height: $video-thumbnail-height;
+
+    &.blur-filter {
+      filter: blur(5px);
+      transform : scale(1.03);
+    }
+  }
+}
+
+@mixin static-thumbnail-overlay {
+  display: inline-block;
+  background-color: rgba(0, 0, 0, 0.7);
+  color: #fff;
+}
+
+@mixin video-miniature-small-screen {
+  text-align: center;
+
+  /deep/ .video-miniature {
+    padding-right: 0;
+    height: auto;
+    width: 100%;
+    margin-bottom: 20px;
+
+    .video-miniature-information {
+      width: 100% !important;
+
+      span {
+        width: 100%;
+      }
+    }
+
+    .video-thumbnail {
+      width: 100%;
+      height: auto;
+
+      img {
+        width: 100%;
+        height: auto;
+      }
+    }
+  }
+}
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index c2e200a14..59b2f42a5 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -516,31 +516,3 @@
   }
 }
 
-@mixin video-miniature-small-screen {
-  text-align: center;
-
-  /deep/ .video-miniature {
-    padding-right: 0;
-    height: auto;
-    width: 100%;
-    margin-bottom: 20px;
-
-    .video-miniature-information {
-      width: 100% !important;
-
-      span {
-        width: 100%;
-      }
-    }
-
-    .video-thumbnail {
-      width: 100%;
-      height: auto;
-
-      img {
-        width: 100%;
-        height: auto;
-      }
-    }
-  }
-}
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 3f9986f8a..a0fbc27c6 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -28,6 +28,7 @@
     "baseUrl": "src",
     "paths": {
       "@app/*": [ "app/*" ],
+      "@shared/*": [ "../../shared/*" ],
       "video.js": [ "../node_modules/video.js/dist/alt/video.core.js" ],
       "fs": [ "./shims/noop" ],
       "http": [ "./shims/http" ],
@@ -41,11 +42,14 @@
     "strictInjectionParameters": true,
     "fullTemplateTypeCheck": true
   },
+  "include": [
+    "../../shared"
+  ],
   "exclude": [
+    "../../node_modules",
     "../node_modules",
-    "node_modules",
-    "dist",
-    "../server",
-    "src/**/*.spec.ts"
+    "../dist",
+    "../../server",
+    "../src/**/*.spec.ts"
   ]
 }
-- 
cgit v1.2.3