Fix videos list when page is empty
[oweals/peertube.git] / client / src / app / shared / video / abstract-video-list.ts
1 import { debounceTime } from 'rxjs/operators'
2 import { ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'
3 import { ActivatedRoute, Router } from '@angular/router'
4 import { Location } from '@angular/common'
5 import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
6 import { NotificationsService } from 'angular2-notifications'
7 import { fromEvent, Observable, Subscription } from 'rxjs'
8 import { AuthService } from '../../core/auth'
9 import { ComponentPagination } from '../rest/component-pagination.model'
10 import { VideoSortField } from './sort-field.type'
11 import { Video } from './video.model'
12 import { I18n } from '@ngx-translate/i18n-polyfill'
13 import { ScreenService } from '@app/shared/misc/screen.service'
14 import { OwnerDisplayType } from '@app/shared/video/video-miniature.component'
15
16 export abstract class AbstractVideoList implements OnInit, OnDestroy {
17   private static LINES_PER_PAGE = 4
18
19   @ViewChild('videosElement') videosElement: ElementRef
20   @ViewChild(InfiniteScrollerDirective) infiniteScroller: InfiniteScrollerDirective
21
22   pagination: ComponentPagination = {
23     currentPage: 1,
24     itemsPerPage: 10,
25     totalItems: null
26   }
27   sort: VideoSortField = '-publishedAt'
28   categoryOneOf?: number
29   defaultSort: VideoSortField = '-publishedAt'
30   syndicationItems = []
31
32   loadOnInit = true
33   marginContent = true
34   pageHeight: number
35   videoWidth: number
36   videoHeight: number
37   videoPages: Video[][] = []
38   ownerDisplayType: OwnerDisplayType = 'account'
39   firstLoadedPage: number
40
41   protected baseVideoWidth = 215
42   protected baseVideoHeight = 205
43
44   protected abstract notificationsService: NotificationsService
45   protected abstract authService: AuthService
46   protected abstract router: Router
47   protected abstract route: ActivatedRoute
48   protected abstract screenService: ScreenService
49   protected abstract i18n: I18n
50   protected abstract location: Location
51   protected abstract currentRoute: string
52   abstract titlePage: string
53
54   protected loadedPages: { [ id: number ]: Video[] } = {}
55   protected loadingPage: { [ id: number ]: boolean } = {}
56   protected otherRouteParams = {}
57
58   private resizeSubscription: Subscription
59
60   abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}>
61   abstract generateSyndicationList ()
62
63   get user () {
64     return this.authService.getUser()
65   }
66
67   ngOnInit () {
68     // Subscribe to route changes
69     const routeParams = this.route.snapshot.queryParams
70     this.loadRouteParams(routeParams)
71
72     this.resizeSubscription = fromEvent(window, 'resize')
73       .pipe(debounceTime(500))
74       .subscribe(() => this.calcPageSizes())
75
76     this.calcPageSizes()
77     if (this.loadOnInit === true) this.loadMoreVideos(this.pagination.currentPage)
78   }
79
80   ngOnDestroy () {
81     if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
82   }
83
84   pageByVideoId (index: number, page: Video[]) {
85     // Video are unique in all pages
86     return page.length !== 0 ? page[0].id : 0
87   }
88
89   videoById (index: number, video: Video) {
90     return video.id
91   }
92
93   onNearOfTop () {
94     this.previousPage()
95   }
96
97   onNearOfBottom () {
98     if (this.hasMoreVideos()) {
99       this.nextPage()
100     }
101   }
102
103   onPageChanged (page: number) {
104     this.pagination.currentPage = page
105     this.setNewRouteParams()
106   }
107
108   reloadVideos () {
109     this.loadedPages = {}
110     this.loadMoreVideos(this.pagination.currentPage)
111   }
112
113   loadMoreVideos (page: number, loadOnTop = false) {
114     this.adjustVideoPageHeight()
115
116     const currentY = window.scrollY
117
118     if (this.loadedPages[page] !== undefined) return
119     if (this.loadingPage[page] === true) return
120
121     this.loadingPage[page] = true
122     const observable = this.getVideosObservable(page)
123
124     observable.subscribe(
125       ({ videos, totalVideos }) => {
126         this.loadingPage[page] = false
127
128         if (this.firstLoadedPage === undefined || this.firstLoadedPage > page) this.firstLoadedPage = page
129
130         // Paging is too high, return to the first one
131         if (this.pagination.currentPage > 1 && totalVideos <= ((this.pagination.currentPage - 1) * this.pagination.itemsPerPage)) {
132           this.pagination.currentPage = 1
133           this.setNewRouteParams()
134           return this.reloadVideos()
135         }
136
137         this.loadedPages[page] = videos
138         this.buildVideoPages()
139         this.pagination.totalItems = totalVideos
140
141         // Initialize infinite scroller now we loaded the first page
142         if (Object.keys(this.loadedPages).length === 1) {
143           // Wait elements creation
144           setTimeout(() => {
145             this.infiniteScroller.initialize()
146
147             // At our first load, we did not load the first page
148             // Load the previous page so the user can move on the top (and browser previous pages)
149             if (this.pagination.currentPage > 1) this.loadMoreVideos(this.pagination.currentPage - 1, true)
150           }, 500)
151         }
152
153         // Insert elements on the top but keep the scroll in the previous position
154         if (loadOnTop) setTimeout(() => { window.scrollTo(0, currentY + this.pageHeight) }, 0)
155       },
156       error => {
157         this.loadingPage[page] = false
158         this.notificationsService.error(this.i18n('Error'), error.message)
159       }
160     )
161   }
162
163   protected hasMoreVideos () {
164     // No results
165     if (this.pagination.totalItems === 0) return false
166
167     // Not loaded yet
168     if (!this.pagination.totalItems) return true
169
170     const maxPage = this.pagination.totalItems / this.pagination.itemsPerPage
171     return maxPage > this.maxPageLoaded()
172   }
173
174   protected previousPage () {
175     const min = this.minPageLoaded()
176
177     if (min > 1) {
178       this.loadMoreVideos(min - 1, true)
179     }
180   }
181
182   protected nextPage () {
183     this.loadMoreVideos(this.maxPageLoaded() + 1)
184   }
185
186   protected buildRouteParams () {
187     // There is always a sort and a current page
188     const params = {
189       sort: this.sort,
190       page: this.pagination.currentPage
191     }
192
193     return Object.assign(params, this.otherRouteParams)
194   }
195
196   protected loadRouteParams (routeParams: { [ key: string ]: any }) {
197     this.sort = routeParams['sort'] as VideoSortField || this.defaultSort
198     this.categoryOneOf = routeParams['categoryOneOf']
199     if (routeParams['page'] !== undefined) {
200       this.pagination.currentPage = parseInt(routeParams['page'], 10)
201     } else {
202       this.pagination.currentPage = 1
203     }
204   }
205
206   protected setNewRouteParams () {
207     const paramsObject = this.buildRouteParams()
208
209     const queryParams = Object.keys(paramsObject).map(p => p + '=' + paramsObject[p]).join('&')
210     this.location.replaceState(this.currentRoute, queryParams)
211   }
212
213   protected buildVideoPages () {
214     this.videoPages = Object.values(this.loadedPages)
215   }
216
217   protected adjustVideoPageHeight () {
218     const numberOfPagesLoaded = Object.keys(this.loadedPages).length
219     if (!numberOfPagesLoaded) return
220
221     this.pageHeight = this.videosElement.nativeElement.offsetHeight / numberOfPagesLoaded
222   }
223
224   protected buildVideoHeight () {
225     // Same ratios than base width/height
226     return this.videosElement.nativeElement.offsetWidth * (this.baseVideoHeight / this.baseVideoWidth)
227   }
228
229   private minPageLoaded () {
230     return Math.min(...Object.keys(this.loadedPages).map(e => parseInt(e, 10)))
231   }
232
233   private maxPageLoaded () {
234     return Math.max(...Object.keys(this.loadedPages).map(e => parseInt(e, 10)))
235   }
236
237   private calcPageSizes () {
238     if (this.screenService.isInMobileView() || this.baseVideoWidth === -1) {
239       this.pagination.itemsPerPage = 5
240
241       // Video takes all the width
242       this.videoWidth = -1
243       this.videoHeight = this.buildVideoHeight()
244       this.pageHeight = this.pagination.itemsPerPage * this.videoHeight
245     } else {
246       this.videoWidth = this.baseVideoWidth
247       this.videoHeight = this.baseVideoHeight
248
249       const videosWidth = this.videosElement.nativeElement.offsetWidth
250       this.pagination.itemsPerPage = Math.floor(videosWidth / this.videoWidth) * AbstractVideoList.LINES_PER_PAGE
251       this.pageHeight = this.videoHeight * AbstractVideoList.LINES_PER_PAGE
252     }
253
254     // Rebuild pages because maybe we modified the number of items per page
255     const videos = [].concat(...this.videoPages)
256     this.loadedPages = {}
257
258     let i = 1
259     // Don't include the last page if it not complete
260     while (videos.length >= this.pagination.itemsPerPage && i < 10000) { // 10000 -> Hard limit in case of infinite loop
261       this.loadedPages[i] = videos.splice(0, this.pagination.itemsPerPage)
262       i++
263     }
264
265     // Re fetch the last page
266     if (videos.length !== 0) {
267       this.loadMoreVideos(i)
268     } else {
269       this.buildVideoPages()
270     }
271
272     console.log('Rebuilt pages with %s elements per page.', this.pagination.itemsPerPage)
273   }
274 }