Răsfoiți Sursa

Add video filters to common video pages

Chocobozzz 2 ani în urmă
părinte
comite
dd24f1bb0a
97 a modificat fișierele cu 2610 adăugiri și 1896 ștergeri
  1. 0 110
      client/src/app/+accounts/account-search/account-search.component.ts
  2. 1 1
      client/src/app/+accounts/account-video-channels/account-video-channels.component.html
  3. 20 0
      client/src/app/+accounts/account-videos/account-videos.component.html
  4. 33 60
      client/src/app/+accounts/account-videos/account-videos.component.ts
  5. 3 7
      client/src/app/+accounts/accounts-routing.module.ts
  6. 2 2
      client/src/app/+accounts/accounts.component.html
  7. 4 1
      client/src/app/+accounts/accounts.component.scss
  8. 9 13
      client/src/app/+accounts/accounts.component.ts
  9. 2 4
      client/src/app/+accounts/accounts.module.ts
  10. 1 1
      client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html
  11. 1 1
      client/src/app/+admin/plugins/plugin-search/plugin-search.component.html
  12. 2 2
      client/src/app/+my-library/my-history/my-history.component.html
  13. 4 2
      client/src/app/+my-library/my-history/my-history.component.ts
  14. 1 1
      client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html
  15. 1 1
      client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html
  16. 1 1
      client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html
  17. 2 2
      client/src/app/+my-library/my-videos/my-videos.component.html
  18. 4 2
      client/src/app/+my-library/my-videos/my-videos.component.ts
  19. 0 1
      client/src/app/+search/search-filters.component.scss
  20. 1 1
      client/src/app/+search/search.component.html
  21. 1 1
      client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html
  22. 21 0
      client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.html
  23. 35 66
      client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts
  24. 1 7
      client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
  25. 1 1
      client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html
  26. 1 3
      client/src/app/+videos/video-list/index.ts
  27. 1 1
      client/src/app/+videos/video-list/overview/video-overview.component.html
  28. 0 2
      client/src/app/+videos/video-list/trending/index.ts
  29. 0 8
      client/src/app/+videos/video-list/trending/video-trending-header.component.html
  30. 0 20
      client/src/app/+videos/video-list/trending/video-trending-header.component.scss
  31. 0 109
      client/src/app/+videos/video-list/trending/video-trending-header.component.ts
  32. 0 127
      client/src/app/+videos/video-list/trending/video-trending.component.ts
  33. 0 81
      client/src/app/+videos/video-list/video-local.component.ts
  34. 0 73
      client/src/app/+videos/video-list/video-recently-added.component.ts
  35. 17 0
      client/src/app/+videos/video-list/video-user-subscriptions.component.html
  36. 56 77
      client/src/app/+videos/video-list/video-user-subscriptions.component.ts
  37. 22 0
      client/src/app/+videos/video-list/videos-list-common-page.component.html
  38. 219 0
      client/src/app/+videos/video-list/videos-list-common-page.component.ts
  39. 21 33
      client/src/app/+videos/videos-routing.module.ts
  40. 3 9
      client/src/app/+videos/videos.module.ts
  41. 1 0
      client/src/app/app-routing.module.ts
  42. 18 62
      client/src/app/app.component.ts
  43. 13 1
      client/src/app/core/core.module.ts
  44. 5 1
      client/src/app/core/routing/custom-reuse-strategy.ts
  45. 2 0
      client/src/app/core/routing/index.ts
  46. 78 0
      client/src/app/core/routing/peertube-router.service.ts
  47. 91 0
      client/src/app/core/routing/scroll.service.ts
  48. 0 226
      client/src/app/helpers/utils.ts
  49. 34 0
      client/src/app/helpers/utils/channel.ts
  50. 25 0
      client/src/app/helpers/utils/date.ts
  51. 18 0
      client/src/app/helpers/utils/html.ts
  52. 7 0
      client/src/app/helpers/utils/index.ts
  53. 47 0
      client/src/app/helpers/utils/object.ts
  54. 33 0
      client/src/app/helpers/utils/ui.ts
  55. 37 0
      client/src/app/helpers/utils/upload.ts
  56. 71 0
      client/src/app/helpers/utils/url.ts
  57. 2 1
      client/src/app/shared/shared-forms/advanced-input-filter.component.ts
  58. 3 0
      client/src/app/shared/shared-forms/select/index.ts
  59. 8 0
      client/src/app/shared/shared-forms/select/select-categories.component.html
  60. 71 0
      client/src/app/shared/shared-forms/select/select-categories.component.ts
  61. 115 0
      client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts
  62. 0 2
      client/src/app/shared/shared-forms/select/select-checkbox.component.html
  63. 2 5
      client/src/app/shared/shared-forms/select/select-checkbox.component.ts
  64. 9 0
      client/src/app/shared/shared-forms/select/select-languages.component.html
  65. 74 0
      client/src/app/shared/shared-forms/select/select-languages.component.ts
  66. 9 0
      client/src/app/shared/shared-forms/shared-form.module.ts
  67. 1 0
      client/src/app/shared/shared-icons/global-icon.component.ts
  68. 18 4
      client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts
  69. 1 2
      client/src/app/shared/shared-main/feeds/feed.component.scss
  70. 14 9
      client/src/app/shared/shared-main/misc/simple-search-input.component.html
  71. 5 8
      client/src/app/shared/shared-main/misc/simple-search-input.component.scss
  72. 10 22
      client/src/app/shared/shared-main/misc/simple-search-input.component.ts
  73. 1 1
      client/src/app/shared/shared-main/users/user-notifications.component.html
  74. 40 70
      client/src/app/shared/shared-main/video/video.service.ts
  75. 6 14
      client/src/app/shared/shared-search/advanced-search.model.ts
  76. 1 6
      client/src/app/shared/shared-user-settings/user-video-settings.component.html
  77. 1 1
      client/src/app/shared/shared-user-settings/user-video-settings.component.scss
  78. 22 71
      client/src/app/shared/shared-user-settings/user-video-settings.component.ts
  79. 0 404
      client/src/app/shared/shared-video-miniature/abstract-video-list.ts
  80. 3 2
      client/src/app/shared/shared-video-miniature/index.ts
  81. 9 5
      client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts
  82. 0 1
      client/src/app/shared/shared-video-miniature/video-download.component.scss
  83. 131 0
      client/src/app/shared/shared-video-miniature/video-filters-header.component.html
  84. 139 0
      client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
  85. 119 0
      client/src/app/shared/shared-video-miniature/video-filters-header.component.ts
  86. 240 0
      client/src/app/shared/shared-video-miniature/video-filters.model.ts
  87. 0 5
      client/src/app/shared/shared-video-miniature/video-list-header.component.html
  88. 0 22
      client/src/app/shared/shared-video-miniature/video-list-header.component.ts
  89. 19 22
      client/src/app/shared/shared-video-miniature/videos-list.component.html
  90. 48 23
      client/src/app/shared/shared-video-miniature/videos-list.component.scss
  91. 396 0
      client/src/app/shared/shared-video-miniature/videos-list.component.ts
  92. 4 1
      client/src/app/shared/shared-video-miniature/videos-selection.component.html
  93. 64 42
      client/src/app/shared/shared-video-miniature/videos-selection.component.ts
  94. 4 0
      client/src/assets/images/feather/chevrons-up.svg
  95. 1 0
      client/src/sass/bootstrap.scss
  96. 4 0
      client/src/sass/classes.scss
  97. 45 32
      client/src/sass/include/_mixins.scss

+ 0 - 110
client/src/app/+accounts/account-search/account-search.component.ts

@@ -1,110 +0,0 @@
-import { forkJoin, Subscription } from 'rxjs'
-import { first, tap } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
-import { Account, AccountService, VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { VideoFilter } from '@shared/models'
-
-@Component({
-  selector: 'my-account-search',
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
-  styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ]
-})
-export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  titlePage: string
-  loadOnInit = false
-  loadUserVideoPreferences = true
-
-  search = ''
-  filter: VideoFilter = null
-
-  private account: Account
-  private accountSub: Subscription
-
-  constructor (
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected notifier: Notifier,
-    protected confirmService: ConfirmService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    protected cfr: ComponentFactoryResolver,
-    private accountService: AccountService,
-    private videoService: VideoService
-  ) {
-    super()
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    this.enableAllFilterIfPossible()
-
-    // Parent get the account for us
-    this.accountSub = forkJoin([
-      this.accountService.accountLoaded.pipe(first()),
-      this.onUserLoadedSubject.pipe(first())
-    ]).subscribe(([ account ]) => {
-      this.account = account
-
-      this.reloadVideos()
-    })
-  }
-
-  ngOnDestroy () {
-    if (this.accountSub) this.accountSub.unsubscribe()
-
-    super.ngOnDestroy()
-  }
-
-  updateSearch (value: string) {
-    this.search = value
-
-    if (!this.search) {
-      this.router.navigate([ '../videos' ], { relativeTo: this.route })
-      return
-    }
-
-    this.videos = []
-    this.reloadVideos()
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    const options = {
-      account: this.account,
-      videoPagination: newPagination,
-      sort: this.sort,
-      nsfwPolicy: this.nsfwPolicy,
-      videoFilter: this.filter,
-      search: this.search
-    }
-
-    return this.videoService
-               .getAccountVideos(options)
-               .pipe(
-                 tap(({ total }) => {
-                   this.titlePage = this.search
-                     ? $localize`Published ${total} videos matching "${this.search}"`
-                     : $localize`Published ${total} videos`
-                 })
-               )
-  }
-
-  toggleModerationDisplay () {
-    this.filter = this.buildLocalFilter(this.filter, null)
-
-    this.reloadVideos()
-  }
-
-  generateSyndicationList () {
-    /* method disabled */
-    throw new Error('Method not implemented.')
-  }
-}

+ 1 - 1
client/src/app/+accounts/account-video-channels/account-video-channels.component.html

@@ -4,7 +4,7 @@
 
   <div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div>
 
-  <div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onChannelDataSubject.asObservable()">
+  <div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onChannelDataSubject.asObservable()">
     <div class="channel" *ngFor="let videoChannel of videoChannels">
 
       <div class="channel-avatar-row">

+ 20 - 0
client/src/app/+accounts/account-videos/account-videos.component.html

@@ -0,0 +1,20 @@
+<my-videos-list
+  *ngIf="account"
+
+  [title]="title"
+  [displayTitle]="false"
+
+  [getVideosObservableFunction]="getVideosObservableFunction"
+  [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+
+  [defaultSort]="defaultSort"
+
+  [displayFilters]="true"
+  [displayModerationBlock]="true"
+  [displayAsRow]="displayAsRow()"
+
+  [loadUserVideoPreferences]="true"
+
+  [disabled]="disabled"
+>
+</my-videos-list>

+ 33 - 60
client/src/app/+accounts/account-videos/account-videos.component.ts

@@ -1,96 +1,69 @@
-import { forkJoin, Subscription } from 'rxjs'
+import { Subscription } from 'rxjs'
 import { first } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
 import { Account, AccountService, VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { VideoFilter } from '@shared/models'
+import { VideoFilters } from '@app/shared/shared-video-miniature'
+import { VideoSortField } from '@shared/models'
 
 @Component({
   selector: 'my-account-videos',
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
-  styleUrls: [
-    '../../shared/shared-video-miniature/abstract-video-list.scss'
-  ]
+  templateUrl: './account-videos.component.html'
 })
-export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  // No value because we don't want a page title
-  titlePage: string
-  loadOnInit = false
-  loadUserVideoPreferences = true
+export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
+  getVideosObservableFunction = this.getVideosObservable.bind(this)
+  getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
 
-  filter: VideoFilter = null
+  title = $localize`Videos`
+  defaultSort = '-publishedAt' as VideoSortField
+
+  account: Account
+  disabled = false
 
-  private account: Account
   private accountSub: Subscription
 
   constructor (
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected notifier: Notifier,
-    protected confirmService: ConfirmService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
+    private screenService: ScreenService,
     private accountService: AccountService,
-    private videoService: VideoService,
-    protected cfr: ComponentFactoryResolver
+    private videoService: VideoService
   ) {
-    super()
   }
 
   ngOnInit () {
-    super.ngOnInit()
-
-    this.enableAllFilterIfPossible()
-
     // Parent get the account for us
-    this.accountSub = forkJoin([
-      this.accountService.accountLoaded.pipe(first()),
-      this.onUserLoadedSubject.pipe(first())
-    ]).subscribe(([ account ]) => {
-      this.account = account
-
-      this.reloadVideos()
-      this.generateSyndicationList()
-    })
+    this.accountService.accountLoaded.pipe(first())
+      .subscribe(account => this.account = account)
   }
 
   ngOnDestroy () {
     if (this.accountSub) this.accountSub.unsubscribe()
-
-    super.ngOnDestroy()
   }
 
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+  getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
     const options = {
+      ...filters.toVideosAPIObject(),
+
+      videoPagination: pagination,
       account: this.account,
-      videoPagination: newPagination,
-      sort: this.sort,
-      nsfwPolicy: this.nsfwPolicy,
-      videoFilter: this.filter
+      skipCount: true
     }
 
-    return this.videoService
-               .getAccountVideos(options)
+    return this.videoService.getAccountVideos(options)
   }
 
-  toggleModerationDisplay () {
-    this.filter = this.buildLocalFilter(this.filter, null)
+  getSyndicationItems () {
+    return this.videoService.getAccountFeedUrls(this.account.id)
+  }
 
-    this.reloadVideos()
+  displayAsRow () {
+    return this.screenService.isInMobileView()
   }
 
-  generateSyndicationList () {
-    this.syndicationItems = this.videoService.getAccountFeedUrls(this.account.id)
+  disableForReuse () {
+    this.disabled = true
   }
 
-  displayAsRow () {
-    return this.screenService.isInMobileView()
+  enabledForReuse () {
+    this.disabled = false
   }
 }

+ 3 - 7
client/src/app/+accounts/accounts-routing.module.ts

@@ -1,6 +1,5 @@
 import { NgModule } from '@angular/core'
 import { RouterModule, Routes } from '@angular/router'
-import { AccountSearchComponent } from './account-search/account-search.component'
 import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
 import { AccountVideosComponent } from './account-videos/account-videos.component'
 import { AccountsComponent } from './accounts.component'
@@ -41,14 +40,11 @@ const accountsRoutes: Routes = [
           }
         }
       },
+
+      // Old URL redirection
       {
         path: 'search',
-        component: AccountSearchComponent,
-        data: {
-          meta: {
-            title: $localize`Search videos within account`
-          }
-        }
+        redirectTo: 'videos'
       }
     ]
   }

+ 2 - 2
client/src/app/+accounts/accounts.component.html

@@ -66,7 +66,7 @@
     </div>
   </div>
 
-  <div class="links">
+  <div class="links" [ngClass]="{ 'on-channel-page': isOnChannelPage() }">
     <ng-template #linkTemplate let-item="item">
       <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
     </ng-template>
@@ -81,7 +81,7 @@
     ></my-simple-search-input>
   </div>
 
-  <router-outlet (activate)="onOutletLoaded($event)"></router-outlet>
+  <router-outlet></router-outlet>
 </div>
 
 <ng-container *ngIf="prependModerationActions">

+ 4 - 1
client/src/app/+accounts/accounts.component.scss

@@ -20,7 +20,10 @@
   display: flex;
   justify-content: space-between;
   align-items: center;
-  max-width: $max-channels-width;
+
+  &.on-channel-page {
+    max-width: $max-channels-width;
+  }
 
   simple-search-input {
     @include margin-left(auto);

+ 9 - 13
client/src/app/+accounts/accounts.component.ts

@@ -1,7 +1,7 @@
 import { Subscription } from 'rxjs'
 import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
 import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
-import { ActivatedRoute } from '@angular/router'
+import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
 import {
   Account,
@@ -14,7 +14,6 @@ import {
 } from '@app/shared/shared-main'
 import { AccountReportComponent } from '@app/shared/shared-moderation'
 import { HttpStatusCode, User, UserRight } from '@shared/models'
-import { AccountSearchComponent } from './account-search/account-search.component'
 
 @Component({
   templateUrl: './accounts.component.html',
@@ -23,8 +22,6 @@ import { AccountSearchComponent } from './account-search/account-search.componen
 export class AccountsComponent implements OnInit, OnDestroy {
   @ViewChild('accountReportModal') accountReportModal: AccountReportComponent
 
-  accountSearch: AccountSearchComponent
-
   account: Account
   accountUser: User
 
@@ -45,6 +42,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
 
   constructor (
     private route: ActivatedRoute,
+    private router: Router,
     private userService: UserService,
     private accountService: AccountService,
     private videoChannelService: VideoChannelService,
@@ -128,16 +126,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
     return $localize`${count} subscribers`
   }
 
-  onOutletLoaded (component: Component) {
-    if (component instanceof AccountSearchComponent) {
-      this.accountSearch = component
-    } else {
-      this.accountSearch = undefined
-    }
-  }
-
   searchChanged (search: string) {
-    if (this.accountSearch) this.accountSearch.updateSearch(search)
+    const queryParams = { search }
+
+    this.router.navigate([ './videos' ], { queryParams, relativeTo: this.route, queryParamsHandling: 'merge' })
   }
 
   onSearchInputDisplayChanged (displayed: boolean) {
@@ -152,6 +144,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
     return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100
   }
 
+  isOnChannelPage () {
+    return this.route.children[0].snapshot.url[0].path === 'video-channels'
+  }
+
   private async onAccount (account: Account) {
     this.accountFollowerTitle = $localize`${account.followersCount} direct account followers`
 

+ 2 - 4
client/src/app/+accounts/accounts.module.ts

@@ -5,12 +5,11 @@ 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 { AccountSearchComponent } from './account-search/account-search.component'
+import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
 import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
 import { AccountVideosComponent } from './account-videos/account-videos.component'
 import { AccountsRoutingModule } from './accounts-routing.module'
 import { AccountsComponent } from './accounts.component'
-import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
 
 @NgModule({
   imports: [
@@ -28,8 +27,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto
   declarations: [
     AccountsComponent,
     AccountVideosComponent,
-    AccountVideoChannelsComponent,
-    AccountSearchComponent
+    AccountVideoChannelsComponent
   ],
 
   exports: [

+ 1 - 1
client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html

@@ -6,7 +6,7 @@
   {{ getNoResultMessage() }}
 </div>
 
-<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()">
+<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div class="card plugin" *ngFor="let plugin of plugins">
     <div class="card-body">
       <div class="first-row">

+ 1 - 1
client/src/app/+admin/plugins/plugin-search/plugin-search.component.html

@@ -29,7 +29,7 @@
   No results.
 </div>
 
-<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()">
+<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div class="card plugin" *ngFor="let plugin of plugins">
     <div class="card-body">
       <div class="first-row">

+ 2 - 2
client/src/app/+my-library/my-history/my-history.component.html

@@ -5,7 +5,7 @@
 
 <div class="top-buttons">
   <div class="search-wrapper">
-    <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
+    <my-advanced-input-filter [emitOnInit]="false" (search)="onSearch($event)"></my-advanced-input-filter>
   </div>
 
   <div class="history-switch">
@@ -26,8 +26,8 @@
   [titlePage]="titlePage"
   [getVideosObservableFunction]="getVideosObservableFunction"
   [user]="user"
-  [loadOnInit]="false"
   i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet."
   [enableSelection]="false"
+  [disabled]="disabled"
   #videosSelection
 ></my-videos-selection>

+ 4 - 2
client/src/app/+my-library/my-history/my-history.component.ts

@@ -50,6 +50,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
   videos: Video[] = []
   search: string
 
+  disabled = false
+
   constructor (
     protected router: Router,
     protected serverService: ServerService,
@@ -74,11 +76,11 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
   }
 
   disableForReuse () {
-    this.videosSelection.disableForReuse()
+    this.disabled = true
   }
 
   enabledForReuse () {
-    this.videosSelection.enabledForReuse()
+    this.disabled = false
   }
 
   reloadData () {

+ 1 - 1
client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html

@@ -12,7 +12,7 @@
 
 <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div>
 
-<div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
+<div class="video-channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div *ngFor="let videoChannel of videoChannels" class="video-channel">
     <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
 

+ 1 - 1
client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html

@@ -34,7 +34,7 @@
     </div>
 
     <div
-      class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
+      class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()"
       cdkDropList (cdkDropListDropped)="drop($event)" [dataObservable]="onDataSubject.asObservable()"
     >
       <div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag [cdkDragStartDelay]="getDragStartDelay()">

+ 1 - 1
client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html

@@ -12,7 +12,7 @@
   </a>
 </div>
 
-<div class="video-playlists" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
+<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
     <my-video-playlist-miniature
       [playlist]="playlist" [toManage]="true" [displayChannel]="true"

+ 2 - 2
client/src/app/+my-library/my-videos/my-videos.component.html

@@ -19,7 +19,7 @@
 </h1>
 
 <div class="videos-header d-flex justify-content-between">
-  <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
+  <my-advanced-input-filter [emitOnInit]="false" [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
 
   <div class="peertube-select-container peertube-select-button">
     <select [(ngModel)]="sort" (ngModelChange)="onChangeSortColumn()" class="form-control">
@@ -41,7 +41,7 @@
   [titlePage]="titlePage"
   [getVideosObservableFunction]="getVideosObservableFunction"
   [user]="user"
-  [loadOnInit]="false"
+  [disabled]="disabled"
   #videosSelection
 >
   <ng-template ptTemplate="globalButtons">

+ 4 - 2
client/src/app/+my-library/my-videos/my-videos.component.ts

@@ -54,6 +54,8 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
     }
   ]
 
+  disabled = false
+
   private search: string
 
   constructor (
@@ -89,11 +91,11 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
   }
 
   disableForReuse () {
-    this.videosSelection.disableForReuse()
+    this.disabled = true
   }
 
   enabledForReuse () {
-    this.videosSelection.enabledForReuse()
+    this.disabled = false
   }
 
   getVideosObservable (page: number) {

+ 0 - 1
client/src/app/+search/search-filters.component.scss

@@ -11,7 +11,6 @@ form {
 }
 
 .peertube-radio-container {
-  @include peertube-radio-container;
   @include margin-right(30px);
 
   display: inline-block;

+ 1 - 1
client/src/app/+search/search.component.html

@@ -1,4 +1,4 @@
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
+<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" class="search-result">
   <div class="results-header">
     <div class="first-line">
       <div class="results-counter" *ngIf="pagination.totalItems">

+ 1 - 1
client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html

@@ -5,7 +5,7 @@
 
   <div i18n class="no-results" *ngIf="pagination.totalItems === 0">This channel does not have playlists.</div>
 
-  <div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()">
+  <div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
     <div *ngFor="let playlist of videoPlaylists" class="playlist-wrapper">
       <my-video-playlist-miniature [playlist]="playlist" [toManage]="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature>
     </div>

+ 21 - 0
client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.html

@@ -0,0 +1,21 @@
+<my-videos-list
+  *ngIf="videoChannel"
+
+  [title]="title"
+  [displayTitle]="false"
+
+  [getVideosObservableFunction]="getVideosObservableFunction"
+  [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+
+  [defaultSort]="defaultSort"
+
+  [displayFilters]="true"
+  [displayModerationBlock]="true"
+  [displayOptions]="displayOptions"
+  [displayAsRow]="displayAsRow()"
+
+  [loadUserVideoPreferences]="true"
+
+  [disabled]="disabled"
+>
+</my-videos-list>

+ 35 - 66
client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts

@@ -1,27 +1,21 @@
-import { forkJoin, Subscription } from 'rxjs'
+import { Subscription } from 'rxjs'
 import { first } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
 import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList, MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
-import { VideoFilter } from '@shared/models'
+import { MiniatureDisplayOptions, VideoFilters } from '@app/shared/shared-video-miniature'
+import { VideoSortField } from '@shared/models/videos'
 
 @Component({
   selector: 'my-video-channel-videos',
-  templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
-  styleUrls: [
-    '../../shared/shared-video-miniature/abstract-video-list.scss'
-  ]
+  templateUrl: './video-channel-videos.component.html'
 })
-export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  // No value because we don't want a page title
-  titlePage: string
-  loadOnInit = false
-  loadUserVideoPreferences = true
+export class VideoChannelVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
+  getVideosObservableFunction = this.getVideosObservable.bind(this)
+  getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
 
-  filter: VideoFilter = null
+  title = $localize`Videos`
+  defaultSort = '-publishedAt' as VideoSortField
 
   displayOptions: MiniatureDisplayOptions = {
     date: true,
@@ -34,80 +28,55 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
     blacklistInfo: false
   }
 
-  private videoChannel: VideoChannel
+  videoChannel: VideoChannel
+  disabled = false
+
   private videoChannelSub: Subscription
 
   constructor (
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected notifier: Notifier,
-    protected confirmService: ConfirmService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    protected cfr: ComponentFactoryResolver,
+    private screenService: ScreenService,
     private videoChannelService: VideoChannelService,
     private videoService: VideoService
   ) {
-    super()
-
-    this.titlePage = $localize`Published videos`
-    this.displayOptions = {
-      ...this.displayOptions,
-      avatar: false
-    }
   }
 
   ngOnInit () {
-    super.ngOnInit()
-
-    this.enableAllFilterIfPossible()
-
     // Parent get the video channel for us
-    this.videoChannelSub = forkJoin([
-      this.videoChannelService.videoChannelLoaded.pipe(first()),
-      this.onUserLoadedSubject.pipe(first())
-    ]).subscribe(([ videoChannel ]) => {
-      this.videoChannel = videoChannel
-
-      this.reloadVideos()
-      this.generateSyndicationList()
-    })
+    this.videoChannelService.videoChannelLoaded.pipe(first())
+      .subscribe(videoChannel => {
+        this.videoChannel = videoChannel
+      })
   }
 
   ngOnDestroy () {
     if (this.videoChannelSub) this.videoChannelSub.unsubscribe()
-
-    super.ngOnDestroy()
   }
 
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
-    const options = {
+  getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
+    const params = {
+      ...filters.toVideosAPIObject(),
+
+      videoPagination: pagination,
       videoChannel: this.videoChannel,
-      videoPagination: newPagination,
-      sort: this.sort,
-      nsfwPolicy: this.nsfwPolicy,
-      videoFilter: this.filter
+      skipCount: true
     }
 
-    return this.videoService
-               .getVideoChannelVideos(options)
+    return this.videoService.getVideoChannelVideos(params)
   }
 
-  generateSyndicationList () {
-    this.syndicationItems = this.videoService.getVideoChannelFeedUrls(this.videoChannel.id)
+  getSyndicationItems () {
+    return this.videoService.getVideoChannelFeedUrls(this.videoChannel.id)
   }
 
-  toggleModerationDisplay () {
-    this.filter = this.buildLocalFilter(this.filter, null)
+  displayAsRow () {
+    return this.screenService.isInMobileView()
+  }
 
-    this.reloadVideos()
+  disableForReuse () {
+    this.disabled = true
   }
 
-  displayAsRow () {
-    return this.screenService.isInMobileView()
+  enabledForReuse () {
+    this.disabled = false
   }
 }

+ 1 - 7
client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html

@@ -27,13 +27,7 @@
 
     <div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div>
 
-    <div
-      class="comment-threads"
-      myInfiniteScroller
-      [autoInit]="true"
-      (nearOfBottom)="onNearOfBottom()"
-      [dataObservable]="onDataSubject.asObservable()"
-    >
+    <div class="comment-threads" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
       <div>
         <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
         <my-video-comment

+ 1 - 1
client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html

@@ -1,6 +1,6 @@
 <div
   *ngIf="playlist && currentPlaylistPosition" class="playlist"
-  myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
+  myInfiniteScroller [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
 >
   <div class="playlist-info">
     <div class="playlist-display-name">

+ 1 - 3
client/src/app/+videos/video-list/index.ts

@@ -1,4 +1,2 @@
 export * from './overview'
-export * from './trending'
-export * from './video-local.component'
-export * from './video-recently-added.component'
+export * from './videos-list-common-page.component'

+ 1 - 1
client/src/app/+videos/video-list/overview/video-overview.component.html

@@ -4,7 +4,7 @@
   <div class="no-results" i18n *ngIf="notResults">No results.</div>
 
   <div
-    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
+    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"
   >
     <ng-container *ngFor="let overview of overviews">
 

+ 0 - 2
client/src/app/+videos/video-list/trending/index.ts

@@ -1,2 +0,0 @@
-export * from './video-trending-header.component'
-export * from './video-trending.component'

+ 0 - 8
client/src/app/+videos/video-list/trending/video-trending-header.component.html

@@ -1,8 +0,0 @@
-<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" [(ngModel)]="data.model" (ngModelChange)="setSort()">
-  <ng-container *ngFor="let button of buttons">
-    <label *ngIf="!button.hidden" ngbButtonLabel class="btn-light" placement="bottom right-bottom left-bottom" [ngbTooltip]="button.tooltip" container="body">
-      <my-global-icon [iconName]="button.iconName"></my-global-icon>
-      <input ngbButton type="radio" [value]="button.value"> {{ button.label }}
-    </label>
-  </ng-container>
-</div>

+ 0 - 20
client/src/app/+videos/video-list/trending/video-trending-header.component.scss

@@ -1,20 +0,0 @@
-@use '_mixins' as *;
-
-.btn-group label {
-  border: 1px solid transparent;
-  border-radius: 9999px !important;
-  padding: 5px 16px;
-  opacity: .8;
-
-  &:not(:first-child) {
-    @include margin-left(.5rem);
-  }
-
-  my-global-icon {
-    @include margin-right(.1rem);
-
-    position: relative;
-    top: -2px;
-    height: 1rem;
-  }
-}

+ 0 - 109
client/src/app/+videos/video-list/trending/video-trending-header.component.ts

@@ -1,109 +0,0 @@
-import { Subscription } from 'rxjs'
-import { Component, HostBinding, Inject, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, RedirectService } from '@app/core'
-import { ServerService } from '@app/core/server/server.service'
-import { GlobalIconName } from '@app/shared/shared-icons'
-import { VideoListHeaderComponent } from '@app/shared/shared-video-miniature'
-
-interface VideoTrendingHeaderItem {
-  label: string
-  iconName: GlobalIconName
-  value: string
-  tooltip?: string
-  hidden?: boolean
-}
-
-@Component({
-  selector: 'my-video-trending-title-page',
-  styleUrls: [ './video-trending-header.component.scss' ],
-  templateUrl: './video-trending-header.component.html'
-})
-export class VideoTrendingHeaderComponent extends VideoListHeaderComponent implements OnInit, OnDestroy {
-  @HostBinding('class') class = 'title-page title-page-single'
-
-  buttons: VideoTrendingHeaderItem[]
-
-  private algorithmChangeSub: Subscription
-
-  constructor (
-    @Inject('data') public data: any,
-    private route: ActivatedRoute,
-    private router: Router,
-    private auth: AuthService,
-    private serverService: ServerService,
-    private redirectService: RedirectService
-  ) {
-    super(data)
-
-    this.buttons = [
-      {
-        label: $localize`:A variant of Trending videos based on the number of recent interactions, minus user history:Best`,
-        iconName: 'award',
-        value: 'best',
-        tooltip: $localize`Videos with the most interactions for recent videos, minus user history`,
-        hidden: true
-      },
-      {
-        label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`,
-        iconName: 'flame',
-        value: 'hot',
-        tooltip: $localize`Videos with the most interactions for recent videos`,
-        hidden: true
-      },
-      {
-        label: $localize`:Main variant of Trending videos based on number of recent views:Views`,
-        iconName: 'trending',
-        value: 'most-viewed',
-        tooltip: $localize`Videos with the most views during the last 24 hours`
-      },
-      {
-        label: $localize`:A variant of Trending videos based on the number of likes:Likes`,
-        iconName: 'like',
-        value: 'most-liked',
-        tooltip: $localize`Videos that have the most likes`
-      }
-    ]
-  }
-
-  ngOnInit () {
-    const serverConfig = this.serverService.getHTMLConfig()
-    const algEnabled = serverConfig.trending.videos.algorithms.enabled
-
-    this.buttons = this.buttons.map(b => {
-      b.hidden = !algEnabled.includes(b.value)
-
-      // Best is adapted by the user history so
-      if (b.value === 'best' && !this.auth.isLoggedIn()) {
-        b.hidden = true
-      }
-
-      return b
-    })
-
-    this.algorithmChangeSub = this.route.queryParams.subscribe(
-      queryParams => {
-        this.data.model = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
-      }
-    )
-  }
-
-  ngOnDestroy () {
-    if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe()
-  }
-
-  setSort () {
-    const alg = this.data.model !== this.redirectService.getDefaultTrendingAlgorithm()
-      ? this.data.model
-      : undefined
-
-    this.router.navigate(
-      [],
-      {
-        relativeTo: this.route,
-        queryParams: { alg },
-        queryParamsHandling: 'merge'
-      }
-    )
-  }
-}

+ 0 - 127
client/src/app/+videos/video-list/trending/video-trending.component.ts

@@ -1,127 +0,0 @@
-import { Subscription } from 'rxjs'
-import { first, switchMap } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, Injector, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Params, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, RedirectService, 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 { VideoSortField } from '@shared/models'
-import { VideoTrendingHeaderComponent } from './video-trending-header.component'
-
-@Component({
-  selector: 'my-videos-hot',
-  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 {
-  HeaderComponent = VideoTrendingHeaderComponent
-  titlePage: string
-  defaultSort: VideoSortField = '-trending'
-
-  loadUserVideoPreferences = true
-
-  private algorithmChangeSub: Subscription
-
-  constructor (
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    protected cfr: ComponentFactoryResolver,
-    private videoService: VideoService,
-    private redirectService: RedirectService,
-    private hooks: HooksService
-  ) {
-    super()
-
-    this.defaultSort = this.parseAlgorithm(this.redirectService.getDefaultTrendingAlgorithm())
-
-    this.headerComponentInjector = this.getInjector()
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    this.generateSyndicationList()
-
-    // Subscribe to alg change after we loaded the data
-    // The initial alg load is handled by the parent class
-    this.algorithmChangeSub = this.onDataSubject
-      .pipe(
-        first(),
-        switchMap(() => this.route.queryParams)
-      ).subscribe(queryParams => {
-        const oldSort = this.sort
-
-        this.loadPageRouteParams(queryParams)
-
-        if (oldSort !== this.sort) this.reloadVideos()
-      }
-      )
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-    if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe()
-  }
-
-  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)
-  }
-
-  getInjector () {
-    return Injector.create({
-      providers: [ {
-        provide: 'data',
-        useValue: {
-          model: this.defaultSort
-        }
-      } ]
-    })
-  }
-
-  protected loadPageRouteParams (queryParams: Params) {
-    const algorithm = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
-
-    this.sort = this.parseAlgorithm(algorithm)
-  }
-
-  private parseAlgorithm (algorithm: string): VideoSortField {
-    switch (algorithm) {
-      case 'most-viewed':
-        return '-trending'
-
-      case 'most-liked':
-        return '-likes'
-
-      default:
-        return '-' + algorithm as VideoSortField
-    }
-  }
-}

+ 0 - 81
client/src/app/+videos/video-list/video-local.component.ts

@@ -1,81 +0,0 @@
-import { Component, ComponentFactoryResolver, 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 { 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'
-
-  loadUserVideoPreferences = true
-
-  constructor (
-    protected router: Router,
-    protected serverService: ServerService,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    protected cfr: ComponentFactoryResolver,
-    private videoService: VideoService,
-    private hooks: HooksService
-  ) {
-    super()
-
-    this.titlePage = $localize`Local videos`
-  }
-
-  ngOnInit () {
-    super.ngOnInit()
-
-    this.enableAllFilterIfPossible()
-    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.buildLocalFilter(this.filter, 'local')
-
-    this.reloadVideos()
-  }
-}

+ 0 - 73
client/src/app/+videos/video-list/video-recently-added.component.ts

@@ -1,73 +0,0 @@
-import { Component, ComponentFactoryResolver, 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 { 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
-
-  loadUserVideoPreferences = true
-
-  constructor (
-    protected route: ActivatedRoute,
-    protected serverService: ServerService,
-    protected router: Router,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    protected cfr: ComponentFactoryResolver,
-    private videoService: VideoService,
-    private hooks: HooksService
-  ) {
-    super()
-
-    this.titlePage = $localize`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)
-  }
-}

+ 17 - 0
client/src/app/+videos/video-list/video-user-subscriptions.component.html

@@ -0,0 +1,17 @@
+<my-videos-list
+  [getVideosObservableFunction]="getVideosObservableFunction"
+  [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+
+  [title]="titlePage"
+
+  [defaultSort]="defaultSort"
+
+  [displayFilters]="false"
+  [displayModerationBlock]="false"
+
+  [loadUserVideoPreferences]="false"
+  [groupByDate]="true"
+
+  [disabled]="disabled"
+>
+</my-videos-list>

+ 56 - 77
client/src/app/+videos/video-list/video-user-subscriptions.component.ts

@@ -1,94 +1,53 @@
 
-import { switchMap } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScopedTokensService, ScreenService, ServerService, UserService } from '@app/core'
+import { firstValueFrom } from 'rxjs'
+import { switchMap, tap } from 'rxjs/operators'
+import { Component } from '@angular/core'
+import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } 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 } from '@app/shared/shared-video-miniature'
-import { FeedFormat, VideoSortField } from '@shared/models'
-import { environment } from '../../../environments/environment'
-import { copyToClipboard } from '../../../root-helpers/utils'
+import { VideoFilters } from '@app/shared/shared-video-miniature'
+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'
+  templateUrl: './video-user-subscriptions.component.html'
 })
-export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
-  titlePage: string
-  sort = '-publishedAt' as VideoSortField
-  groupByDate = true
+export class VideoUserSubscriptionsComponent implements DisableForReuseHook {
+  getVideosObservableFunction = this.getVideosObservable.bind(this)
+  getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
+
+  defaultSort = '-publishedAt' as VideoSortField
+
+  actions = [
+    {
+      routerLink: '/my-library/subscriptions',
+      label: $localize`Subscriptions`,
+      iconName: 'cog'
+    }
+  ]
+
+  titlePage = $localize`Videos from your subscriptions`
+
+  disabled = false
+
+  private feedToken: string
 
   constructor (
-    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 authService: AuthService,
     private userSubscription: UserSubscriptionService,
-    protected cfr: ComponentFactoryResolver,
     private hooks: HooksService,
     private videoService: VideoService,
     private scopedTokensService: ScopedTokensService
   ) {
-    super()
 
-    this.titlePage = $localize`Videos from your subscriptions`
-
-    this.actions.push({
-      routerLink: '/my-library/subscriptions',
-      label: $localize`Subscriptions`,
-      iconName: 'cog'
-    })
   }
 
-  ngOnInit () {
-    super.ngOnInit()
-
-    const user = this.authService.getUser()
-    let feedUrl = environment.originServerUrl
-
-    this.authService.userInformationLoaded
-      .pipe(switchMap(() => this.scopedTokensService.getScopedTokens()))
-      .subscribe({
-        next: tokens => {
-          const feeds = this.videoService.getVideoSubscriptionFeedUrls(user.account.id, tokens.feedToken)
-          feedUrl = feedUrl + feeds.find(f => f.format === FeedFormat.RSS).url
-
-          this.actions.unshift({
-            label: $localize`Copy feed URL`,
-            iconName: 'syndication',
-            justIcon: true,
-            href: feedUrl,
-            click: e => {
-              e.preventDefault()
-              copyToClipboard(feedUrl)
-              this.activateCopiedMessage()
-            }
-          })
-        },
-
-        error: err => {
-          this.notifier.error(err.message)
-        }
-      })
-  }
-
-  ngOnDestroy () {
-    super.ngOnDestroy()
-  }
-
-  getVideosObservable (page: number) {
-    const newPagination = immutableAssign(this.pagination, { currentPage: page })
+  getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
     const params = {
-      videoPagination: newPagination,
-      sort: this.sort,
+      ...filters.toVideosAPIObject(),
+
+      videoPagination: pagination,
       skipCount: true
     }
 
@@ -101,12 +60,32 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
     )
   }
 
-  generateSyndicationList () {
-    /* method disabled: the view provides its own */
-    throw new Error('Method not implemented.')
+  getSyndicationItems () {
+    return this.loadFeedToken()
+      .then(() => {
+        const user = this.authService.getUser()
+
+        return this.videoService.getVideoSubscriptionFeedUrls(user.account.id, this.feedToken)
+      })
   }
 
-  activateCopiedMessage () {
-    this.notifier.success($localize`Feed URL copied`)
+  disableForReuse () {
+    this.disabled = true
+  }
+
+  enabledForReuse () {
+    this.disabled = false
+  }
+
+  private loadFeedToken () {
+    if (this.feedToken) return Promise.resolve(this.feedToken)
+
+    const obs = this.authService.userInformationLoaded
+      .pipe(
+        switchMap(() => this.scopedTokensService.getScopedTokens()),
+        tap(tokens => this.feedToken = tokens.feedToken)
+      )
+
+    return firstValueFrom(obs)
   }
 }

+ 22 - 0
client/src/app/+videos/video-list/videos-list-common-page.component.html

@@ -0,0 +1,22 @@
+<my-videos-list
+  [getVideosObservableFunction]="getVideosObservableFunction"
+  [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+  [baseRouteBuilderFunction]="baseRouteBuilderFunction"
+
+  [title]="title"
+  [titleTooltip]="titleTooltip"
+
+  [defaultSort]="defaultSort"
+  [defaultScope]="defaultScope"
+
+  [displayFilters]="true"
+  [displayModerationBlock]="true"
+
+  [loadUserVideoPreferences]="true"
+  [groupByDate]="groupByDate"
+
+  [disabled]="disabled"
+
+  (filtersChanged)="onFiltersChanged($event)"
+>
+</my-videos-list>

+ 219 - 0
client/src/app/+videos/video-list/videos-list-common-page.component.ts

@@ -0,0 +1,219 @@
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'
+import { ComponentPaginationLight, DisableForReuseHook, MetaService, RedirectService, ServerService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { VideoService } from '@app/shared/shared-main'
+import { VideoFilters, VideoFilterScope } from '@app/shared/shared-video-miniature/video-filters.model'
+import { ClientFilterHookName, VideoSortField } from '@shared/models'
+import { Subscription } from 'rxjs'
+
+export type VideosListCommonPageRouteData = {
+  sort: VideoSortField
+
+  scope: VideoFilterScope
+  hookParams: ClientFilterHookName
+  hookResult: ClientFilterHookName
+}
+
+@Component({
+  templateUrl: './videos-list-common-page.component.html'
+})
+export class VideosListCommonPageComponent implements OnInit, OnDestroy, DisableForReuseHook {
+  getVideosObservableFunction = this.getVideosObservable.bind(this)
+  getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
+  baseRouteBuilderFunction = this.baseRouteBuilder.bind(this)
+
+  title: string
+  titleTooltip: string
+
+  groupByDate: boolean
+
+  defaultSort: VideoSortField
+  defaultScope: VideoFilterScope
+
+  hookParams: ClientFilterHookName
+  hookResult: ClientFilterHookName
+
+  loadUserVideoPreferences = true
+
+  displayFilters = true
+
+  disabled = false
+
+  private trendingDays: number
+  private routeSub: Subscription
+
+  constructor (
+    private server: ServerService,
+    private route: ActivatedRoute,
+    private videoService: VideoService,
+    private hooks: HooksService,
+    private meta: MetaService,
+    private redirectService: RedirectService
+  ) {
+  }
+
+  ngOnInit () {
+    this.trendingDays = this.server.getHTMLConfig().trending.videos.intervalDays
+
+    this.routeSub = this.route.params.subscribe(params => {
+      this.update(params['page'])
+    })
+  }
+
+  ngOnDestroy () {
+    if (this.routeSub) this.routeSub.unsubscribe()
+  }
+
+  getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
+    const params = {
+      ...filters.toVideosAPIObject(),
+
+      videoPagination: pagination,
+      skipCount: true
+    }
+
+    return this.hooks.wrapObsFun(
+      this.videoService.getVideos.bind(this.videoService),
+      params,
+      'common',
+      this.hookParams,
+      this.hookResult
+    )
+  }
+
+  getSyndicationItems (filters: VideoFilters) {
+    const result = filters.toVideosAPIObject()
+
+    return this.videoService.getVideoFeedUrls(result.sort, result.filter)
+  }
+
+  onFiltersChanged (filters: VideoFilters) {
+    this.buildTitle(filters.scope, filters.sort)
+    this.updateGroupByDate(filters.sort)
+  }
+
+  baseRouteBuilder (filters: VideoFilters) {
+    const sanitizedSort = this.getSanitizedSort(filters.sort)
+
+    let suffix: string
+
+    if (filters.scope === 'local') suffix = 'local'
+    else if (sanitizedSort === 'publishedAt') suffix = 'recently-added'
+    else suffix = 'trending'
+
+    return [ '/videos', suffix ]
+  }
+
+  disableForReuse () {
+    this.disabled = true
+  }
+
+  enabledForReuse () {
+    this.disabled = false
+  }
+
+  update (page: string) {
+    const data = this.getData(page)
+
+    this.hookParams = data.hookParams
+    this.hookResult = data.hookResult
+
+    this.defaultSort = data.sort
+    this.defaultScope = data.scope
+
+    this.buildTitle()
+    this.updateGroupByDate(this.defaultSort)
+
+    this.meta.setTitle(this.title)
+  }
+
+  private getData (page: string) {
+    if (page === 'trending') return this.generateTrendingData(this.route.snapshot)
+
+    if (page === 'local') return this.generateLocalData()
+
+    return this.generateRecentlyAddedData()
+  }
+
+  private generateRecentlyAddedData (): VideosListCommonPageRouteData {
+    return {
+      sort: '-publishedAt',
+      scope: 'federated',
+      hookParams: 'filter:api.recently-added-videos.videos.list.params',
+      hookResult: 'filter:api.recently-added-videos.videos.list.result'
+    }
+  }
+
+  private generateLocalData (): VideosListCommonPageRouteData {
+    return {
+      sort: '-publishedAt' as VideoSortField,
+      scope: 'local',
+      hookParams: 'filter:api.local-videos.videos.list.params',
+      hookResult: 'filter:api.local-videos.videos.list.result'
+    }
+  }
+
+  private generateTrendingData (route: ActivatedRouteSnapshot): VideosListCommonPageRouteData {
+    const sort = route.queryParams['sort'] ?? this.parseTrendingAlgorithm(this.redirectService.getDefaultTrendingAlgorithm())
+
+    return {
+      sort,
+      scope: 'federated',
+      hookParams: 'filter:api.trending-videos.videos.list.params',
+      hookResult: 'filter:api.trending-videos.videos.list.result'
+    }
+  }
+
+  private parseTrendingAlgorithm (algorithm: string): VideoSortField {
+    switch (algorithm) {
+      case 'most-viewed':
+        return '-trending'
+
+      case 'most-liked':
+        return '-likes'
+
+      default:
+        return '-' + algorithm as VideoSortField
+    }
+  }
+
+  private updateGroupByDate (sort: VideoSortField) {
+    this.groupByDate = sort === '-publishedAt' || sort === 'publishedAt'
+  }
+
+  private buildTitle (scope: VideoFilterScope = this.defaultScope, sort: VideoSortField = this.defaultSort) {
+    const sanitizedSort = this.getSanitizedSort(sort)
+
+    if (scope === 'local') {
+      this.title = $localize`Local videos`
+      this.titleTooltip = $localize`Only videos uploaded on this instance are displayed`
+      return
+    }
+
+    if (sanitizedSort === 'publishedAt') {
+      this.title = $localize`Recently added`
+      this.titleTooltip = undefined
+      return
+    }
+
+    if ([ 'best', 'hot', 'trending', 'likes' ].includes(sanitizedSort)) {
+      this.title = $localize`Trending`
+
+      if (sanitizedSort === 'best') this.titleTooltip = $localize`Videos with the most interactions for recent videos, minus user history`
+      if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos`
+      if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes`
+
+      if (sanitizedSort === 'trending') {
+        if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours`
+        else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days`
+      }
+
+      return
+    }
+  }
+
+  private getSanitizedSort (sort: VideoSortField) {
+    return sort.replace(/^-/, '') as VideoSortField
+  }
+}

+ 21 - 33
client/src/app/+videos/videos-routing.module.ts

@@ -1,10 +1,8 @@
 import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
+import { RouterModule, Routes, UrlSegment } from '@angular/router'
 import { LoginGuard } from '@app/core'
-import { VideoTrendingComponent } from './video-list'
+import { VideosListCommonPageComponent } from './video-list'
 import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
-import { VideoLocalComponent } from './video-list/video-local.component'
-import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
 import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
 import { VideosComponent } from './videos.component'
 
@@ -22,32 +20,35 @@ const videosRoutes: Routes = [
           }
         }
       },
+
       {
-        path: 'trending',
-        component: VideoTrendingComponent,
-        data: {
-          meta: {
-            title: $localize`Trending videos`
-          }
-        }
-      },
-      {
+        // Old URL redirection
         path: 'most-liked',
-        redirectTo: 'trending?alg=most-liked'
+        redirectTo: 'trending?sort=most-liked'
       },
       {
-        path: 'recently-added',
-        component: VideoRecentlyAddedComponent,
+        matcher: (url: UrlSegment[]) => {
+          if (url.length === 1 && [ 'recently-added', 'trending', 'local' ].includes(url[0].path)) {
+            return {
+              consumed: url,
+              posParams: {
+                page: new UrlSegment(url[0].path, {})
+              }
+            }
+          }
+
+          return null
+        },
+
+        component: VideosListCommonPageComponent,
         data: {
-          meta: {
-            title: $localize`Recently added videos`
-          },
           reuse: {
             enabled: true,
-            key: 'recently-added-videos-list'
+            key: 'videos-list'
           }
         }
       },
+
       {
         path: 'subscriptions',
         canActivate: [ LoginGuard ],
@@ -61,19 +62,6 @@ const videosRoutes: Routes = [
             key: 'subscription-videos-list'
           }
         }
-      },
-      {
-        path: 'local',
-        component: VideoLocalComponent,
-        data: {
-          meta: {
-            title: $localize`Local videos`
-          },
-          reuse: {
-            enabled: true,
-            key: 'local-videos-list'
-          }
-        }
       }
     ]
   }

+ 3 - 9
client/src/app/+videos/videos.module.ts

@@ -5,11 +5,8 @@ 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, VideoTrendingComponent } from './video-list'
+import { OverviewService, VideosListCommonPageComponent } from './video-list'
 import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
-import { VideoTrendingHeaderComponent } from './video-list/trending/video-trending-header.component'
-import { VideoLocalComponent } from './video-list/video-local.component'
-import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
 import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
 import { VideosRoutingModule } from './videos-routing.module'
 import { VideosComponent } from './videos.component'
@@ -29,12 +26,9 @@ import { VideosComponent } from './videos.component'
   declarations: [
     VideosComponent,
 
-    VideoTrendingHeaderComponent,
-    VideoTrendingComponent,
-    VideoRecentlyAddedComponent,
-    VideoLocalComponent,
     VideoUserSubscriptionsComponent,
-    VideoOverviewComponent
+    VideoOverviewComponent,
+    VideosListCommonPageComponent
   ],
 
   exports: [

+ 1 - 0
client/src/app/app-routing.module.ts

@@ -177,6 +177,7 @@ routes.push({
   imports: [
     RouterModule.forRoot(routes, {
       useHash: Boolean(history.pushState) === false,
+      // Redefined in app component
       scrollPositionRestoration: 'disabled',
       preloadingStrategy: PreloadSelectedModulesList,
       anchorScrolling: 'disabled'

+ 18 - 62
client/src/app/app.component.ts

@@ -1,10 +1,20 @@
 import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { filter, map, pairwise, switchMap } from 'rxjs/operators'
-import { DOCUMENT, getLocaleDirection, PlatformLocation, ViewportScroller } from '@angular/common'
+import { filter, map, switchMap } from 'rxjs/operators'
+import { DOCUMENT, getLocaleDirection, PlatformLocation } from '@angular/common'
 import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
 import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
-import { Event, GuardsCheckStart, NavigationEnd, RouteConfigLoadEnd, RouteConfigLoadStart, Router, Scroll } from '@angular/router'
-import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core'
+import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router'
+import {
+  AuthService,
+  MarkdownService,
+  PeerTubeRouterService,
+  RedirectService,
+  ScreenService,
+  ScrollService,
+  ServerService,
+  ThemeService,
+  User
+} from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
 import { PluginService } from '@app/core/plugins/plugin.service'
 import { CustomModalComponent } from '@app/modal/custom-modal.component'
@@ -39,10 +49,10 @@ export class AppComponent implements OnInit, AfterViewInit {
   constructor (
     @Inject(DOCUMENT) private document: Document,
     @Inject(LOCALE_ID) private localeId: string,
-    private viewportScroller: ViewportScroller,
     private router: Router,
     private authService: AuthService,
     private serverService: ServerService,
+    private peertubeRouter: PeerTubeRouterService,
     private pluginService: PluginService,
     private instanceService: InstanceService,
     private domSanitizer: DomSanitizer,
@@ -56,6 +66,7 @@ export class AppComponent implements OnInit, AfterViewInit {
     private markdownService: MarkdownService,
     private ngbConfig: NgbConfig,
     private loadingBar: LoadingBarService,
+    private scrollService: ScrollService,
     public menu: MenuService
   ) {
     this.ngbConfig.animation = false
@@ -85,6 +96,7 @@ export class AppComponent implements OnInit, AfterViewInit {
     }
 
     this.initRouteEvents()
+    this.scrollService.enableScrollRestoration()
 
     this.injectJS()
     this.injectCSS()
@@ -132,66 +144,10 @@ export class AppComponent implements OnInit, AfterViewInit {
   }
 
   private initRouteEvents () {
-    let resetScroll = true
     const eventsObs = this.router.events
 
-    const scrollEvent = eventsObs.pipe(filter((e: Event): e is Scroll => e instanceof Scroll))
-
-    // Handle anchors/restore position
-    scrollEvent.subscribe(e => {
-      // scrollToAnchor first to preserve anchor position when using history navigation
-      if (e.anchor) {
-        setTimeout(() => {
-          this.viewportScroller.scrollToAnchor(e.anchor)
-        })
-
-        return
-      }
-
-      if (e.position) {
-        return this.viewportScroller.scrollToPosition(e.position)
-      }
-
-      if (resetScroll) {
-        return this.viewportScroller.scrollToPosition([ 0, 0 ])
-      }
-    })
-
-    const navigationEndEvent = eventsObs.pipe(filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd))
-
-    // When we add the a-state parameter, we don't want to alter the scroll
-    navigationEndEvent.pipe(pairwise())
-                      .subscribe(([ e1, e2 ]) => {
-                        try {
-                          resetScroll = false
-
-                          const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects)
-                          const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects)
-
-                          if (previousUrl.pathname !== nextUrl.pathname) {
-                            resetScroll = true
-                            return
-                          }
-
-                          const nextSearchParams = nextUrl.searchParams
-                          nextSearchParams.delete('a-state')
-
-                          const previousSearchParams = previousUrl.searchParams
-
-                          nextSearchParams.sort()
-                          previousSearchParams.sort()
-
-                          if (nextSearchParams.toString() !== previousSearchParams.toString()) {
-                            resetScroll = true
-                          }
-                        } catch (e) {
-                          console.error('Cannot parse URL to check next scroll.', e)
-                          resetScroll = true
-                        }
-                      })
-
     // Plugin hooks
-    navigationEndEvent.subscribe(e => {
+    this.peertubeRouter.getNavigationEndEvents().subscribe(e => {
       this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url })
     })
 

+ 13 - 1
client/src/app/core/core.module.ts

@@ -14,7 +14,17 @@ import { throwIfAlreadyLoaded } from './module-import-guard'
 import { Notifier } from './notification'
 import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
 import { RestExtractor, RestService } from './rest'
-import { HomepageRedirectComponent, LoginGuard, MetaGuard, MetaService, RedirectService, UnloggedGuard, UserRightGuard } from './routing'
+import {
+  HomepageRedirectComponent,
+  LoginGuard,
+  MetaGuard,
+  MetaService,
+  PeerTubeRouterService,
+  RedirectService,
+  ScrollService,
+  UnloggedGuard,
+  UserRightGuard
+} from './routing'
 import { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
 import { ServerConfigResolver } from './routing/server-config-resolver.service'
 import { ScopedTokensService } from './scoped-tokens'
@@ -80,6 +90,8 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
     PeerTubeSocket,
     ServerConfigResolver,
     CanDeactivateGuard,
+    PeerTubeRouterService,
+    ScrollService,
 
     MetaService,
     MetaGuard

+ 5 - 1
client/src/app/core/routing/custom-reuse-strategy.ts

@@ -1,5 +1,7 @@
 import { Injectable } from '@angular/core'
 import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
+import { RouterSetting } from './'
+import { PeerTubeRouterService } from './peertube-router.service'
 
 @Injectable()
 export class CustomReuseStrategy implements RouteReuseStrategy {
@@ -78,6 +80,8 @@ export class CustomReuseStrategy implements RouteReuseStrategy {
   }
 
   private isReuseEnabled (route: ActivatedRouteSnapshot) {
-    return route.data.reuse?.enabled && route.queryParams['a-state']
+    // Cannot use peertube router here because of cyclic router dependency
+    return route.data.reuse?.enabled &&
+      !!(route.queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME] & RouterSetting.REUSE_COMPONENT)
   }
 }

+ 2 - 0
client/src/app/core/routing/index.ts

@@ -5,9 +5,11 @@ export * from './homepage-redirect.component'
 export * from './login-guard.service'
 export * from './menu-guard.service'
 export * from './meta-guard.service'
+export * from './peertube-router.service'
 export * from './meta.service'
 export * from './preload-selected-modules-list'
 export * from './redirect.service'
+export * from './scroll.service'
 export * from './server-config-resolver.service'
 export * from './unlogged-guard.service'
 export * from './user-right-guard.service'

+ 78 - 0
client/src/app/core/routing/peertube-router.service.ts

@@ -0,0 +1,78 @@
+import { filter } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ActivatedRoute, ActivatedRouteSnapshot, Event, NavigationEnd, Router, Scroll } from '@angular/router'
+import { ServerService } from '../server'
+
+export const enum RouterSetting {
+  NONE = 0,
+  REUSE_COMPONENT = 1 << 0,
+  DISABLE_SCROLL_RESTORE = 1 << 1
+}
+
+@Injectable()
+export class PeerTubeRouterService {
+  static readonly ROUTE_SETTING_NAME = 's'
+
+  constructor (
+    private route: ActivatedRoute,
+    private router: Router,
+    private server: ServerService
+  ) { }
+
+  addRouteSetting (toAdd: RouterSetting) {
+    if (this.hasRouteSetting(toAdd)) return
+
+    const current = this.getRouteSetting()
+
+    this.setRouteSetting(current | toAdd)
+  }
+
+  deleteRouteSetting (toDelete: RouterSetting) {
+    const current = this.getRouteSetting()
+
+    this.setRouteSetting(current & ~toDelete)
+  }
+
+  getRouteSetting (snapshot?: ActivatedRouteSnapshot) {
+    return (snapshot || this.route.snapshot).queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME]
+  }
+
+  setRouteSetting (value: number) {
+    let path = window.location.pathname
+    if (!path || path === '/') path = this.server.getHTMLConfig().instance.defaultClientRoute
+
+    const queryParams = { [PeerTubeRouterService.ROUTE_SETTING_NAME]: value }
+
+    this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
+  }
+
+  hasRouteSetting (setting: RouterSetting, snapshot?: ActivatedRouteSnapshot) {
+    return !!(this.getRouteSetting(snapshot) & setting)
+  }
+
+  getNavigationEndEvents () {
+    return this.router.events.pipe(
+      filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd)
+    )
+  }
+
+  getScrollEvents () {
+    return this.router.events.pipe(
+      filter((e: Event): e is Scroll => e instanceof Scroll)
+    )
+  }
+
+  silentNavigate (baseRoute: string[], queryParams: { [id: string]: string }) {
+    let routeSetting = this.getRouteSetting() ?? RouterSetting.NONE
+    routeSetting |= RouterSetting.DISABLE_SCROLL_RESTORE
+
+    queryParams = {
+      ...queryParams,
+
+      [PeerTubeRouterService.ROUTE_SETTING_NAME]: routeSetting
+    }
+
+    return this.router.navigate(baseRoute, { queryParams })
+  }
+
+}

+ 91 - 0
client/src/app/core/routing/scroll.service.ts

@@ -0,0 +1,91 @@
+import * as debug from 'debug'
+import { pairwise } from 'rxjs'
+import { ViewportScroller } from '@angular/common'
+import { Injectable } from '@angular/core'
+import { RouterSetting } from '../'
+import { PeerTubeRouterService } from './peertube-router.service'
+
+const logger = debug('peertube:main:ScrollService')
+
+@Injectable()
+export class ScrollService {
+
+  private resetScroll = true
+
+  constructor (
+    private viewportScroller: ViewportScroller,
+    private peertubeRouter: PeerTubeRouterService
+  ) { }
+
+  enableScrollRestoration () {
+    // We'll manage scroll restoration ourselves
+    this.viewportScroller.setHistoryScrollRestoration('manual')
+
+    this.consumeScroll()
+    this.produceScroll()
+  }
+
+  private produceScroll () {
+    // When we add the a-state parameter, we don't want to alter the scroll
+    this.peertubeRouter.getNavigationEndEvents().pipe(pairwise())
+                      .subscribe(([ e1, e2 ]) => {
+                        try {
+                          this.resetScroll = false
+
+                          const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects)
+                          const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects)
+
+                          if (previousUrl.pathname !== nextUrl.pathname) {
+                            this.resetScroll = true
+                            return
+                          }
+
+                          if (this.peertubeRouter.hasRouteSetting(RouterSetting.DISABLE_SCROLL_RESTORE)) {
+                            this.resetScroll = false
+                            return
+                          }
+
+                          // Remove route settings from the comparison
+                          const nextSearchParams = nextUrl.searchParams
+                          nextSearchParams.delete(PeerTubeRouterService.ROUTE_SETTING_NAME)
+
+                          const previousSearchParams = previousUrl.searchParams
+
+                          nextSearchParams.sort()
+                          previousSearchParams.sort()
+
+                          if (nextSearchParams.toString() !== previousSearchParams.toString()) {
+                            this.resetScroll = true
+                          }
+                        } catch (e) {
+                          console.error('Cannot parse URL to check next scroll.', e)
+                          this.resetScroll = true
+                        }
+                      })
+  }
+
+  private consumeScroll () {
+    // Handle anchors/restore position
+    this.peertubeRouter.getScrollEvents().subscribe(e => {
+      logger('Will schedule scroll after router event %o.', e)
+
+      // scrollToAnchor first to preserve anchor position when using history navigation
+      if (e.anchor) {
+        setTimeout(() => this.viewportScroller.scrollToAnchor(e.anchor))
+
+        return
+      }
+
+      if (e.position) {
+        setTimeout(() => this.viewportScroller.scrollToPosition(e.position))
+
+        return
+      }
+
+      if (this.resetScroll) {
+        return this.viewportScroller.scrollToPosition([ 0, 0 ])
+      }
+    })
+  }
+
+}

+ 0 - 226
client/src/app/helpers/utils.ts

@@ -1,226 +0,0 @@
-import { first, map } from 'rxjs/operators'
-import { SelectChannelItem } from 'src/types/select-options-item.model'
-import { DatePipe } from '@angular/common'
-import { HttpErrorResponse } from '@angular/common/http'
-import { Notifier } from '@app/core'
-import { HttpStatusCode } from '@shared/models'
-import { environment } from '../../environments/environment'
-import { AuthService } from '../core/auth'
-
-// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
-function getParameterByName (name: string, url: string) {
-  if (!url) url = window.location.href
-  name = name.replace(/[[\]]/g, '\\$&')
-
-  const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)')
-  const results = regex.exec(url)
-
-  if (!results) return null
-  if (!results[2]) return ''
-
-  return decodeURIComponent(results[2].replace(/\+/g, ' '))
-}
-
-function listUserChannels (authService: AuthService) {
-  return authService.userInformationLoaded
-    .pipe(
-      first(),
-      map(() => {
-        const user = authService.getUser()
-        if (!user) return undefined
-
-        const videoChannels = user.videoChannels
-        if (Array.isArray(videoChannels) === false) return undefined
-
-        return videoChannels
-          .sort((a, b) => {
-            if (a.updatedAt < b.updatedAt) return 1
-            if (a.updatedAt > b.updatedAt) return -1
-            return 0
-          })
-          .map(c => ({
-            id: c.id,
-            label: c.displayName,
-            support: c.support,
-            avatarPath: c.avatar?.path
-          }) as SelectChannelItem)
-      })
-    )
-}
-
-function getAbsoluteAPIUrl () {
-  let absoluteAPIUrl = environment.hmr === true
-    ? 'http://localhost:9000'
-    : environment.apiUrl
-
-  if (!absoluteAPIUrl) {
-    // The API is on the same domain
-    absoluteAPIUrl = window.location.origin
-  }
-
-  return absoluteAPIUrl
-}
-
-function getAbsoluteEmbedUrl () {
-  let absoluteEmbedUrl = environment.originServerUrl
-  if (!absoluteEmbedUrl) {
-    // The Embed is on the same domain
-    absoluteEmbedUrl = window.location.origin
-  }
-
-  return absoluteEmbedUrl
-}
-
-const datePipe = new DatePipe('en')
-function dateToHuman (date: string) {
-  return datePipe.transform(date, 'medium')
-}
-
-function durationToString (duration: number) {
-  const hours = Math.floor(duration / 3600)
-  const minutes = Math.floor((duration % 3600) / 60)
-  const seconds = duration % 60
-
-  const minutesPadding = minutes >= 10 ? '' : '0'
-  const secondsPadding = seconds >= 10 ? '' : '0'
-  const displayedHours = hours > 0 ? hours.toString() + ':' : ''
-
-  return (
-    displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
-  ).replace(/^0/, '')
-}
-
-function immutableAssign <A, B> (target: A, source: B) {
-  return Object.assign({}, target, source)
-}
-
-// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34
-function objectToFormData (obj: any, form?: FormData, namespace?: string) {
-  const fd = form || new FormData()
-  let formKey
-
-  for (const key of Object.keys(obj)) {
-    if (namespace) formKey = `${namespace}[${key}]`
-    else formKey = key
-
-    if (obj[key] === undefined) continue
-
-    if (Array.isArray(obj[key]) && obj[key].length === 0) {
-      fd.append(key, null)
-      continue
-    }
-
-    if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) {
-      objectToFormData(obj[key], fd, formKey)
-    } else {
-      fd.append(formKey, obj[key])
-    }
-  }
-
-  return fd
-}
-
-function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
-  return immutableAssign(obj, {
-    [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
-  })
-}
-
-function lineFeedToHtml (text: string) {
-  if (!text) return text
-
-  return text.replace(/\r?\n|\r/g, '<br />')
-}
-
-function removeElementFromArray <T> (arr: T[], elem: T) {
-  const index = arr.indexOf(elem)
-  if (index !== -1) arr.splice(index, 1)
-}
-
-function sortBy (obj: any[], key1: string, key2?: string) {
-  return obj.sort((a, b) => {
-    const elem1 = key2 ? a[key1][key2] : a[key1]
-    const elem2 = key2 ? b[key1][key2] : b[key1]
-
-    if (elem1 < elem2) return -1
-    if (elem1 === elem2) return 0
-    return 1
-  })
-}
-
-function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
-  window.scrollTo({
-    left: 0,
-    top: 0,
-    behavior
-  })
-}
-
-function isInViewport (el: HTMLElement) {
-  const bounding = el.getBoundingClientRect()
-  return (
-    bounding.top >= 0 &&
-      bounding.left >= 0 &&
-      bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
-      bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
-  )
-}
-
-function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
-  const rect = el.getBoundingClientRect()
-  const windowHeight = (window.innerHeight || document.documentElement.clientHeight)
-
-  return !(
-    Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible ||
-    Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible
-  )
-}
-
-function genericUploadErrorHandler (parameters: {
-  err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
-  name: string
-  notifier: Notifier
-  sticky?: boolean
-}) {
-  const { err, name, notifier, sticky } = { sticky: false, ...parameters }
-  const title = $localize`The upload failed`
-  let message = err.message
-
-  if (err instanceof ErrorEvent) { // network error
-    message = $localize`The connection was interrupted`
-    notifier.error(message, title, null, sticky)
-  } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
-    message = $localize`The server encountered an error`
-    notifier.error(message, title, null, sticky)
-  } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
-    message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
-    notifier.error(message, title, null, sticky)
-  } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
-    const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
-    message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
-    notifier.error(message, title, null, sticky)
-  } else {
-    notifier.error(err.message, title)
-  }
-
-  return message
-}
-
-export {
-  sortBy,
-  durationToString,
-  lineFeedToHtml,
-  getParameterByName,
-  getAbsoluteAPIUrl,
-  dateToHuman,
-  immutableAssign,
-  objectToFormData,
-  getAbsoluteEmbedUrl,
-  objectLineFeedToHtml,
-  removeElementFromArray,
-  scrollToTop,
-  isInViewport,
-  isXPercentInViewport,
-  listUserChannels,
-  genericUploadErrorHandler
-}

+ 34 - 0
client/src/app/helpers/utils/channel.ts

@@ -0,0 +1,34 @@
+import { first, map } from 'rxjs/operators'
+import { SelectChannelItem } from 'src/types/select-options-item.model'
+import { AuthService } from '../../core/auth'
+
+function listUserChannels (authService: AuthService) {
+  return authService.userInformationLoaded
+    .pipe(
+      first(),
+      map(() => {
+        const user = authService.getUser()
+        if (!user) return undefined
+
+        const videoChannels = user.videoChannels
+        if (Array.isArray(videoChannels) === false) return undefined
+
+        return videoChannels
+          .sort((a, b) => {
+            if (a.updatedAt < b.updatedAt) return 1
+            if (a.updatedAt > b.updatedAt) return -1
+            return 0
+          })
+          .map(c => ({
+            id: c.id,
+            label: c.displayName,
+            support: c.support,
+            avatarPath: c.avatar?.path
+          }) as SelectChannelItem)
+      })
+    )
+}
+
+export {
+  listUserChannels
+}

+ 25 - 0
client/src/app/helpers/utils/date.ts

@@ -0,0 +1,25 @@
+import { DatePipe } from '@angular/common'
+
+const datePipe = new DatePipe('en')
+function dateToHuman (date: string) {
+  return datePipe.transform(date, 'medium')
+}
+
+function durationToString (duration: number) {
+  const hours = Math.floor(duration / 3600)
+  const minutes = Math.floor((duration % 3600) / 60)
+  const seconds = duration % 60
+
+  const minutesPadding = minutes >= 10 ? '' : '0'
+  const secondsPadding = seconds >= 10 ? '' : '0'
+  const displayedHours = hours > 0 ? hours.toString() + ':' : ''
+
+  return (
+    displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
+  ).replace(/^0/, '')
+}
+
+export {
+  durationToString,
+  dateToHuman
+}

+ 18 - 0
client/src/app/helpers/utils/html.ts

@@ -0,0 +1,18 @@
+import { immutableAssign } from './object'
+
+function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
+  return immutableAssign(obj, {
+    [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
+  })
+}
+
+function lineFeedToHtml (text: string) {
+  if (!text) return text
+
+  return text.replace(/\r?\n|\r/g, '<br />')
+}
+
+export {
+  objectLineFeedToHtml,
+  lineFeedToHtml
+}

+ 7 - 0
client/src/app/helpers/utils/index.ts

@@ -0,0 +1,7 @@
+export * from './channel'
+export * from './date'
+export * from './html'
+export * from './object'
+export * from './ui'
+export * from './upload'
+export * from './url'

+ 47 - 0
client/src/app/helpers/utils/object.ts

@@ -0,0 +1,47 @@
+function immutableAssign <A, B> (target: A, source: B) {
+  return Object.assign({}, target, source)
+}
+
+function removeElementFromArray <T> (arr: T[], elem: T) {
+  const index = arr.indexOf(elem)
+  if (index !== -1) arr.splice(index, 1)
+}
+
+function sortBy (obj: any[], key1: string, key2?: string) {
+  return obj.sort((a, b) => {
+    const elem1 = key2 ? a[key1][key2] : a[key1]
+    const elem2 = key2 ? b[key1][key2] : b[key1]
+
+    if (elem1 < elem2) return -1
+    if (elem1 === elem2) return 0
+    return 1
+  })
+}
+
+function intoArray (value: any) {
+  if (!value) return undefined
+  if (Array.isArray(value)) return value
+
+  if (typeof value === 'string') return value.split(',')
+
+  return [ value ]
+}
+
+function toBoolean (value: any) {
+  if (!value) return undefined
+
+  if (typeof value === 'boolean') return value
+
+  if (value === 'true') return true
+  if (value === 'false') return false
+
+  return undefined
+}
+
+export {
+  sortBy,
+  immutableAssign,
+  removeElementFromArray,
+  intoArray,
+  toBoolean
+}

+ 33 - 0
client/src/app/helpers/utils/ui.ts

@@ -0,0 +1,33 @@
+function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
+  window.scrollTo({
+    left: 0,
+    top: 0,
+    behavior
+  })
+}
+
+function isInViewport (el: HTMLElement) {
+  const bounding = el.getBoundingClientRect()
+  return (
+    bounding.top >= 0 &&
+      bounding.left >= 0 &&
+      bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+      bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
+  )
+}
+
+function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
+  const rect = el.getBoundingClientRect()
+  const windowHeight = (window.innerHeight || document.documentElement.clientHeight)
+
+  return !(
+    Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible ||
+    Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible
+  )
+}
+
+export {
+  scrollToTop,
+  isInViewport,
+  isXPercentInViewport
+}

+ 37 - 0
client/src/app/helpers/utils/upload.ts

@@ -0,0 +1,37 @@
+import { HttpErrorResponse } from '@angular/common/http'
+import { Notifier } from '@app/core'
+import { HttpStatusCode } from '@shared/models'
+
+function genericUploadErrorHandler (parameters: {
+  err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
+  name: string
+  notifier: Notifier
+  sticky?: boolean
+}) {
+  const { err, name, notifier, sticky } = { sticky: false, ...parameters }
+  const title = $localize`The upload failed`
+  let message = err.message
+
+  if (err instanceof ErrorEvent) { // network error
+    message = $localize`The connection was interrupted`
+    notifier.error(message, title, null, sticky)
+  } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
+    message = $localize`The server encountered an error`
+    notifier.error(message, title, null, sticky)
+  } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
+    message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
+    notifier.error(message, title, null, sticky)
+  } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
+    const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
+    message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
+    notifier.error(message, title, null, sticky)
+  } else {
+    notifier.error(err.message, title)
+  }
+
+  return message
+}
+
+export {
+  genericUploadErrorHandler
+}

+ 71 - 0
client/src/app/helpers/utils/url.ts

@@ -0,0 +1,71 @@
+import { environment } from '../../../environments/environment'
+
+// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
+function getParameterByName (name: string, url: string) {
+  if (!url) url = window.location.href
+  name = name.replace(/[[\]]/g, '\\$&')
+
+  const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)')
+  const results = regex.exec(url)
+
+  if (!results) return null
+  if (!results[2]) return ''
+
+  return decodeURIComponent(results[2].replace(/\+/g, ' '))
+}
+
+function getAbsoluteAPIUrl () {
+  let absoluteAPIUrl = environment.hmr === true
+    ? 'http://localhost:9000'
+    : environment.apiUrl
+
+  if (!absoluteAPIUrl) {
+    // The API is on the same domain
+    absoluteAPIUrl = window.location.origin
+  }
+
+  return absoluteAPIUrl
+}
+
+function getAbsoluteEmbedUrl () {
+  let absoluteEmbedUrl = environment.originServerUrl
+  if (!absoluteEmbedUrl) {
+    // The Embed is on the same domain
+    absoluteEmbedUrl = window.location.origin
+  }
+
+  return absoluteEmbedUrl
+}
+
+// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34
+function objectToFormData (obj: any, form?: FormData, namespace?: string) {
+  const fd = form || new FormData()
+  let formKey
+
+  for (const key of Object.keys(obj)) {
+    if (namespace) formKey = `${namespace}[${key}]`
+    else formKey = key
+
+    if (obj[key] === undefined) continue
+
+    if (Array.isArray(obj[key]) && obj[key].length === 0) {
+      fd.append(key, null)
+      continue
+    }
+
+    if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) {
+      objectToFormData(obj[key], fd, formKey)
+    } else {
+      fd.append(formKey, obj[key])
+    }
+  }
+
+  return fd
+}
+
+export {
+  getParameterByName,
+  objectToFormData,
+  getAbsoluteAPIUrl,
+  getAbsoluteEmbedUrl
+}

+ 2 - 1
client/src/app/shared/shared-forms/advanced-input-filter.component.ts

@@ -18,6 +18,7 @@ const logger = debug('peertube:AdvancedInputFilterComponent')
 })
 export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
   @Input() filters: AdvancedInputFilter[] = []
+  @Input() emitOnInit = true
 
   @Output() search = new EventEmitter<string>()
 
@@ -42,7 +43,7 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
     this.viewInitialized = true
 
     // Init after view init to not send an event too early
-    if (this.emitSearchAfterViewInit) this.emitSearch()
+    if (this.emitOnInit && this.emitSearchAfterViewInit) this.emitSearch()
   }
 
   onInputSearch (event: Event) {

+ 3 - 0
client/src/app/shared/shared-forms/select/index.ts

@@ -1,5 +1,8 @@
+export * from './select-categories.component'
 export * from './select-channel.component'
+export * from './select-checkbox-all.component'
 export * from './select-checkbox.component'
 export * from './select-custom-value.component'
+export * from './select-languages.component'
 export * from './select-options.component'
 export * from './select-tags.component'

+ 8 - 0
client/src/app/shared/shared-forms/select/select-categories.component.html

@@ -0,0 +1,8 @@
+<my-select-checkbox-all
+  [(ngModel)]="selectedCategories"
+  (ngModelChange)="onModelChange()"
+  [availableItems]="availableCategories"
+  i18n-placeholder placeholder="Add a new category"
+  [allGroupLabel]="allCategoriesGroup"
+>
+</my-select-checkbox-all>

+ 71 - 0
client/src/app/shared/shared-forms/select/select-categories.component.ts

@@ -0,0 +1,71 @@
+
+import { Component, forwardRef, OnInit } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { ServerService } from '@app/core'
+import { SelectOptionsItem } from '../../../../types/select-options-item.model'
+import { ItemSelectCheckboxValue } from './select-checkbox.component'
+
+@Component({
+  selector: 'my-select-categories',
+  styleUrls: [ './select-shared.component.scss' ],
+  templateUrl: './select-categories.component.html',
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => SelectCategoriesComponent),
+      multi: true
+    }
+  ]
+})
+export class SelectCategoriesComponent implements ControlValueAccessor, OnInit {
+  selectedCategories: ItemSelectCheckboxValue[] = []
+  availableCategories: SelectOptionsItem[] = []
+
+  allCategoriesGroup = $localize`All categories`
+
+  // Fix a bug on ng-select when we update items after we selected items
+  private toWrite: any
+  private loaded = false
+
+  constructor (
+    private server: ServerService
+  ) {
+
+  }
+
+  ngOnInit () {
+    this.server.getVideoCategories()
+      .subscribe(
+        categories => {
+          this.availableCategories = categories.map(c => ({ label: c.label, id: c.id + '', group: this.allCategoriesGroup }))
+          this.loaded = true
+          this.writeValue(this.toWrite)
+        }
+      )
+  }
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (categories: string[] | number[]) {
+    if (!this.loaded) {
+      this.toWrite = categories
+      return
+    }
+
+    this.selectedCategories = categories
+      ? categories.map(c => c + '')
+      : categories as string[]
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  onModelChange () {
+    this.propagateChange(this.selectedCategories)
+  }
+}

+ 115 - 0
client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts

@@ -0,0 +1,115 @@
+import { Component, forwardRef, Input } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { Notifier } from '@app/core'
+import { SelectOptionsItem } from '../../../../types/select-options-item.model'
+import { ItemSelectCheckboxValue } from './select-checkbox.component'
+
+@Component({
+  selector: 'my-select-checkbox-all',
+  styleUrls: [ './select-shared.component.scss' ],
+
+  template: `
+  <my-select-checkbox
+    [(ngModel)]="selectedItems"
+    (ngModelChange)="onModelChange()"
+    [availableItems]="availableItems"
+    [selectableGroup]="true" [selectableGroupAsModel]="true"
+    [placeholder]="placeholder"
+    (focusout)="onBlur()"
+  >
+  </my-select-checkbox>`,
+
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => SelectCheckboxAllComponent),
+      multi: true
+    }
+  ]
+})
+export class SelectCheckboxAllComponent implements ControlValueAccessor {
+  @Input() availableItems: SelectOptionsItem[] = []
+  @Input() allGroupLabel: string
+
+  @Input() placeholder: string
+  @Input() maxItems: number
+
+  selectedItems: ItemSelectCheckboxValue[]
+
+  constructor (
+    private notifier: Notifier
+  ) {
+
+  }
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (items: string[]) {
+    this.selectedItems = items
+      ? items.map(l => ({ id: l }))
+      : [ { group: this.allGroupLabel } ]
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  onModelChange () {
+    if (!this.isMaxConstraintValid()) return
+
+    this.propagateChange(this.buildOutputItems())
+  }
+
+  onBlur () {
+    // Automatically use "All languages" if the user did not select any language
+    if (Array.isArray(this.selectedItems) && this.selectedItems.length === 0) {
+      this.selectedItems = [ { group: this.allGroupLabel } ]
+    }
+  }
+
+  private isMaxConstraintValid () {
+    if (!this.maxItems) return true
+
+    const outputItems = this.buildOutputItems()
+    if (!outputItems) return true
+
+    if (outputItems.length >= this.maxItems) {
+      this.notifier.error($localize`You can't select more than ${this.maxItems} items`)
+
+      return false
+    }
+
+    return true
+  }
+
+  private buildOutputItems () {
+    if (!Array.isArray(this.selectedItems)) return undefined
+
+    // null means "All"
+    if (this.selectedItems.length === 0 || this.selectedItems.length === this.availableItems.length) {
+      return null
+    }
+
+    if (this.selectedItems.length === 1) {
+      const item = this.selectedItems[0]
+
+      const itemGroup = typeof item === 'string' || typeof item === 'number'
+        ? item
+        : item.group
+
+      if (itemGroup === this.allGroupLabel) return null
+    }
+
+    return this.selectedItems.map(l => {
+      if (typeof l === 'string' || typeof l === 'number') return l
+
+      if (l.group) return l.group
+
+      return l.id + ''
+    })
+  }
+}

+ 0 - 2
client/src/app/shared/shared-forms/select/select-checkbox.component.html

@@ -18,8 +18,6 @@
 
   groupBy="group"
   [compareWith]="compareFn"
-
-  [maxSelectedItems]="maxSelectedItems"
 >
 
   <ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index">

+ 2 - 5
client/src/app/shared/shared-forms/select/select-checkbox.component.ts

@@ -2,7 +2,7 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 import { SelectOptionsItem } from '../../../../types/select-options-item.model'
 
-export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string
+export type ItemSelectCheckboxValue = { id?: string, group?: string } | string
 
 @Component({
   selector: 'my-select-checkbox',
@@ -21,7 +21,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
   @Input() selectedItems: ItemSelectCheckboxValue[] = []
   @Input() selectableGroup: boolean
   @Input() selectableGroupAsModel: boolean
-  @Input() maxSelectedItems: number
   @Input() placeholder: string
 
   ngOnInit () {
@@ -46,8 +45,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
     } else {
       this.selectedItems = items
     }
-
-    this.propagateChange(this.selectedItems)
   }
 
   registerOnChange (fn: (_: any) => void) {
@@ -63,7 +60,7 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
   }
 
   compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) {
-    if (typeof selected === 'string') {
+    if (typeof selected === 'string' || typeof selected === 'number') {
       return item.id === selected
     }
 

+ 9 - 0
client/src/app/shared/shared-forms/select/select-languages.component.html

@@ -0,0 +1,9 @@
+<my-select-checkbox-all
+  [(ngModel)]="selectedLanguages"
+  (ngModelChange)="onModelChange()"
+  [availableItems]="availableLanguages"
+  [maxItems]="maxLanguages"
+  i18n-placeholder placeholder="Add a new language"
+  [allGroupLabel]="allLanguagesGroup"
+>
+</my-select-checkbox-all>

+ 74 - 0
client/src/app/shared/shared-forms/select/select-languages.component.ts

@@ -0,0 +1,74 @@
+import { Component, forwardRef, Input, OnInit } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { ServerService } from '@app/core'
+import { SelectOptionsItem } from '../../../../types/select-options-item.model'
+import { ItemSelectCheckboxValue } from './select-checkbox.component'
+
+@Component({
+  selector: 'my-select-languages',
+  styleUrls: [ './select-shared.component.scss' ],
+  templateUrl: './select-languages.component.html',
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => SelectLanguagesComponent),
+      multi: true
+    }
+  ]
+})
+export class SelectLanguagesComponent implements ControlValueAccessor, OnInit {
+  @Input() maxLanguages: number
+
+  selectedLanguages: ItemSelectCheckboxValue[]
+  availableLanguages: SelectOptionsItem[] = []
+
+  allLanguagesGroup = $localize`All languages`
+
+  // Fix a bug on ng-select when we update items after we selected items
+  private toWrite: any
+  private loaded = false
+
+  constructor (
+    private server: ServerService
+  ) {
+
+  }
+
+  ngOnInit () {
+    this.server.getVideoLanguages()
+      .subscribe(
+        languages => {
+          this.availableLanguages = [ { label: $localize`Unknown language`, id: '_unknown', group: this.allLanguagesGroup } ]
+
+          this.availableLanguages = this.availableLanguages
+            .concat(languages.map(l => ({ label: l.label, id: l.id, group: this.allLanguagesGroup })))
+
+          this.loaded = true
+          this.writeValue(this.toWrite)
+        }
+      )
+  }
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (languages: ItemSelectCheckboxValue[]) {
+    if (!this.loaded) {
+      this.toWrite = languages
+      return
+    }
+
+    this.selectedLanguages = languages
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  onModelChange () {
+    this.propagateChange(this.selectedLanguages)
+  }
+}

+ 9 - 0
client/src/app/shared/shared-forms/shared-form.module.ts

@@ -15,9 +15,12 @@ import { PeertubeCheckboxComponent } from './peertube-checkbox.component'
 import { PreviewUploadComponent } from './preview-upload.component'
 import { ReactiveFileComponent } from './reactive-file.component'
 import {
+  SelectCategoriesComponent,
   SelectChannelComponent,
+  SelectCheckboxAllComponent,
   SelectCheckboxComponent,
   SelectCustomValueComponent,
+  SelectLanguagesComponent,
   SelectOptionsComponent,
   SelectTagsComponent
 } from './select'
@@ -52,6 +55,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
     SelectTagsComponent,
     SelectCheckboxComponent,
     SelectCustomValueComponent,
+    SelectLanguagesComponent,
+    SelectCategoriesComponent,
+    SelectCheckboxAllComponent,
 
     DynamicFormFieldComponent,
 
@@ -80,6 +86,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
     SelectTagsComponent,
     SelectCheckboxComponent,
     SelectCustomValueComponent,
+    SelectLanguagesComponent,
+    SelectCategoriesComponent,
+    SelectCheckboxAllComponent,
 
     DynamicFormFieldComponent,
 

+ 1 - 0
client/src/app/shared/shared-icons/global-icon.component.ts

@@ -71,6 +71,7 @@ const icons = {
   columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
   live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
   repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
+  'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default,
   'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
   codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
   award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default

+ 18 - 4
client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts

@@ -1,16 +1,19 @@
 import { fromEvent, Observable, Subscription } from 'rxjs'
 import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
 import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
+import { PeerTubeRouterService, RouterSetting } from '@app/core'
 
 @Directive({
   selector: '[myInfiniteScroller]'
 })
 export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
   @Input() percentLimit = 70
-  @Input() autoInit = false
   @Input() onItself = false
   @Input() dataObservable: Observable<any[]>
 
+  // Add angular state in query params to reuse the routed component
+  @Input() setAngularState: boolean
+
   @Output() nearOfBottom = new EventEmitter<void>()
 
   private decimalLimit = 0
@@ -20,7 +23,10 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
 
   private checkScroll = false
 
-  constructor (private el: ElementRef) {
+  constructor (
+    private peertubeRouter: PeerTubeRouterService,
+    private el: ElementRef
+  ) {
     this.decimalLimit = this.percentLimit / 100
   }
 
@@ -36,7 +42,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
   }
 
   ngOnInit () {
-    if (this.autoInit === true) return this.initialize()
+    this.initialize()
   }
 
   ngOnDestroy () {
@@ -67,7 +73,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
         filter(({ current }) => this.isScrollingDown(current)),
         filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
       )
-      .subscribe(() => this.nearOfBottom.emit())
+      .subscribe(() => {
+        if (this.setAngularState) this.setScrollRouteParams()
+
+        this.nearOfBottom.emit()
+      })
 
     if (this.dataObservable) {
       this.dataObservable
@@ -96,4 +106,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
     this.lastCurrentBottom = current
     return result
   }
+
+  private setScrollRouteParams () {
+    this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT)
+  }
 }

+ 1 - 2
client/src/app/shared/shared-main/feeds/feed.component.scss

@@ -7,12 +7,11 @@
   a {
     color: #000;
     display: block;
+    min-width: 100px;
   }
 }
 
 my-global-icon {
-  @include apply-svg-color(pvar(--mainForegroundColor));
-
   cursor: pointer;
   width: 100%;
 }

+ 14 - 9
client/src/app/shared/shared-main/misc/simple-search-input.component.html

@@ -1,13 +1,18 @@
 <div class="root">
-  <input
-    #ref
-    type="text"
-    [(ngModel)]="value"
-    (keyup.enter)="searchChange()"
-    [hidden]="!inputShown"
-    [name]="name"
-    [placeholder]="placeholder"
-  >
+  <div class="input-group has-feedback has-clear">
+    <input
+      #ref
+      type="text"
+      [(ngModel)]="value"
+      (keyup.enter)="sendSearch()"
+      [hidden]="!inputShown"
+      [name]="name"
+      [placeholder]="placeholder"
+    >
+
+    <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="onResetFilter()"></a>
+    <span class="sr-only" i18n>Clear filters</span>
+  </div>
 
   <my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon>
 

+ 5 - 8
client/src/app/shared/shared-main/misc/simple-search-input.component.scss

@@ -11,20 +11,17 @@ my-global-icon {
   height: 28px;
   width: 28px;
   cursor: pointer;
+  color: pvar(--mainColor);
 
   &:hover {
     color: pvar(--mainHoverColor);
   }
-
-  &[iconName=search] {
-    color: pvar(--mainForegroundColor);
-  }
-
-  &[iconName=cross] {
-    color: pvar(--mainForegroundColor);
-  }
 }
 
 input {
   @include peertube-input-text(200px);
+
+  &:focus {
+    box-shadow: 0 0 5px 0 #a5a5a5;
+  }
 }

+ 10 - 22
client/src/app/shared/shared-main/misc/simple-search-input.component.ts

@@ -1,7 +1,4 @@
-import { Subject } from 'rxjs'
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
 import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
 
 @Component({
   selector: 'my-simple-search-input',
@@ -22,23 +19,9 @@ export class SimpleSearchInputComponent implements OnInit {
   value = ''
   inputShown: boolean
 
-  private searchSubject = new Subject<string>()
-
-  constructor (
-    private router: Router,
-    private route: ActivatedRoute
-  ) {}
+  private hasAlreadySentSearch = false
 
   ngOnInit () {
-    this.searchSubject
-        .pipe(
-          debounceTime(400),
-          distinctUntilChanged()
-        )
-        .subscribe(value => this.searchChanged.emit(value))
-
-    this.searchSubject.next(this.value)
-
     if (this.isInputShown()) this.showInput(false)
   }
 
@@ -54,7 +37,7 @@ export class SimpleSearchInputComponent implements OnInit {
       return
     }
 
-    this.searchChange()
+    this.sendSearch()
   }
 
   showInput (focus = true) {
@@ -80,9 +63,14 @@ export class SimpleSearchInputComponent implements OnInit {
     this.hideInput()
   }
 
-  searchChange () {
-    this.router.navigate([ './search' ], { relativeTo: this.route })
+  sendSearch () {
+    this.hasAlreadySentSearch = true
+    this.searchChanged.emit(this.value)
+  }
+
+  onResetFilter () {
+    this.value = ''
 
-    this.searchSubject.next(this.value)
+    if (this.hasAlreadySentSearch) this.sendSearch()
   }
 }

+ 1 - 1
client/src/app/shared/shared-main/users/user-notifications.component.html

@@ -1,6 +1,6 @@
 <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
 
-<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
+<div class="notifications" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
 
     <ng-container [ngSwitch]="notification.type">

+ 40 - 70
client/src/app/shared/shared-main/video/video.service.ts

@@ -5,6 +5,7 @@ import { Injectable } from '@angular/core'
 import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
 import { objectToFormData } from '@app/helpers'
 import {
+  BooleanBothQuery,
   FeedFormat,
   NSFWPolicyType,
   ResultList,
@@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model'
 import { VideoEdit } from './video-edit.model'
 import { Video } from './video.model'
 
-export interface VideosProvider {
-  getVideos (parameters: {
-    videoPagination: ComponentPaginationLight
-    sort: VideoSortField
-    filter?: VideoFilter
-    categoryOneOf?: number[]
-    languageOneOf?: string[]
-    nsfwPolicy: NSFWPolicyType
-  }): Observable<ResultList<Video>>
+export type CommonVideoParams = {
+  videoPagination: ComponentPaginationLight
+  sort: VideoSortField
+  filter?: VideoFilter
+  categoryOneOf?: number[]
+  languageOneOf?: string[]
+  isLive?: boolean
+  skipCount?: boolean
+  // FIXME: remove?
+  nsfwPolicy?: NSFWPolicyType
+  nsfw?: BooleanBothQuery
 }
 
 @Injectable()
-export class VideoService implements VideosProvider {
+export class VideoService {
   static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
   static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
   static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
@@ -144,32 +147,16 @@ export class VideoService implements VideosProvider {
                )
   }
 
-  getAccountVideos (parameters: {
+  getAccountVideos (parameters: CommonVideoParams & {
     account: Pick<Account, 'nameWithHost'>
-    videoPagination: ComponentPaginationLight
-    sort: VideoSortField
-    nsfwPolicy?: NSFWPolicyType
-    videoFilter?: VideoFilter
     search?: string
   }): Observable<ResultList<Video>> {
-    const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters
-
-    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+    const { account, search } = parameters
 
     let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    if (nsfwPolicy) {
-      params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
-    }
-
-    if (videoFilter) {
-      params = params.set('filter', videoFilter)
-    }
+    params = this.buildCommonVideosParams({ params, ...parameters })
 
-    if (search) {
-      params = params.set('search', search)
-    }
+    if (search) params = params.set('search', search)
 
     return this.authHttp
                .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
@@ -179,27 +166,13 @@ export class VideoService implements VideosProvider {
                )
   }
 
-  getVideoChannelVideos (parameters: {
+  getVideoChannelVideos (parameters: CommonVideoParams & {
     videoChannel: Pick<VideoChannel, 'nameWithHost'>
-    videoPagination: ComponentPaginationLight
-    sort: VideoSortField
-    nsfwPolicy?: NSFWPolicyType
-    videoFilter?: VideoFilter
   }): Observable<ResultList<Video>> {
-    const { videoChannel, videoPagination, sort, nsfwPolicy, videoFilter } = parameters
-
-    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+    const { videoChannel } = parameters
 
     let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    if (nsfwPolicy) {
-      params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
-    }
-
-    if (videoFilter) {
-      params = params.set('filter', videoFilter)
-    }
+    params = this.buildCommonVideosParams({ params, ...parameters })
 
     return this.authHttp
                .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
@@ -209,30 +182,9 @@ export class VideoService implements VideosProvider {
                )
   }
 
-  getVideos (parameters: {
-    videoPagination: ComponentPaginationLight
-    sort: VideoSortField
-    filter?: VideoFilter
-    categoryOneOf?: number[]
-    languageOneOf?: string[]
-    isLive?: boolean
-    skipCount?: boolean
-    nsfwPolicy?: NSFWPolicyType
-  }): Observable<ResultList<Video>> {
-    const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive } = parameters
-
-    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
+  getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> {
     let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination, sort)
-
-    if (filter) params = params.set('filter', filter)
-    if (skipCount) params = params.set('skipCount', skipCount + '')
-
-    if (isLive) params = params.set('isLive', isLive)
-    if (nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
-    if (languageOneOf) this.restService.addArrayParams(params, 'languageOneOf', languageOneOf)
-    if (categoryOneOf) this.restService.addArrayParams(params, 'categoryOneOf', categoryOneOf)
+    params = this.buildCommonVideosParams({ params, ...parameters })
 
     return this.authHttp
                .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
@@ -421,4 +373,22 @@ export class VideoService implements VideosProvider {
                  catchError(err => this.restExtractor.handleError(err))
                )
   }
+
+  private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
+    const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options
+
+    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+    let newParams = this.restService.addRestGetParams(params, pagination, sort)
+
+    if (filter) newParams = newParams.set('filter', filter)
+    if (skipCount) newParams = newParams.set('skipCount', skipCount + '')
+
+    if (isLive) newParams = newParams.set('isLive', isLive)
+    if (nsfw) newParams = newParams.set('nsfw', nsfw)
+    if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
+    if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf)
+    if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf)
+
+    return newParams
+  }
 }

+ 6 - 14
client/src/app/shared/shared-search/advanced-search.model.ts

@@ -1,3 +1,4 @@
+import { intoArray } from '@app/helpers'
 import {
   BooleanBothQuery,
   BooleanQuery,
@@ -74,8 +75,8 @@ export class AdvancedSearch {
     this.categoryOneOf = options.categoryOneOf || undefined
     this.licenceOneOf = options.licenceOneOf || undefined
     this.languageOneOf = options.languageOneOf || undefined
-    this.tagsOneOf = this.intoArray(options.tagsOneOf)
-    this.tagsAllOf = this.intoArray(options.tagsAllOf)
+    this.tagsOneOf = intoArray(options.tagsOneOf)
+    this.tagsAllOf = intoArray(options.tagsAllOf)
     this.durationMin = parseInt(options.durationMin, 10)
     this.durationMax = parseInt(options.durationMax, 10)
 
@@ -150,9 +151,9 @@ export class AdvancedSearch {
       originallyPublishedStartDate: this.originallyPublishedStartDate,
       originallyPublishedEndDate: this.originallyPublishedEndDate,
       nsfw: this.nsfw,
-      categoryOneOf: this.intoArray(this.categoryOneOf),
-      licenceOneOf: this.intoArray(this.licenceOneOf),
-      languageOneOf: this.intoArray(this.languageOneOf),
+      categoryOneOf: intoArray(this.categoryOneOf),
+      licenceOneOf: intoArray(this.licenceOneOf),
+      languageOneOf: intoArray(this.languageOneOf),
       tagsOneOf: this.tagsOneOf,
       tagsAllOf: this.tagsAllOf,
       durationMin: this.durationMin,
@@ -198,13 +199,4 @@ export class AdvancedSearch {
 
     return true
   }
-
-  private intoArray (value: any) {
-    if (!value) return undefined
-    if (Array.isArray(value)) return value
-
-    if (typeof value === 'string') return value.split(',')
-
-    return [ value ]
-  }
 }

+ 1 - 6
client/src/app/shared/shared-user-settings/user-video-settings.component.html

@@ -30,12 +30,7 @@
     </my-help>
 
     <div>
-      <my-select-checkbox
-        formControlName="videoLanguages" [availableItems]="languageItems"
-        [selectableGroup]="true" [selectableGroupAsModel]="true"
-        i18n-placeholder placeholder="Add a new language"
-      >
-      </my-select-checkbox >
+      <my-select-languages formControlName="videoLanguages"></my-select-languages>
     </div>
   </div>
 

+ 1 - 1
client/src/app/shared/shared-user-settings/user-video-settings.component.scss

@@ -19,7 +19,7 @@ input[type=submit] {
   margin-bottom: 30px;
 }
 
-my-select-checkbox {
+my-select-languages {
   @include responsive-width(340px);
 
   display: block;

+ 22 - 71
client/src/app/shared/shared-user-settings/user-video-settings.component.ts

@@ -1,12 +1,11 @@
 import { pick } from 'lodash-es'
-import { forkJoin, Subject, Subscription } from 'rxjs'
+import { Subject, Subscription } from 'rxjs'
 import { first } from 'rxjs/operators'
 import { Component, Input, OnDestroy, OnInit } from '@angular/core'
 import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
-import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
 import { UserUpdateMe } from '@shared/models'
 import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { SelectOptionsItem } from '../../../types/select-options-item.model'
 
 @Component({
   selector: 'my-user-video-settings',
@@ -19,12 +18,9 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
   @Input() notifyOnUpdate = true
   @Input() userInformationLoaded: Subject<any>
 
-  languageItems: SelectOptionsItem[] = []
   defaultNSFWPolicy: NSFWPolicyType
   formValuesWatcher: Subscription
 
-  private allLanguagesGroup: string
-
   constructor (
     protected formValidatorService: FormValidatorService,
     private authService: AuthService,
@@ -36,8 +32,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
   }
 
   ngOnInit () {
-    this.allLanguagesGroup = $localize`All languages`
-
     this.buildForm({
       nsfwPolicy: null,
       webTorrentEnabled: null,
@@ -46,33 +40,23 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
       videoLanguages: null
     })
 
-    forkJoin([
-      this.serverService.getVideoLanguages(),
-      this.userInformationLoaded.pipe(first())
-    ]).subscribe(([ languages ]) => {
-      const group = this.allLanguagesGroup
-
-      this.languageItems = [ { label: $localize`Unknown language`, id: '_unknown', group } ]
-      this.languageItems = this.languageItems
-                               .concat(languages.map(l => ({ label: l.label, id: l.id, group })))
-
-      const videoLanguages: ItemSelectCheckboxValue[] = this.user.videoLanguages
-        ? this.user.videoLanguages.map(l => ({ id: l }))
-        : [ { group } ]
-
-      const serverConfig = this.serverService.getHTMLConfig()
-      this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
-
-      this.form.patchValue({
-        nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy,
-        webTorrentEnabled: this.user.webTorrentEnabled,
-        autoPlayVideo: this.user.autoPlayVideo === true,
-        autoPlayNextVideo: this.user.autoPlayNextVideo,
-        videoLanguages
-      })
-
-      if (this.reactiveUpdate) this.handleReactiveUpdate()
-    })
+    this.userInformationLoaded.pipe(first())
+      .subscribe(
+        () => {
+          const serverConfig = this.serverService.getHTMLConfig()
+          this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
+
+          this.form.patchValue({
+            nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy,
+            webTorrentEnabled: this.user.webTorrentEnabled,
+            autoPlayVideo: this.user.autoPlayVideo === true,
+            autoPlayNextVideo: this.user.autoPlayNextVideo,
+            videoLanguages: this.user.videoLanguages
+          })
+
+          if (this.reactiveUpdate) this.handleReactiveUpdate()
+        }
+      )
   }
 
   ngOnDestroy () {
@@ -85,23 +69,15 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
     const autoPlayVideo = this.form.value['autoPlayVideo']
     const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
 
-    let videoLanguagesForm = this.form.value['videoLanguages']
+    const videoLanguages = this.form.value['videoLanguages']
 
-    if (Array.isArray(videoLanguagesForm)) {
-      if (videoLanguagesForm.length > 20) {
+    if (Array.isArray(videoLanguages)) {
+      if (videoLanguages.length > 20) {
         this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`)
         return
       }
-
-      // Automatically use "All languages" if the user did not select any language
-      if (videoLanguagesForm.length === 0) {
-        videoLanguagesForm = [ this.allLanguagesGroup ]
-        this.form.patchValue({ videoLanguages: [ { group: this.allLanguagesGroup } ] })
-      }
     }
 
-    const videoLanguages = this.buildLanguagesFromForm(videoLanguagesForm)
-
     let details: UserUpdateMe = {
       nsfwPolicy,
       webTorrentEnabled,
@@ -123,31 +99,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
     return this.updateAnonymousProfile(details)
   }
 
-  private buildLanguagesFromForm (videoLanguages: ItemSelectCheckboxValue[]) {
-    if (!Array.isArray(videoLanguages)) return undefined
-
-    // null means "All"
-    if (videoLanguages.length === this.languageItems.length) return null
-
-    if (videoLanguages.length === 1) {
-      const videoLanguage = videoLanguages[0]
-
-      if (typeof videoLanguage === 'string') {
-        if (videoLanguage === this.allLanguagesGroup) return null
-      } else {
-        if (videoLanguage.group === this.allLanguagesGroup) return null
-      }
-    }
-
-    return videoLanguages.map(l => {
-      if (typeof l === 'string') return l
-
-      if (l.group) return l.group
-
-      return l.id + ''
-    })
-  }
-
   private handleReactiveUpdate () {
     let oldForm = { ...this.form.value }
 

+ 0 - 404
client/src/app/shared/shared-video-miniature/abstract-video-list.ts

@@ -1,404 +0,0 @@
-import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs'
-import { debounceTime, switchMap, tap } from 'rxjs/operators'
-import {
-  AfterContentInit,
-  ComponentFactoryResolver,
-  Directive,
-  Injector,
-  OnDestroy,
-  OnInit,
-  Type,
-  ViewChild,
-  ViewContainerRef
-} from '@angular/core'
-import { ActivatedRoute, Params, Router } from '@angular/router'
-import {
-  AuthService,
-  ComponentPaginationLight,
-  LocalStorageService,
-  Notifier,
-  ScreenService,
-  ServerService,
-  User,
-  UserService
-} from '@app/core'
-import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
-import { GlobalIconName } from '@app/shared/shared-icons'
-import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
-import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { Syndication, Video } from '../shared-main'
-import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component'
-import { MiniatureDisplayOptions } from './video-miniature.component'
-
-enum GroupDate {
-  UNKNOWN = 0,
-  TODAY = 1,
-  YESTERDAY = 2,
-  THIS_WEEK = 3,
-  THIS_MONTH = 4,
-  LAST_MONTH = 5,
-  OLDER = 6
-}
-
-@Directive()
-// eslint-disable-next-line @angular-eslint/directive-class-suffix
-export abstract class AbstractVideoList implements OnInit, OnDestroy, AfterContentInit, DisableForReuseHook {
-  @ViewChild('videoListHeader', { static: true, read: ViewContainerRef }) videoListHeader: ViewContainerRef
-
-  HeaderComponent: Type<GenericHeaderComponent> = VideoListHeaderComponent
-  headerComponentInjector: Injector
-
-  pagination: ComponentPaginationLight = {
-    currentPage: 1,
-    itemsPerPage: 25
-  }
-  sort: VideoSortField = '-publishedAt'
-
-  categoryOneOf?: number[]
-  languageOneOf?: string[]
-  nsfwPolicy?: NSFWPolicyType
-  defaultSort: VideoSortField = '-publishedAt'
-
-  syndicationItems: Syndication[] = []
-
-  loadOnInit = true
-  loadUserVideoPreferences = false
-
-  displayModerationBlock = false
-  titleTooltip: string
-  displayVideoActions = true
-  groupByDate = false
-
-  videos: Video[] = []
-  hasDoneFirstQuery = false
-  disabled = false
-
-  displayOptions: MiniatureDisplayOptions = {
-    date: true,
-    views: true,
-    by: true,
-    avatar: false,
-    privacyLabel: true,
-    privacyText: false,
-    state: false,
-    blacklistInfo: false
-  }
-
-  actions: {
-    iconName: GlobalIconName
-    label: string
-    justIcon?: boolean
-    routerLink?: string
-    href?: string
-    click?: (e: Event) => void
-  }[] = []
-
-  onDataSubject = new Subject<any[]>()
-
-  userMiniature: User
-
-  protected onUserLoadedSubject = new ReplaySubject<void>(1)
-
-  protected serverConfig: HTMLServerConfig
-
-  protected abstract notifier: Notifier
-  protected abstract authService: AuthService
-  protected abstract userService: UserService
-  protected abstract route: ActivatedRoute
-  protected abstract serverService: ServerService
-  protected abstract screenService: ScreenService
-  protected abstract storageService: LocalStorageService
-  protected abstract router: Router
-  protected abstract cfr: ComponentFactoryResolver
-  abstract titlePage: string
-
-  private resizeSubscription: Subscription
-  private angularState: number
-
-  private groupedDateLabels: { [id in GroupDate]: string }
-  private groupedDates: { [id: number]: GroupDate } = {}
-
-  private lastQueryLength: number
-
-  abstract getVideosObservable (page: number): Observable<{ data: Video[] }>
-
-  abstract generateSyndicationList (): void
-
-  ngOnInit () {
-    this.serverConfig = this.serverService.getHTMLConfig()
-
-    this.groupedDateLabels = {
-      [GroupDate.UNKNOWN]: null,
-      [GroupDate.TODAY]: $localize`Today`,
-      [GroupDate.YESTERDAY]: $localize`Yesterday`,
-      [GroupDate.THIS_WEEK]: $localize`This week`,
-      [GroupDate.THIS_MONTH]: $localize`This month`,
-      [GroupDate.LAST_MONTH]: $localize`Last month`,
-      [GroupDate.OLDER]: $localize`Older`
-    }
-
-    // Subscribe to route changes
-    const routeParams = this.route.snapshot.queryParams
-    this.loadRouteParams(routeParams)
-
-    this.resizeSubscription = fromEvent(window, 'resize')
-      .pipe(debounceTime(500))
-      .subscribe(() => this.calcPageSizes())
-
-    this.calcPageSizes()
-
-    const loadUserObservable = this.loadUserAndSettings()
-    loadUserObservable.subscribe(() => {
-      this.onUserLoadedSubject.next()
-
-      if (this.loadOnInit === true) this.loadMoreVideos()
-    })
-
-    this.userService.listenAnonymousUpdate()
-      .pipe(switchMap(() => this.loadUserAndSettings()))
-      .subscribe(() => {
-        if (this.hasDoneFirstQuery) this.reloadVideos()
-      })
-
-    // Display avatar in mobile view
-    if (this.screenService.isInMobileView()) {
-      this.displayOptions.avatar = true
-    }
-  }
-
-  ngOnDestroy () {
-    if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
-  }
-
-  ngAfterContentInit () {
-    if (this.videoListHeader) {
-      // some components don't use the header: they use their own template, like my-history.component.html
-      this.setHeader(this.HeaderComponent, this.headerComponentInjector)
-    }
-  }
-
-  disableForReuse () {
-    this.disabled = true
-  }
-
-  enabledForReuse () {
-    this.disabled = false
-  }
-
-  videoById (index: number, video: Video) {
-    return video.id
-  }
-
-  onNearOfBottom () {
-    if (this.disabled) return
-
-    // No more results
-    if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
-
-    this.pagination.currentPage += 1
-
-    this.setScrollRouteParams()
-
-    this.loadMoreVideos()
-  }
-
-  loadMoreVideos (reset = false) {
-    this.getVideosObservable(this.pagination.currentPage)
-      .subscribe({
-        next: ({ data }) => {
-          this.hasDoneFirstQuery = true
-          this.lastQueryLength = data.length
-
-          if (reset) this.videos = []
-          this.videos = this.videos.concat(data)
-
-          if (this.groupByDate) this.buildGroupedDateLabels()
-
-          this.onMoreVideos()
-
-          this.onDataSubject.next(data)
-        },
-
-        error: err => {
-          const message = $localize`Cannot load more videos. Try again later.`
-
-          console.error(message, { err })
-          this.notifier.error(message)
-        }
-      })
-  }
-
-  reloadVideos () {
-    this.pagination.currentPage = 1
-    this.loadMoreVideos(true)
-  }
-
-  removeVideoFromArray (video: Video) {
-    this.videos = this.videos.filter(v => v.id !== video.id)
-  }
-
-  buildGroupedDateLabels () {
-    let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
-
-    const periods = [
-      {
-        value: GroupDate.TODAY,
-        validator: (d: Date) => isToday(d)
-      },
-      {
-        value: GroupDate.YESTERDAY,
-        validator: (d: Date) => isYesterday(d)
-      },
-      {
-        value: GroupDate.THIS_WEEK,
-        validator: (d: Date) => isLastWeek(d)
-      },
-      {
-        value: GroupDate.THIS_MONTH,
-        validator: (d: Date) => isThisMonth(d)
-      },
-      {
-        value: GroupDate.LAST_MONTH,
-        validator: (d: Date) => isLastMonth(d)
-      },
-      {
-        value: GroupDate.OLDER,
-        validator: () => true
-      }
-    ]
-
-    for (const video of this.videos) {
-      const publishedDate = video.publishedAt
-
-      for (let i = 0; i < periods.length; i++) {
-        const period = periods[i]
-
-        if (currentGroupedDate <= period.value && period.validator(publishedDate)) {
-
-          if (currentGroupedDate !== period.value) {
-            currentGroupedDate = period.value
-            this.groupedDates[video.id] = currentGroupedDate
-          }
-
-          break
-        }
-      }
-    }
-  }
-
-  getCurrentGroupedDateLabel (video: Video) {
-    if (this.groupByDate === false) return undefined
-
-    return this.groupedDateLabels[this.groupedDates[video.id]]
-  }
-
-  toggleModerationDisplay () {
-    throw new Error('toggleModerationDisplay ' + $localize`function is not implemented`)
-  }
-
-  setHeader (
-    t: Type<any> = this.HeaderComponent,
-    i: Injector = this.headerComponentInjector
-  ) {
-    const injector = i || Injector.create({
-      providers: [ {
-        provide: 'data',
-        useValue: {
-          titlePage: this.titlePage,
-          titleTooltip: this.titleTooltip
-        }
-      } ]
-    })
-    const viewContainerRef = this.videoListHeader
-    viewContainerRef.clear()
-
-    const componentFactory = this.cfr.resolveComponentFactory(t)
-    viewContainerRef.createComponent(componentFactory, 0, injector)
-  }
-
-  // Can be redefined by child
-  displayAsRow () {
-    return false
-  }
-
-  // On videos hook for children that want to do something
-  protected onMoreVideos () { /* empty */ }
-
-  protected load () { /* empty */ }
-
-  // Hook if the page has custom route params
-  protected loadPageRouteParams (_queryParams: Params) { /* empty */ }
-
-  protected loadRouteParams (queryParams: Params) {
-    this.sort = queryParams['sort'] as VideoSortField || this.defaultSort
-    this.categoryOneOf = queryParams['categoryOneOf']
-    this.angularState = queryParams['a-state']
-
-    this.loadPageRouteParams(queryParams)
-  }
-
-  protected buildLocalFilter (existing: VideoFilter, base: VideoFilter) {
-    if (base === 'local') {
-      return existing === 'local'
-        ? 'all-local' as 'all-local'
-        : 'local' as 'local'
-    }
-
-    return existing === 'all'
-      ? null
-      : 'all'
-  }
-
-  protected enableAllFilterIfPossible () {
-    if (!this.authService.isLoggedIn()) return
-
-    this.authService.userInformationLoaded
-      .subscribe(() => {
-        const user = this.authService.getUser()
-        this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS)
-      })
-  }
-
-  private calcPageSizes () {
-    if (this.screenService.isInMobileView()) {
-      this.pagination.itemsPerPage = 5
-    }
-  }
-
-  private setScrollRouteParams () {
-    // Already set
-    if (this.angularState) return
-
-    this.angularState = 42
-
-    const queryParams = {
-      'a-state': this.angularState,
-      categoryOneOf: this.categoryOneOf
-    }
-
-    let path = this.getUrlWithoutParams()
-    if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute
-
-    this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
-  }
-
-  private loadUserAndSettings () {
-    return this.userService.getAnonymousOrLoggedUser()
-      .pipe(tap(user => {
-        this.userMiniature = user
-
-        if (!this.loadUserVideoPreferences) return
-
-        this.languageOneOf = user.videoLanguages
-        this.nsfwPolicy = user.nsfwPolicy
-      }))
-  }
-
-  private getUrlWithoutParams () {
-    const urlTree = this.router.parseUrl(this.router.url)
-    urlTree.queryParams = {}
-
-    return urlTree.toString()
-  }
-}

+ 3 - 2
client/src/app/shared/shared-video-miniature/index.ts

@@ -1,7 +1,8 @@
-export * from './abstract-video-list'
 export * from './video-actions-dropdown.component'
 export * from './video-download.component'
+export * from './video-filters-header.component'
+export * from './video-filters.model'
 export * from './video-miniature.component'
+export * from './videos-list.component'
 export * from './videos-selection.component'
-export * from './video-list-header.component'
 export * from './shared-video-miniature.module'

+ 9 - 5
client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts

@@ -1,19 +1,20 @@
 
 import { NgModule } from '@angular/core'
+import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
 import { SharedFormModule } from '../shared-forms'
 import { SharedGlobalIconModule } from '../shared-icons'
 import { SharedMainModule } from '../shared-main/shared-main.module'
 import { SharedModerationModule } from '../shared-moderation'
-import { SharedVideoModule } from '../shared-video'
 import { SharedThumbnailModule } from '../shared-thumbnail'
+import { SharedVideoModule } from '../shared-video'
 import { SharedVideoLiveModule } from '../shared-video-live'
 import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
 import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
 import { VideoDownloadComponent } from './video-download.component'
+import { VideoFiltersHeaderComponent } from './video-filters-header.component'
 import { VideoMiniatureComponent } from './video-miniature.component'
+import { VideosListComponent } from './videos-list.component'
 import { VideosSelectionComponent } from './videos-selection.component'
-import { VideoListHeaderComponent } from './video-list-header.component'
-import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
 
 @NgModule({
   imports: [
@@ -33,14 +34,17 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image
     VideoDownloadComponent,
     VideoMiniatureComponent,
     VideosSelectionComponent,
-    VideoListHeaderComponent
+    VideoFiltersHeaderComponent,
+    VideosListComponent
   ],
 
   exports: [
     VideoActionsDropdownComponent,
     VideoDownloadComponent,
     VideoMiniatureComponent,
-    VideosSelectionComponent
+    VideosSelectionComponent,
+    VideoFiltersHeaderComponent,
+    VideosListComponent
   ],
 
   providers: [ ]

+ 0 - 1
client/src/app/shared/shared-video-miniature/video-download.component.scss

@@ -39,7 +39,6 @@
   margin-top: 20px;
 
   .peertube-radio-container {
-    @include peertube-radio-container;
     @include margin-right(30px);
 
     display: inline-block;

+ 131 - 0
client/src/app/shared/shared-video-miniature/video-filters-header.component.html

@@ -0,0 +1,131 @@
+<ng-template #updateSettings let-fragment>
+  <div class="label-description text-muted" i18n>
+    Update
+    <a routerLink="/my-account/settings" [fragment]="fragment">
+      <span (click)="onAccountSettingsClick($event)">your settings</span>
+    </a
+  ></div>
+</ng-template>
+
+
+<div class="root" [formGroup]="form">
+
+  <div class="first-row">
+    <div class="active-filters">
+      <div
+        class="pastille filters-toggle" (click)="areFiltersCollapsed = !areFiltersCollapsed" role="button"
+        [attr.aria-expanded]="!areFiltersCollapsed" aria-controls="collapseBasic"
+        [ngClass]="{ active: !areFiltersCollapsed }"
+      >
+        <ng-container i18n *ngIf="areFiltersCollapsed">More filters</ng-container>
+        <ng-container i18n *ngIf="!areFiltersCollapsed">Less filters</ng-container>
+
+        <my-global-icon iconName="chevrons-up"></my-global-icon>
+      </div>
+
+      <div
+        *ngFor="let activeFilter of filters.getActiveFilters()" (click)="resetFilter(activeFilter.key, activeFilter.canRemove)"
+        class="active-filter pastille" [ngClass]="{ 'can-remove': activeFilter.canRemove }" [title]="getFilterTitle(activeFilter.canRemove)"
+      >
+        <span>
+          {{ activeFilter.label }}
+
+          <ng-container *ngIf="activeFilter.value">: {{ activeFilter.value }}</ng-container>
+        </span>
+
+        <my-global-icon *ngIf="activeFilter.canRemove" iconName="cross"></my-global-icon>
+      </div>
+    </div>
+
+    <ng-select
+      class="sort"
+      formControlName="sort"
+      [clearable]="false"
+      [searchable]="false"
+    >
+      <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option>
+
+      <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Views"</strong></ng-option>
+      <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option>
+      <ng-option i18n *ngIf="isTrendingSortEnabled('best')" value="-best">Sort by <strong>"Best"</strong></ng-option>
+      <ng-option i18n *ngIf="isTrendingSortEnabled('most-liked')" value="-likes">Sort by <strong>"Likes"</strong></ng-option>
+    </ng-select>
+
+  </div>
+
+  <div class="collapse-transition" [ngbCollapse]="areFiltersCollapsed">
+    <div class="filters">
+      <div class="form-group">
+        <label class="with-description" for="languageOneOf" i18n>Languages:</label>
+        <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-languages-subtitles' }"></ng-template>
+
+        <my-select-languages [maxLanguages]="20" formControlName="languageOneOf"></my-select-languages>
+      </div>
+
+      <div class="form-group">
+        <label class="with-description" for="nsfw" i18n>Sensitive content:</label>
+        <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-sensitive-content-policy' }"></ng-template>
+
+        <div class="peertube-radio-container">
+          <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwBoth" i18n-value value="both" />
+          <label for="nsfwBoth">{{ filters.getNSFWDisplayLabel() }}</label>
+        </div>
+
+        <div class="peertube-radio-container">
+          <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwFalse" i18n-value value="false" />
+          <label for="nsfwFalse" i18n>Hide</label>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="scope" i18n>Scope:</label>
+
+        <div class="peertube-radio-container">
+          <input formControlName="scope" type="radio" name="scope" id="scopeLocal" i18n-value value="local" />
+          <label for="scopeLocal" i18n>Local videos (this instance)</label>
+        </div>
+
+        <div class="peertube-radio-container">
+          <input formControlName="scope" type="radio" name="scope" id="scopeFederated" i18n-value value="federated" />
+          <label for="scopeFederated" i18n>Federated videos (this instance + followed instances)</label>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="type" i18n>Type:</label>
+
+        <div class="peertube-radio-container">
+          <input formControlName="live" type="radio" name="live" id="liveBoth" i18n-value value="both" />
+          <label for="liveBoth" i18n>VOD & Live videos</label>
+        </div>
+
+        <div class="peertube-radio-container">
+          <input formControlName="live" type="radio" name="live" id="liveTrue" i18n-value value="true" />
+          <label for="liveTrue" i18n>Live videos</label>
+        </div>
+
+        <div class="peertube-radio-container">
+          <input formControlName="live" type="radio" name="live" id="liveFalse" i18n-value value="false" />
+          <label for="liveFalse" i18n>VOD videos</label>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="categoryOneOf" i18n>Categories:</label>
+
+        <my-select-categories formControlName="categoryOneOf"></my-select-categories>
+      </div>
+
+      <div class="form-group" *ngIf="canSeeAllVideos()">
+        <label for="allVideos" i18n>Moderation:</label>
+
+        <my-peertube-checkbox
+          formControlName="allVideos"
+          inputName="allVideos"
+          i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
+        ></my-peertube-checkbox>
+      </div>
+    </div>
+  </div>
+
+</div>

+ 139 - 0
client/src/app/shared/shared-video-miniature/video-filters-header.component.scss

@@ -0,0 +1,139 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+.root {
+  margin-bottom: 45px;
+  font-size: 15px;
+}
+
+.first-row {
+  display: flex;
+  justify-content: space-between;
+}
+
+.active-filters {
+  display: flex;
+  flex-wrap: wrap;
+}
+
+.filters {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 25px;
+
+  border-bottom: 1px solid $separator-border-color;
+
+  input[type=radio] + label {
+    font-weight: $font-regular;
+  }
+
+  .form-group > label:first-child {
+    display: block;
+
+    &.with-description {
+      margin-bottom: 0;
+    }
+
+    &:not(.with-description) {
+      margin-bottom: 10px;
+    }
+  }
+
+  .form-group {
+    @include margin-right(30px);
+  }
+}
+
+.pastille {
+  @include margin-right(15px);
+
+  border-radius: 24px;
+  padding: 4px 15px;
+  font-size: 16px;
+  margin-bottom: 15px;
+  cursor: pointer;
+}
+
+.filters-toggle {
+  border: 2px solid pvar(--mainForegroundColor);
+
+  my-global-icon {
+    @include margin-left(5px);
+  }
+
+  &.active my-global-icon {
+    position: relative;
+    top: -1px;
+  }
+
+  &:not(.active) {
+    my-global-icon ::ng-deep svg {
+      transform: rotate(180deg);
+    }
+  }
+}
+
+// Than have an icon
+.filters-toggle,
+.active-filter.can-remove {
+  padding: 4px 11px 4px 15px;
+}
+
+.active-filter {
+  background-color: pvar(--channelBackgroundColor);
+  display: flex;
+  align-items: center;
+
+  &:not(.can-remove) {
+    cursor: default;
+  }
+
+  &.can-remove:hover {
+    opacity: 0.9;
+  }
+
+  my-global-icon {
+    @include margin-left(10px);
+
+    width: 16px;
+    color: pvar(--greyForegroundColor);
+  }
+}
+
+.sort {
+  min-width: 200px;
+  max-width: 300px;
+  height: min-content;
+
+  ::ng-deep {
+    .ng-select-container {
+      height: 33px !important;
+    }
+
+    .ng-value strong {
+      @include margin-left(5px);
+    }
+  }
+}
+
+my-select-languages,
+my-select-categories {
+  width: 300px;
+  display: inline-block;
+}
+
+.label-description {
+  font-size: 12px;
+  font-style: italic;
+  margin-bottom: 10px;
+
+  a {
+    color: pvar(--mainColor);
+  }
+}
+
+@media screen and (max-width: $small-view) {
+  .first-row {
+    flex-direction: column;
+  }
+}

+ 119 - 0
client/src/app/shared/shared-video-miniature/video-filters-header.component.ts

@@ -0,0 +1,119 @@
+import * as debug from 'debug'
+import { Subscription } from 'rxjs'
+import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
+import { FormBuilder, FormGroup } from '@angular/forms'
+import { AuthService } from '@app/core'
+import { ServerService } from '@app/core/server/server.service'
+import { UserRight } from '@shared/models'
+import { NSFWPolicyType } from '@shared/models/videos'
+import { PeertubeModalService } from '../shared-main'
+import { VideoFilters } from './video-filters.model'
+
+const logger = debug('peertube:videos:VideoFiltersHeaderComponent')
+
+@Component({
+  selector: 'my-video-filters-header',
+  styleUrls: [ './video-filters-header.component.scss' ],
+  templateUrl: './video-filters-header.component.html'
+})
+export class VideoFiltersHeaderComponent implements OnInit, OnDestroy {
+  @Input() filters: VideoFilters
+
+  @Input() displayModerationBlock = false
+
+  @Input() defaultSort = '-publishedAt'
+  @Input() nsfwPolicy: NSFWPolicyType
+
+  @Output() filtersChanged = new EventEmitter()
+
+  areFiltersCollapsed = true
+
+  form: FormGroup
+
+  private routeSub: Subscription
+
+  constructor (
+    private auth: AuthService,
+    private serverService: ServerService,
+    private fb: FormBuilder,
+    private modalService: PeertubeModalService
+  ) {
+  }
+
+  ngOnInit () {
+    this.form = this.fb.group({
+      sort: [ '' ],
+      nsfw: [ '' ],
+      languageOneOf: [ '' ],
+      categoryOneOf: [ '' ],
+      scope: [ '' ],
+      allVideos: [ '' ],
+      live: [ '' ]
+    })
+
+    this.patchForm(false)
+
+    this.filters.onChange(() => {
+      this.patchForm(false)
+    })
+
+    this.form.valueChanges.subscribe(values => {
+      logger('Loading values from form: %O', values)
+
+      this.filters.load(values)
+      this.filtersChanged.emit()
+    })
+  }
+
+  ngOnDestroy () {
+    if (this.routeSub) this.routeSub.unsubscribe()
+  }
+
+  canSeeAllVideos () {
+    if (!this.auth.isLoggedIn()) return false
+    if (!this.displayModerationBlock) return false
+
+    return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
+  }
+
+  isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'best' | 'most-liked') {
+    const serverConfig = this.serverService.getHTMLConfig()
+
+    const enabled = serverConfig.trending.videos.algorithms.enabled.includes(sort)
+
+    // Best is adapted from the user
+    if (sort === 'best') return enabled && this.auth.isLoggedIn()
+
+    return enabled
+  }
+
+  resetFilter (key: string, canRemove: boolean) {
+    if (!canRemove) return
+
+    this.filters.reset(key)
+    this.patchForm(false)
+    this.filtersChanged.emit()
+  }
+
+  getFilterTitle (canRemove: boolean) {
+    if (canRemove) return $localize`Remove this filter`
+
+    return ''
+  }
+
+  onAccountSettingsClick (event: Event) {
+    if (this.auth.isLoggedIn()) return
+
+    event.preventDefault()
+    event.stopPropagation()
+
+    this.modalService.openQuickSettingsSubject.next()
+  }
+
+  private patchForm (emitEvent: boolean) {
+    const defaultValues = this.filters.toFormObject()
+    this.form.patchValue(defaultValues, { emitEvent })
+
+    logger('Patched form: %O', defaultValues)
+  }
+}

+ 240 - 0
client/src/app/shared/shared-video-miniature/video-filters.model.ts

@@ -0,0 +1,240 @@
+import { intoArray, toBoolean } from '@app/helpers'
+import { AttributesOnly } from '@shared/core-utils'
+import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models'
+
+type VideoFiltersKeys = {
+  [ id in keyof AttributesOnly<VideoFilters> ]: any
+}
+
+export type VideoFilterScope = 'local' | 'federated'
+
+export class VideoFilters {
+  sort: VideoSortField
+  nsfw: BooleanBothQuery
+
+  languageOneOf: string[]
+  categoryOneOf: number[]
+
+  scope: VideoFilterScope
+  allVideos: boolean
+
+  live: BooleanBothQuery
+
+  search: string
+
+  private defaultValues = new Map<keyof VideoFilters, any>([
+    [ 'sort', '-publishedAt' ],
+    [ 'nsfw', 'false' ],
+    [ 'languageOneOf', undefined ],
+    [ 'categoryOneOf', undefined ],
+    [ 'scope', 'federated' ],
+    [ 'allVideos', false ],
+    [ 'live', 'both' ]
+  ])
+
+  private activeFilters: { key: string, canRemove: boolean, label: string, value?: string }[] = []
+  private defaultNSFWPolicy: NSFWPolicyType
+
+  private onChangeCallbacks: Array<() => void> = []
+  private oldFormObjectString: string
+
+  constructor (defaultSort: string, defaultScope: VideoFilterScope) {
+    this.setDefaultSort(defaultSort)
+    this.setDefaultScope(defaultScope)
+
+    this.reset()
+  }
+
+  onChange (cb: () => void) {
+    this.onChangeCallbacks.push(cb)
+  }
+
+  triggerChange () {
+    // Don't run on change if the values did not change
+    const currentFormObjectString = JSON.stringify(this.toFormObject())
+    if (this.oldFormObjectString && currentFormObjectString === this.oldFormObjectString) return
+
+    this.oldFormObjectString = currentFormObjectString
+
+    for (const cb of this.onChangeCallbacks) {
+      cb()
+    }
+  }
+
+  setDefaultScope (scope: VideoFilterScope) {
+    this.defaultValues.set('scope', scope)
+  }
+
+  setDefaultSort (sort: string) {
+    this.defaultValues.set('sort', sort)
+  }
+
+  setNSFWPolicy (nsfwPolicy: NSFWPolicyType) {
+    this.updateDefaultNSFW(nsfwPolicy)
+  }
+
+  reset (specificKey?: string) {
+    for (const [ key, value ] of this.defaultValues) {
+      if (specificKey && specificKey !== key) continue
+
+      // FIXME: typings
+      this[key as any] = value
+    }
+
+    this.buildActiveFilters()
+  }
+
+  load (obj: Partial<AttributesOnly<VideoFilters>>) {
+    if (obj.sort !== undefined) this.sort = obj.sort
+
+    if (obj.nsfw !== undefined) this.nsfw = obj.nsfw
+
+    if (obj.languageOneOf !== undefined) this.languageOneOf = intoArray(obj.languageOneOf)
+    if (obj.categoryOneOf !== undefined) this.categoryOneOf = intoArray(obj.categoryOneOf)
+
+    if (obj.scope !== undefined) this.scope = obj.scope
+    if (obj.allVideos !== undefined) this.allVideos = toBoolean(obj.allVideos)
+
+    if (obj.live !== undefined) this.live = obj.live
+
+    if (obj.search !== undefined) this.search = obj.search
+
+    this.buildActiveFilters()
+  }
+
+  buildActiveFilters () {
+    this.activeFilters = []
+
+    this.activeFilters.push({
+      key: 'nsfw',
+      canRemove: false,
+      label: $localize`Sensitive content`,
+      value: this.getNSFWValue()
+    })
+
+    this.activeFilters.push({
+      key: 'scope',
+      canRemove: false,
+      label: $localize`Scope`,
+      value: this.scope === 'federated'
+        ? $localize`Federated`
+        : $localize`Local`
+    })
+
+    if (this.languageOneOf && this.languageOneOf.length !== 0) {
+      this.activeFilters.push({
+        key: 'languageOneOf',
+        canRemove: true,
+        label: $localize`Languages`,
+        value: this.languageOneOf.map(l => l.toUpperCase()).join(', ')
+      })
+    }
+
+    if (this.categoryOneOf && this.categoryOneOf.length !== 0) {
+      this.activeFilters.push({
+        key: 'categoryOneOf',
+        canRemove: true,
+        label: $localize`Categories`,
+        value: this.categoryOneOf.join(', ')
+      })
+    }
+
+    if (this.allVideos) {
+      this.activeFilters.push({
+        key: 'allVideos',
+        canRemove: true,
+        label: $localize`All videos`
+      })
+    }
+
+    if (this.live === 'true') {
+      this.activeFilters.push({
+        key: 'live',
+        canRemove: true,
+        label: $localize`Live videos`
+      })
+    } else if (this.live === 'false') {
+      this.activeFilters.push({
+        key: 'live',
+        canRemove: true,
+        label: $localize`VOD videos`
+      })
+    }
+  }
+
+  getActiveFilters () {
+    return this.activeFilters
+  }
+
+  toFormObject (): VideoFiltersKeys {
+    const result: Partial<VideoFiltersKeys> = {}
+
+    for (const [ key ] of this.defaultValues) {
+      result[key] = this[key]
+    }
+
+    return result as VideoFiltersKeys
+  }
+
+  toUrlObject () {
+    const result: { [ id: string ]: any } = {}
+
+    for (const [ key, defaultValue ] of this.defaultValues) {
+      if (this[key] !== defaultValue) {
+        result[key] = this[key]
+      }
+    }
+
+    return result
+  }
+
+  toVideosAPIObject () {
+    let filter: VideoFilter
+
+    if (this.scope === 'local' && this.allVideos) {
+      filter = 'all-local'
+    } else if (this.scope === 'federated' && this.allVideos) {
+      filter = 'all'
+    } else if (this.scope === 'local') {
+      filter = 'local'
+    }
+
+    let isLive: boolean
+    if (this.live === 'true') isLive = true
+    else if (this.live === 'false') isLive = false
+
+    return {
+      sort: this.sort,
+      nsfw: this.nsfw,
+      languageOneOf: this.languageOneOf,
+      categoryOneOf: this.categoryOneOf,
+      search: this.search,
+      filter,
+      isLive
+    }
+  }
+
+  getNSFWDisplayLabel () {
+    if (this.defaultNSFWPolicy === 'blur') return $localize`Blurred`
+
+    return $localize`Displayed`
+  }
+
+  private getNSFWValue () {
+    if (this.nsfw === 'false') return $localize`hidden`
+    if (this.defaultNSFWPolicy === 'blur') return $localize`blurred`
+
+    return $localize`displayed`
+  }
+
+  private updateDefaultNSFW (nsfwPolicy: NSFWPolicyType) {
+    const nsfw = nsfwPolicy === 'do_not_list'
+      ? 'false'
+      : 'both'
+
+    this.defaultValues.set('nsfw', nsfw)
+    this.defaultNSFWPolicy = nsfwPolicy
+
+    this.reset('nsfw')
+  }
+}

+ 0 - 5
client/src/app/shared/shared-video-miniature/video-list-header.component.html

@@ -1,5 +0,0 @@
-<h1 class="title-page title-page-single">
-  <div placement="bottom" [ngbTooltip]="data.titleTooltip" container="body">
-    {{ data.titlePage }}
-  </div>
-</h1>

+ 0 - 22
client/src/app/shared/shared-video-miniature/video-list-header.component.ts

@@ -1,22 +0,0 @@
-import { Component, Inject, ViewEncapsulation } from '@angular/core'
-
-export interface GenericHeaderData {
-  titlePage: string
-  titleTooltip?: string
-}
-
-export abstract class GenericHeaderComponent {
-  constructor (@Inject('data') public data: GenericHeaderData) {}
-}
-
-@Component({
-  selector: 'my-video-list-header',
-  // eslint-disable-next-line @angular-eslint/use-component-view-encapsulation
-  encapsulation: ViewEncapsulation.None,
-  templateUrl: './video-list-header.component.html'
-})
-export class VideoListHeaderComponent extends GenericHeaderComponent {
-  constructor (@Inject('data') public data: GenericHeaderData) {
-    super(data)
-  }
-}

+ 19 - 22
client/src/app/shared/shared-video-miniature/abstract-video-list.html → client/src/app/shared/shared-video-miniature/videos-list.component.html

@@ -1,11 +1,17 @@
 <div class="margin-content">
   <div class="videos-header">
-    <ng-template #videoListHeader></ng-template>
+    <h1 *ngIf="displayTitle" class="title" placement="bottom" [ngbTooltip]="titleTooltip" container="body">
+      {{ title }}
+    </h1>
 
-    <div class="action-block">
-      <my-feed *ngIf="syndicationItems" [syndicationItems]="syndicationItems"></my-feed>
+    <div *ngIf="syndicationItems" [ngClass]="{ 'no-title': !displayTitle }" class="title-subscription">
+      <ng-container i18n>Subscribe to RSS feed "{{ title }}"</ng-container>
+
+      <my-feed [syndicationItems]="syndicationItems"></my-feed>
+    </div>
 
-      <ng-container *ngFor="let action of actions">
+    <div class="action-block">
+      <ng-container *ngFor="let action of headerActions">
         <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active">
           <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
         </a>
@@ -24,27 +30,18 @@
         </ng-template>
       </ng-container>
     </div>
-
-    <div class="moderation-block" *ngIf="displayModerationBlock">
-      <div class="c-hand" ngbDropdown placement="bottom-right auto">
-        <my-global-icon iconName="cog" ngbDropdownToggle></my-global-icon>
-
-        <div role="menu" class="dropdown-menu" ngbDropdownMenu>
-          <div class="dropdown-item">
-            <my-peertube-checkbox
-              (change)="toggleModerationDisplay()"
-              inputName="display-unlisted-private" i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
-            ></my-peertube-checkbox>
-          </div>
-        </div>
-      </div>
-    </div>
   </div>
 
+  <my-video-filters-header
+    *ngIf="displayFilters" [displayModerationBlock]="displayModerationBlock"
+    [defaultSort]="defaultSort" [filters]="filters"
+    (filtersChanged)="onFiltersChanged(true)"
+  ></my-video-filters-header>
+
   <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
   <div
-    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
-    class="videos" [ngClass]="{ 'display-as-row': displayAsRow() }"
+    myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true"
+    class="videos" [ngClass]="{ 'display-as-row': displayAsRow }"
   >
     <ng-container *ngFor="let video of videos; trackBy: videoById;">
       <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
@@ -53,7 +50,7 @@
 
       <div class="video-wrapper">
         <my-video-miniature
-          [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow()"
+          [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow"
           [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
           (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
         >

+ 48 - 23
client/src/app/shared/shared-video-miniature/abstract-video-list.scss → client/src/app/shared/shared-video-miniature/videos-list.component.scss

@@ -3,44 +3,57 @@
 @use '_mixins' as *;
 @use '_miniature' as *;
 
-$icon-size: 16px;
-
-::ng-deep my-video-list-header {
-  display: flex;
-  flex-grow: 1;
-}
-
 .videos-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
+  display: grid;
+  grid-template-columns: auto 1fr auto;
+  margin-bottom: 30px;
 
-  my-feed {
-    display: inline-block;
-    width: calc(#{$icon-size} - 2px);
+  .title,
+  .title-subscription {
+    grid-column: 1;
   }
 
-  .moderation-block {
-    @include margin-left(.4rem);
+  .title {
+    font-size: 18px;
+    color: pvar(--mainForegroundColor);
+    display: inline-block;
+    font-weight: $font-semibold;
 
-    display: flex;
-    justify-content: flex-end;
-    align-items: center;
+    margin-top: 30px;
+    margin-bottom: 0;
+  }
+
+  .title-subscription {
+    grid-row: 2;
+    font-size: 14px;
+    color: pvar(--greyForegroundColor);
 
-    my-global-icon {
-      position: relative;
-      width: $icon-size;
+    &.no-title {
+      margin-top: 10px;
     }
   }
+
+  .action-block {
+    grid-column: 3;
+  }
+
+  my-feed {
+    @include margin-left(5px);
+
+    display: inline-block;
+    width: 16px;
+    color: pvar(--mainColor);
+    position: relative;
+    top: -2px;
+  }
 }
 
 .date-title {
   font-size: 16px;
   font-weight: $font-semibold;
   margin-bottom: 20px;
-  margin-top: -10px;
 
-  // make the element span a full grid row within .videos grid
+  // Make the element span a full grid row within .videos grid
   grid-column: 1 / -1;
 
   &:not(:first-child) {
@@ -64,6 +77,18 @@ $icon-size: 16px;
 }
 
 @media screen and (max-width: $mobile-view) {
+  .videos-header,
+  my-video-filters-header {
+    @include margin-left(15px);
+    @include margin-right(15px);
+
+    display: inline-block;
+  }
+
+  .date-title {
+    text-align: center;
+  }
+
   .videos-header {
     flex-direction: column;
     align-items: center;

+ 396 - 0
client/src/app/shared/shared-video-miniature/videos-list.component.ts

@@ -0,0 +1,396 @@
+import * as debug from 'debug'
+import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
+import { debounceTime, switchMap } from 'rxjs/operators'
+import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
+import { ResultList, UserRight, VideoSortField } from '@shared/models'
+import { Syndication, Video } from '../shared-main'
+import { VideoFilters, VideoFilterScope } from './video-filters.model'
+import { MiniatureDisplayOptions } from './video-miniature.component'
+
+const logger = debug('peertube:videos:VideosListComponent')
+
+export type HeaderAction = {
+  iconName: GlobalIconName
+  label: string
+  justIcon?: boolean
+  routerLink?: string
+  href?: string
+  click?: (e: Event) => void
+}
+
+enum GroupDate {
+  UNKNOWN = 0,
+  TODAY = 1,
+  YESTERDAY = 2,
+  THIS_WEEK = 3,
+  THIS_MONTH = 4,
+  LAST_MONTH = 5,
+  OLDER = 6
+}
+
+@Component({
+  selector: 'my-videos-list',
+  templateUrl: './videos-list.component.html',
+  styleUrls: [ './videos-list.component.scss' ]
+})
+export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
+  @Input() getVideosObservableFunction: (pagination: ComponentPaginationLight, filters: VideoFilters) => Observable<ResultList<Video>>
+  @Input() getSyndicationItemsFunction: (filters: VideoFilters) => Promise<Syndication[]> | Syndication[]
+  @Input() baseRouteBuilderFunction: (filters: VideoFilters) => string[]
+
+  @Input() title: string
+  @Input() titleTooltip: string
+  @Input() displayTitle = true
+
+  @Input() defaultSort: VideoSortField
+  @Input() defaultScope: VideoFilterScope = 'federated'
+  @Input() displayFilters = false
+  @Input() displayModerationBlock = false
+
+  @Input() loadUserVideoPreferences = false
+
+  @Input() displayAsRow = false
+  @Input() displayVideoActions = true
+  @Input() groupByDate = false
+
+  @Input() headerActions: HeaderAction[] = []
+
+  @Input() displayOptions: MiniatureDisplayOptions = {
+    date: true,
+    views: true,
+    by: true,
+    avatar: false,
+    privacyLabel: true,
+    privacyText: false,
+    state: false,
+    blacklistInfo: false
+  }
+
+  @Input() disabled = false
+
+  @Output() filtersChanged = new EventEmitter<VideoFilters>()
+
+  videos: Video[] = []
+  filters: VideoFilters
+  syndicationItems: Syndication[]
+
+  onDataSubject = new Subject<any[]>()
+  hasDoneFirstQuery = false
+
+  userMiniature: User
+
+  private routeSub: Subscription
+  private userSub: Subscription
+  private resizeSub: Subscription
+
+  private pagination: ComponentPaginationLight = {
+    currentPage: 1,
+    itemsPerPage: 25
+  }
+
+  private groupedDateLabels: { [id in GroupDate]: string }
+  private groupedDates: { [id: number]: GroupDate } = {}
+
+  private lastQueryLength: number
+
+  constructor (
+    private notifier: Notifier,
+    private authService: AuthService,
+    private userService: UserService,
+    private route: ActivatedRoute,
+    private screenService: ScreenService,
+    private peertubeRouter: PeerTubeRouterService
+  ) {
+
+  }
+
+  ngOnInit () {
+    this.filters = new VideoFilters(this.defaultSort, this.defaultScope)
+    this.filters.load({ ...this.route.snapshot.queryParams, scope: this.defaultScope })
+
+    this.groupedDateLabels = {
+      [GroupDate.UNKNOWN]: null,
+      [GroupDate.TODAY]: $localize`Today`,
+      [GroupDate.YESTERDAY]: $localize`Yesterday`,
+      [GroupDate.THIS_WEEK]: $localize`This week`,
+      [GroupDate.THIS_MONTH]: $localize`This month`,
+      [GroupDate.LAST_MONTH]: $localize`Last month`,
+      [GroupDate.OLDER]: $localize`Older`
+    }
+
+    this.resizeSub = fromEvent(window, 'resize')
+      .pipe(debounceTime(500))
+      .subscribe(() => this.calcPageSizes())
+
+    this.calcPageSizes()
+
+    this.userService.getAnonymousOrLoggedUser()
+      .subscribe(user => {
+        this.userMiniature = user
+
+        if (this.loadUserVideoPreferences) {
+          this.loadUserSettings(user)
+        }
+
+        this.scheduleOnFiltersChanged(false)
+
+        this.subscribeToAnonymousUpdate()
+        this.subscribeToSearchChange()
+      })
+
+    // Display avatar in mobile view
+    if (this.screenService.isInMobileView()) {
+      this.displayOptions.avatar = true
+    }
+  }
+
+  ngOnDestroy () {
+    if (this.resizeSub) this.resizeSub.unsubscribe()
+    if (this.routeSub) this.routeSub.unsubscribe()
+    if (this.userSub) this.userSub.unsubscribe()
+  }
+
+  ngOnChanges (changes: SimpleChanges) {
+    if (!this.filters) return
+
+    let updated = false
+
+    if (changes['defaultScope']) {
+      updated = true
+      this.filters.setDefaultScope(this.defaultScope)
+    }
+
+    if (changes['defaultSort']) {
+      updated = true
+      this.filters.setDefaultSort(this.defaultSort)
+    }
+
+    if (!updated) return
+
+    const customizedByUser = this.hasBeenCustomizedByUser()
+
+    if (!customizedByUser) {
+      if (this.loadUserVideoPreferences) {
+        this.loadUserSettings(this.userMiniature)
+      }
+
+      this.filters.reset('scope')
+      this.filters.reset('sort')
+    }
+
+    this.scheduleOnFiltersChanged(customizedByUser)
+  }
+
+  videoById (_index: number, video: Video) {
+    return video.id
+  }
+
+  onNearOfBottom () {
+    if (this.disabled) return
+
+    // No more results
+    if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
+
+    this.pagination.currentPage += 1
+
+    this.loadMoreVideos()
+  }
+
+  loadMoreVideos (reset = false) {
+    this.getVideosObservableFunction(this.pagination, this.filters)
+      .subscribe({
+        next: ({ data }) => {
+          this.hasDoneFirstQuery = true
+          this.lastQueryLength = data.length
+
+          if (reset) this.videos = []
+          this.videos = this.videos.concat(data)
+
+          if (this.groupByDate) this.buildGroupedDateLabels()
+
+          this.onDataSubject.next(data)
+        },
+
+        error: err => {
+          const message = $localize`Cannot load more videos. Try again later.`
+
+          console.error(message, { err })
+          this.notifier.error(message)
+        }
+      })
+  }
+
+  reloadVideos () {
+    this.pagination.currentPage = 1
+    this.loadMoreVideos(true)
+  }
+
+  removeVideoFromArray (video: Video) {
+    this.videos = this.videos.filter(v => v.id !== video.id)
+  }
+
+  buildGroupedDateLabels () {
+    let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
+
+    const periods = [
+      {
+        value: GroupDate.TODAY,
+        validator: (d: Date) => isToday(d)
+      },
+      {
+        value: GroupDate.YESTERDAY,
+        validator: (d: Date) => isYesterday(d)
+      },
+      {
+        value: GroupDate.THIS_WEEK,
+        validator: (d: Date) => isLastWeek(d)
+      },
+      {
+        value: GroupDate.THIS_MONTH,
+        validator: (d: Date) => isThisMonth(d)
+      },
+      {
+        value: GroupDate.LAST_MONTH,
+        validator: (d: Date) => isLastMonth(d)
+      },
+      {
+        value: GroupDate.OLDER,
+        validator: () => true
+      }
+    ]
+
+    for (const video of this.videos) {
+      const publishedDate = video.publishedAt
+
+      for (let i = 0; i < periods.length; i++) {
+        const period = periods[i]
+
+        if (currentGroupedDate <= period.value && period.validator(publishedDate)) {
+
+          if (currentGroupedDate !== period.value) {
+            currentGroupedDate = period.value
+            this.groupedDates[video.id] = currentGroupedDate
+          }
+
+          break
+        }
+      }
+    }
+  }
+
+  getCurrentGroupedDateLabel (video: Video) {
+    if (this.groupByDate === false) return undefined
+
+    return this.groupedDateLabels[this.groupedDates[video.id]]
+  }
+
+  scheduleOnFiltersChanged (customizedByUser: boolean) {
+    // We'll reload videos, but avoid weird UI effect
+    this.videos = []
+
+    setTimeout(() => this.onFiltersChanged(customizedByUser))
+  }
+
+  onFiltersChanged (customizedByUser: boolean) {
+    logger('Running on filters changed')
+
+    this.updateUrl(customizedByUser)
+
+    this.filters.triggerChange()
+
+    this.reloadSyndicationItems()
+    this.reloadVideos()
+  }
+
+  protected enableAllFilterIfPossible () {
+    if (!this.authService.isLoggedIn()) return
+
+    this.authService.userInformationLoaded
+      .subscribe(() => {
+        const user = this.authService.getUser()
+        this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS)
+      })
+  }
+
+  private calcPageSizes () {
+    if (this.screenService.isInMobileView()) {
+      this.pagination.itemsPerPage = 5
+    }
+  }
+
+  private loadUserSettings (user: User) {
+    this.filters.setNSFWPolicy(user.nsfwPolicy)
+
+    // Don't reset language filter if we don't want to refresh the component
+    if (!this.hasBeenCustomizedByUser()) {
+      this.filters.load({ languageOneOf: user.videoLanguages })
+    }
+  }
+
+  private reloadSyndicationItems () {
+    Promise.resolve(this.getSyndicationItemsFunction(this.filters))
+      .then(items => {
+        if (!items || items.length === 0) this.syndicationItems = undefined
+        else this.syndicationItems = items
+      })
+      .catch(err => console.error('Cannot get syndication items.', err))
+  }
+
+  private updateUrl (customizedByUser: boolean) {
+    const baseQuery = this.filters.toUrlObject()
+
+    // Set or reset customized by user query param
+    const queryParams = customizedByUser || this.hasBeenCustomizedByUser()
+      ? { ...baseQuery, c: customizedByUser }
+      : baseQuery
+
+    logger('Will inject %O in URL query', queryParams)
+
+    const baseRoute = this.baseRouteBuilderFunction
+      ? this.baseRouteBuilderFunction(this.filters)
+      : []
+
+    const pathname = window.location.pathname
+
+    const baseRouteChanged = baseRoute.length !== 0 &&
+                             pathname !== '/' && // Exclude special '/' case, we'll be redirected without component change
+                             baseRoute.length !== 0 && pathname !== baseRoute.join('/')
+
+    if (baseRouteChanged || Object.keys(baseQuery).length !== 0 || customizedByUser) {
+      this.peertubeRouter.silentNavigate(baseRoute, queryParams)
+    }
+
+    this.filtersChanged.emit(this.filters)
+  }
+
+  private hasBeenCustomizedByUser () {
+    return this.route.snapshot.queryParams['c'] === 'true'
+  }
+
+  private subscribeToAnonymousUpdate () {
+    this.userSub = this.userService.listenAnonymousUpdate()
+      .pipe(switchMap(() => this.userService.getAnonymousOrLoggedUser()))
+      .subscribe(user => {
+        if (this.loadUserVideoPreferences) {
+          this.loadUserSettings(user)
+        }
+
+        if (this.hasDoneFirstQuery) {
+          this.reloadVideos()
+        }
+      })
+  }
+
+  private subscribeToSearchChange () {
+    this.routeSub = this.route.queryParams.subscribe(param => {
+      if (!param['search']) return
+
+      this.filters.load({ search: param['search'] })
+      this.onFiltersChanged(true)
+    })
+  }
+}

+ 4 - 1
client/src/app/shared/shared-video-miniature/videos-selection.component.html

@@ -1,6 +1,9 @@
 <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div>
 
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos">
+<div
+  class="videos"
+  myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true"
+>
   <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
 
     <div class="checkbox-container" *ngIf="enableSelection">

+ 64 - 42
client/src/app/shared/shared-video-miniature/videos-selection.component.ts

@@ -1,22 +1,8 @@
-import { Observable } from 'rxjs'
-import {
-  AfterContentInit,
-  Component,
-  ComponentFactoryResolver,
-  ContentChildren,
-  EventEmitter,
-  Input,
-  OnDestroy,
-  OnInit,
-  Output,
-  QueryList,
-  TemplateRef
-} from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
+import { Observable, Subject } from 'rxjs'
+import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core'
+import { ComponentPagination, Notifier, User } from '@app/core'
 import { ResultList, VideoSortField } from '@shared/models'
 import { PeerTubeTemplateDirective, Video } from '../shared-main'
-import { AbstractVideoList } from './abstract-video-list'
 import { MiniatureDisplayOptions } from './video-miniature.component'
 
 export type SelectionType = { [ id: number ]: boolean }
@@ -26,14 +12,18 @@ export type SelectionType = { [ id: number ]: boolean }
   templateUrl: './videos-selection.component.html',
   styleUrls: [ './videos-selection.component.scss' ]
 })
-export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
+export class VideosSelectionComponent implements AfterContentInit {
   @Input() user: User
   @Input() pagination: ComponentPagination
+
   @Input() titlePage: string
+
   @Input() miniatureDisplayOptions: MiniatureDisplayOptions
+
   @Input() noResultMessage = $localize`No results.`
   @Input() enableSelection = true
-  @Input() loadOnInit = true
+
+  @Input() disabled = false
 
   @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
 
@@ -47,19 +37,18 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
   rowButtonsTemplate: TemplateRef<any>
   globalButtonsTemplate: TemplateRef<any>
 
+  videos: Video[] = []
+  sort: VideoSortField = '-publishedAt'
+
+  onDataSubject = new Subject<any[]>()
+
+  hasDoneFirstQuery = false
+
+  private lastQueryLength: number
+
   constructor (
-    protected router: Router,
-    protected route: ActivatedRoute,
-    protected notifier: Notifier,
-    protected authService: AuthService,
-    protected userService: UserService,
-    protected screenService: ScreenService,
-    protected storageService: LocalStorageService,
-    protected serverService: ServerService,
-    protected cfr: ComponentFactoryResolver
-  ) {
-    super()
-  }
+    private notifier: Notifier
+  ) { }
 
   @Input() get selection () {
     return this._selection
@@ -79,10 +68,6 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
     this.videosModelChange.emit(this.videos)
   }
 
-  ngOnInit () {
-    super.ngOnInit()
-  }
-
   ngAfterContentInit () {
     {
       const t = this.templates.find(t => t.name === 'rowButtons')
@@ -93,10 +78,8 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
       const t = this.templates.find(t => t.name === 'globalButtons')
       if (t) this.globalButtonsTemplate = t.template
     }
-  }
 
-  ngOnDestroy () {
-    super.ngOnDestroy()
+    this.loadMoreVideos()
   }
 
   getVideosObservable (page: number) {
@@ -111,11 +94,50 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
     return Object.keys(this._selection).some(k => this._selection[k] === true)
   }
 
-  generateSyndicationList () {
-    throw new Error('Method not implemented.')
+  videoById (index: number, video: Video) {
+    return video.id
+  }
+
+  onNearOfBottom () {
+    if (this.disabled) return
+
+    // No more results
+    if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
+
+    this.pagination.currentPage += 1
+
+    this.loadMoreVideos()
+  }
+
+  loadMoreVideos (reset = false) {
+    this.getVideosObservable(this.pagination.currentPage)
+      .subscribe({
+        next: ({ data }) => {
+          this.hasDoneFirstQuery = true
+          this.lastQueryLength = data.length
+
+          if (reset) this.videos = []
+          this.videos = this.videos.concat(data)
+          this.videosModel = this.videos
+
+          this.onDataSubject.next(data)
+        },
+
+        error: err => {
+          const message = $localize`Cannot load more videos. Try again later.`
+
+          console.error(message, { err })
+          this.notifier.error(message)
+        }
+      })
+  }
+
+  reloadVideos () {
+    this.pagination.currentPage = 1
+    this.loadMoreVideos(true)
   }
 
-  protected onMoreVideos () {
-    this.videosModel = this.videos
+  removeVideoFromArray (video: Video) {
+    this.videos = this.videos.filter(v => v.id !== video.id)
   }
 }

+ 4 - 0
client/src/assets/images/feather/chevrons-up.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevrons-up">
+  <polyline points="17 11 12 6 7 11"></polyline>
+  <polyline points="17 18 12 13 7 18"></polyline>
+</svg>

+ 1 - 0
client/src/sass/bootstrap.scss

@@ -287,6 +287,7 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
 
   &.show {
     max-height: 1500px;
+    overflow: inherit !important;
   }
 }
 

+ 4 - 0
client/src/sass/classes.scss

@@ -24,3 +24,7 @@
 .tertiary-button {
   @include tertiary-button;
 }
+
+.peertube-radio-container {
+  @include peertube-radio-container;
+}

+ 45 - 32
client/src/sass/include/_mixins.scss

@@ -420,42 +420,55 @@
   }
 }
 
-// Thanks: https://codepen.io/triss90/pen/XNEdRe/
+// Thanks: https://codepen.io/manabox/pen/raQmpL
 @mixin peertube-radio-container {
-  input[type=radio] {
-    display: none;
-
-    + label {
-      font-weight: $font-regular;
-      cursor: pointer;
+  [type=radio]:checked,
+  [type=radio]:not(:checked) {
+    position: absolute;
+    left: -9999px;
+  }
 
-      &::before {
-        @include margin-right(10px);
-
-        position: relative;
-        top: -2px;
-        content: '';
-        background: #fff;
-        border-radius: 100%;
-        border: 1px solid #000;
-        display: inline-block;
-        width: 15px;
-        height: 15px;
-        vertical-align: middle;
-        cursor: pointer;
-        text-align: center;
-      }
-    }
+  [type=radio]:checked + label,
+  [type=radio]:not(:checked) + label {
+    position: relative;
+    padding-left: 28px;
+    cursor: pointer;
+    line-height: 20px;
+    display: inline-block;
+  }
 
-    &:checked + label::before {
-      background-color: #000;
-      box-shadow: inset 0 0 0 4px #fff;
-    }
+  [type=radio]:checked + label::before,
+  [type=radio]:not(:checked) + label::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 18px;
+    height: 18px;
+    border: 1px solid #C6C6C6;
+    border-radius: 100%;
+    background: #fff;
+  }
 
-    &:focus + label::before {
-      outline: none;
-      border-color: #000;
-    }
+  [type=radio]:checked + label::after,
+  [type=radio]:not(:checked) + label::after {
+    content: '';
+    width: 10px;
+    height: 10px;
+    background: pvar(--mainColor);
+    position: absolute;
+    top: 4px;
+    left: 4px;
+    border-radius: 100%;
+    transition: all 0.2s ease;
+  }
+  [type=radio]:not(:checked) + label::after {
+    opacity: 0;
+    transform: scale(0);
+  }
+  [type=radio]:checked + label::after {
+    opacity: 1;
+    transform: scale(1);
   }
 }