diff options
Diffstat (limited to 'client/src/app/shared')
-rw-r--r-- | client/src/app/shared/video/abstract-video-list.html | 11 | ||||
-rw-r--r-- | client/src/app/shared/video/abstract-video-list.ts | 244 | ||||
-rw-r--r-- | client/src/app/shared/video/infinite-scroller.directive.ts | 47 |
3 files changed, 59 insertions, 243 deletions
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html index 1f97bc389..e134654a3 100644 --- a/client/src/app/shared/video/abstract-video-list.html +++ b/client/src/app/shared/video/abstract-video-list.html | |||
@@ -19,13 +19,10 @@ | |||
19 | 19 | ||
20 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> | 20 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> |
21 | <div | 21 | <div |
22 | myInfiniteScroller | 22 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" |
23 | [pageHeight]="pageHeight" [firstLoadedPage]="firstLoadedPage" | 23 | class="videos" |
24 | (nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)" | ||
25 | class="videos" #videosElement | ||
26 | > | 24 | > |
27 | <div *ngFor="let videos of videoPages; trackBy: pageByVideoId" class="videos-page"> | 25 | <my-video-miniature *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"> |
28 | <my-video-miniature *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature> | 26 | </my-video-miniature> |
29 | </div> | ||
30 | </div> | 27 | </div> |
31 | </div> | 28 | </div> |
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index 2cd5bc393..467f629ea 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -1,66 +1,52 @@ | |||
1 | import { debounceTime } from 'rxjs/operators' | 1 | import { debounceTime } from 'rxjs/operators' |
2 | import { ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core' | 2 | import { OnDestroy, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 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 { fromEvent, Observable, Subscription } from 'rxjs' | 4 | import { fromEvent, Observable, Subscription } from 'rxjs' |
7 | import { AuthService } from '../../core/auth' | 5 | import { AuthService } from '../../core/auth' |
8 | import { ComponentPagination } from '../rest/component-pagination.model' | 6 | import { ComponentPagination } from '../rest/component-pagination.model' |
9 | import { VideoSortField } from './sort-field.type' | 7 | import { VideoSortField } from './sort-field.type' |
10 | import { Video } from './video.model' | 8 | import { Video } from './video.model' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
12 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
13 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' | 10 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' |
14 | import { Syndication } from '@app/shared/video/syndication.model' | 11 | import { Syndication } from '@app/shared/video/syndication.model' |
15 | import { Notifier } from '@app/core' | 12 | import { Notifier, ServerService } from '@app/core' |
16 | 13 | import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | |
17 | export abstract class AbstractVideoList implements OnInit, OnDestroy { | ||
18 | private static LINES_PER_PAGE = 4 | ||
19 | |||
20 | @ViewChild('videosElement') videosElement: ElementRef | ||
21 | @ViewChild(InfiniteScrollerDirective) infiniteScroller: InfiniteScrollerDirective | ||
22 | 14 | ||
15 | export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook { | ||
23 | pagination: ComponentPagination = { | 16 | pagination: ComponentPagination = { |
24 | currentPage: 1, | 17 | currentPage: 1, |
25 | itemsPerPage: 10, | 18 | itemsPerPage: 25, |
26 | totalItems: null | 19 | totalItems: null |
27 | } | 20 | } |
28 | sort: VideoSortField = '-publishedAt' | 21 | sort: VideoSortField = '-publishedAt' |
22 | |||
29 | categoryOneOf?: number | 23 | categoryOneOf?: number |
30 | defaultSort: VideoSortField = '-publishedAt' | 24 | defaultSort: VideoSortField = '-publishedAt' |
25 | |||
31 | syndicationItems: Syndication[] = [] | 26 | syndicationItems: Syndication[] = [] |
32 | 27 | ||
33 | loadOnInit = true | 28 | loadOnInit = true |
34 | marginContent = true | 29 | marginContent = true |
35 | pageHeight: number | 30 | videos: Video[] = [] |
36 | videoWidth: number | ||
37 | videoHeight: number | ||
38 | videoPages: Video[][] = [] | ||
39 | ownerDisplayType: OwnerDisplayType = 'account' | 31 | ownerDisplayType: OwnerDisplayType = 'account' |
40 | firstLoadedPage: number | ||
41 | displayModerationBlock = false | 32 | displayModerationBlock = false |
42 | titleTooltip: string | 33 | titleTooltip: string |
43 | 34 | ||
44 | protected baseVideoWidth = 238 | 35 | disabled = false |
45 | protected baseVideoHeight = 225 | ||
46 | 36 | ||
47 | protected abstract notifier: Notifier | 37 | protected abstract notifier: Notifier |
48 | protected abstract authService: AuthService | 38 | protected abstract authService: AuthService |
49 | protected abstract router: Router | ||
50 | protected abstract route: ActivatedRoute | 39 | protected abstract route: ActivatedRoute |
40 | protected abstract serverService: ServerService | ||
51 | protected abstract screenService: ScreenService | 41 | protected abstract screenService: ScreenService |
52 | protected abstract i18n: I18n | 42 | protected abstract router: Router |
53 | protected abstract location: Location | ||
54 | protected abstract currentRoute: string | ||
55 | abstract titlePage: string | 43 | abstract titlePage: string |
56 | 44 | ||
57 | protected loadedPages: { [ id: number ]: Video[] } = {} | ||
58 | protected loadingPage: { [ id: number ]: boolean } = {} | ||
59 | protected otherRouteParams = {} | ||
60 | |||
61 | private resizeSubscription: Subscription | 45 | private resizeSubscription: Subscription |
46 | private angularState: number | ||
47 | |||
48 | abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number }> | ||
62 | 49 | ||
63 | abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}> | ||
64 | abstract generateSyndicationList (): void | 50 | abstract generateSyndicationList (): void |
65 | 51 | ||
66 | get user () { | 52 | get user () { |
@@ -77,207 +63,87 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
77 | .subscribe(() => this.calcPageSizes()) | 63 | .subscribe(() => this.calcPageSizes()) |
78 | 64 | ||
79 | this.calcPageSizes() | 65 | this.calcPageSizes() |
80 | if (this.loadOnInit === true) this.loadMoreVideos(this.pagination.currentPage) | 66 | if (this.loadOnInit === true) this.loadMoreVideos() |
81 | } | 67 | } |
82 | 68 | ||
83 | ngOnDestroy () { | 69 | ngOnDestroy () { |
84 | if (this.resizeSubscription) this.resizeSubscription.unsubscribe() | 70 | if (this.resizeSubscription) this.resizeSubscription.unsubscribe() |
85 | } | 71 | } |
86 | 72 | ||
87 | pageByVideoId (index: number, page: Video[]) { | 73 | disableForReuse () { |
88 | // Video are unique in all pages | 74 | this.disabled = true |
89 | return page.length !== 0 ? page[0].id : 0 | ||
90 | } | 75 | } |
91 | 76 | ||
92 | videoById (index: number, video: Video) { | 77 | enabledForReuse () { |
93 | return video.id | 78 | this.disabled = false |
94 | } | 79 | } |
95 | 80 | ||
96 | onNearOfTop () { | 81 | videoById (index: number, video: Video) { |
97 | this.previousPage() | 82 | return video.id |
98 | } | 83 | } |
99 | 84 | ||
100 | onNearOfBottom () { | 85 | onNearOfBottom () { |
101 | if (this.hasMoreVideos()) { | 86 | if (this.disabled) return |
102 | this.nextPage() | ||
103 | } | ||
104 | } | ||
105 | 87 | ||
106 | onPageChanged (page: number) { | 88 | // Last page |
107 | this.pagination.currentPage = page | 89 | if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return |
108 | this.setNewRouteParams() | ||
109 | } | ||
110 | 90 | ||
111 | reloadVideos () { | 91 | this.pagination.currentPage += 1 |
112 | this.loadedPages = {} | ||
113 | this.loadMoreVideos(this.pagination.currentPage) | ||
114 | } | ||
115 | |||
116 | loadMoreVideos (page: number, loadOnTop = false) { | ||
117 | this.adjustVideoPageHeight() | ||
118 | 92 | ||
119 | const currentY = window.scrollY | 93 | this.setScrollRouteParams() |
120 | 94 | ||
121 | if (this.loadedPages[page] !== undefined) return | 95 | this.loadMoreVideos() |
122 | if (this.loadingPage[page] === true) return | 96 | } |
123 | 97 | ||
124 | this.loadingPage[page] = true | 98 | loadMoreVideos () { |
125 | const observable = this.getVideosObservable(page) | 99 | const observable = this.getVideosObservable(this.pagination.currentPage) |
126 | 100 | ||
127 | observable.subscribe( | 101 | observable.subscribe( |
128 | ({ videos, totalVideos }) => { | 102 | ({ videos, totalVideos }) => { |
129 | this.loadingPage[page] = false | ||
130 | |||
131 | if (this.firstLoadedPage === undefined || this.firstLoadedPage > page) this.firstLoadedPage = page | ||
132 | |||
133 | // Paging is too high, return to the first one | ||
134 | if (this.pagination.currentPage > 1 && totalVideos <= ((this.pagination.currentPage - 1) * this.pagination.itemsPerPage)) { | ||
135 | this.pagination.currentPage = 1 | ||
136 | this.setNewRouteParams() | ||
137 | return this.reloadVideos() | ||
138 | } | ||
139 | |||
140 | this.loadedPages[page] = videos | ||
141 | this.buildVideoPages() | ||
142 | this.pagination.totalItems = totalVideos | 103 | this.pagination.totalItems = totalVideos |
143 | 104 | this.videos = this.videos.concat(videos) | |
144 | // Initialize infinite scroller now we loaded the first page | ||
145 | if (Object.keys(this.loadedPages).length === 1) { | ||
146 | // Wait elements creation | ||
147 | setTimeout(() => { | ||
148 | this.infiniteScroller.initialize() | ||
149 | |||
150 | // At our first load, we did not load the first page | ||
151 | // Load the previous page so the user can move on the top (and browser previous pages) | ||
152 | if (this.pagination.currentPage > 1) this.loadMoreVideos(this.pagination.currentPage - 1, true) | ||
153 | }, 500) | ||
154 | } | ||
155 | |||
156 | // Insert elements on the top but keep the scroll in the previous position | ||
157 | if (loadOnTop) setTimeout(() => { window.scrollTo(0, currentY + this.pageHeight) }, 0) | ||
158 | }, | 105 | }, |
159 | error => { | ||
160 | this.loadingPage[page] = false | ||
161 | this.notifier.error(error.message) | ||
162 | } | ||
163 | ) | ||
164 | } | ||
165 | |||
166 | toggleModerationDisplay () { | ||
167 | throw new Error('toggleModerationDisplay is not implemented') | ||
168 | } | ||
169 | 106 | ||
170 | protected hasMoreVideos () { | 107 | error => this.notifier.error(error.message) |
171 | // No results | 108 | ) |
172 | if (this.pagination.totalItems === 0) return false | ||
173 | |||
174 | // Not loaded yet | ||
175 | if (!this.pagination.totalItems) return true | ||
176 | |||
177 | const maxPage = this.pagination.totalItems / this.pagination.itemsPerPage | ||
178 | return maxPage > this.maxPageLoaded() | ||
179 | } | ||
180 | |||
181 | protected previousPage () { | ||
182 | const min = this.minPageLoaded() | ||
183 | |||
184 | if (min > 1) { | ||
185 | this.loadMoreVideos(min - 1, true) | ||
186 | } | ||
187 | } | 109 | } |
188 | 110 | ||
189 | protected nextPage () { | 111 | reloadVideos () { |
190 | this.loadMoreVideos(this.maxPageLoaded() + 1) | 112 | this.pagination.currentPage = 1 |
113 | this.videos = [] | ||
114 | this.loadMoreVideos() | ||
191 | } | 115 | } |
192 | 116 | ||
193 | protected buildRouteParams () { | 117 | toggleModerationDisplay () { |
194 | // There is always a sort and a current page | 118 | throw new Error('toggleModerationDisplay is not implemented') |
195 | const params = { | ||
196 | sort: this.sort, | ||
197 | page: this.pagination.currentPage | ||
198 | } | ||
199 | |||
200 | return Object.assign(params, this.otherRouteParams) | ||
201 | } | 119 | } |
202 | 120 | ||
203 | protected loadRouteParams (routeParams: { [ key: string ]: any }) { | 121 | protected loadRouteParams (routeParams: { [ key: string ]: any }) { |
204 | this.sort = routeParams['sort'] as VideoSortField || this.defaultSort | 122 | this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort |
205 | this.categoryOneOf = routeParams['categoryOneOf'] | 123 | this.categoryOneOf = routeParams[ 'categoryOneOf' ] |
206 | if (routeParams['page'] !== undefined) { | 124 | this.angularState = routeParams[ 'a-state' ] |
207 | this.pagination.currentPage = parseInt(routeParams['page'], 10) | ||
208 | } else { | ||
209 | this.pagination.currentPage = 1 | ||
210 | } | ||
211 | } | ||
212 | |||
213 | protected setNewRouteParams () { | ||
214 | const paramsObject = this.buildRouteParams() | ||
215 | |||
216 | const queryParams = Object.keys(paramsObject) | ||
217 | .map(p => p + '=' + paramsObject[p]) | ||
218 | .join('&') | ||
219 | this.location.replaceState(this.currentRoute, queryParams) | ||
220 | } | ||
221 | |||
222 | protected buildVideoPages () { | ||
223 | this.videoPages = Object.values(this.loadedPages) | ||
224 | } | ||
225 | |||
226 | protected adjustVideoPageHeight () { | ||
227 | const numberOfPagesLoaded = Object.keys(this.loadedPages).length | ||
228 | if (!numberOfPagesLoaded) return | ||
229 | |||
230 | this.pageHeight = this.videosElement.nativeElement.offsetHeight / numberOfPagesLoaded | ||
231 | } | ||
232 | |||
233 | protected buildVideoHeight () { | ||
234 | // Same ratios than base width/height | ||
235 | return this.videosElement.nativeElement.offsetWidth * (this.baseVideoHeight / this.baseVideoWidth) | ||
236 | } | ||
237 | |||
238 | private minPageLoaded () { | ||
239 | return Math.min(...Object.keys(this.loadedPages).map(e => parseInt(e, 10))) | ||
240 | } | ||
241 | |||
242 | private maxPageLoaded () { | ||
243 | return Math.max(...Object.keys(this.loadedPages).map(e => parseInt(e, 10))) | ||
244 | } | 125 | } |
245 | 126 | ||
246 | private calcPageSizes () { | 127 | private calcPageSizes () { |
247 | if (this.screenService.isInMobileView() || this.baseVideoWidth === -1) { | 128 | if (this.screenService.isInMobileView()) { |
248 | this.pagination.itemsPerPage = 5 | 129 | this.pagination.itemsPerPage = 5 |
249 | |||
250 | // Video takes all the width | ||
251 | this.videoWidth = -1 | ||
252 | this.videoHeight = this.buildVideoHeight() | ||
253 | this.pageHeight = this.pagination.itemsPerPage * this.videoHeight | ||
254 | } else { | ||
255 | this.videoWidth = this.baseVideoWidth | ||
256 | this.videoHeight = this.baseVideoHeight | ||
257 | |||
258 | const videosWidth = this.videosElement.nativeElement.offsetWidth | ||
259 | this.pagination.itemsPerPage = Math.floor(videosWidth / this.videoWidth) * AbstractVideoList.LINES_PER_PAGE | ||
260 | this.pageHeight = this.videoHeight * AbstractVideoList.LINES_PER_PAGE | ||
261 | } | 130 | } |
131 | } | ||
262 | 132 | ||
263 | // Rebuild pages because maybe we modified the number of items per page | 133 | private setScrollRouteParams () { |
264 | const videos = [].concat(...this.videoPages) | 134 | // Already set |
265 | this.loadedPages = {} | 135 | if (this.angularState) return |
266 | 136 | ||
267 | let i = 1 | 137 | this.angularState = 42 |
268 | // Don't include the last page if it not complete | ||
269 | while (videos.length >= this.pagination.itemsPerPage && i < 10000) { // 10000 -> Hard limit in case of infinite loop | ||
270 | this.loadedPages[i] = videos.splice(0, this.pagination.itemsPerPage) | ||
271 | i++ | ||
272 | } | ||
273 | 138 | ||
274 | // Re fetch the last page | 139 | const queryParams = { |
275 | if (videos.length !== 0) { | 140 | 'a-state': this.angularState, |
276 | this.loadMoreVideos(i) | 141 | categoryOneOf: this.categoryOneOf |
277 | } else { | ||
278 | this.buildVideoPages() | ||
279 | } | 142 | } |
280 | 143 | ||
281 | console.log('Rebuilt pages with %s elements per page.', this.pagination.itemsPerPage) | 144 | let path = this.router.url |
145 | if (!path || path === '/') path = this.serverService.getConfig().instance.defaultClientRoute | ||
146 | |||
147 | this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) | ||
282 | } | 148 | } |
283 | } | 149 | } |
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts index a9e75007c..5f8a1dd6e 100644 --- a/client/src/app/shared/video/infinite-scroller.directive.ts +++ b/client/src/app/shared/video/infinite-scroller.directive.ts | |||
@@ -6,24 +6,15 @@ import { fromEvent, Subscription } from 'rxjs' | |||
6 | selector: '[myInfiniteScroller]' | 6 | selector: '[myInfiniteScroller]' |
7 | }) | 7 | }) |
8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy { | 8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy { |
9 | @Input() containerHeight: number | ||
10 | @Input() pageHeight: number | ||
11 | @Input() firstLoadedPage = 1 | ||
12 | @Input() percentLimit = 70 | 9 | @Input() percentLimit = 70 |
13 | @Input() autoInit = false | 10 | @Input() autoInit = false |
14 | @Input() onItself = false | 11 | @Input() onItself = false |
15 | 12 | ||
16 | @Output() nearOfBottom = new EventEmitter<void>() | 13 | @Output() nearOfBottom = new EventEmitter<void>() |
17 | @Output() nearOfTop = new EventEmitter<void>() | ||
18 | @Output() pageChanged = new EventEmitter<number>() | ||
19 | 14 | ||
20 | private decimalLimit = 0 | 15 | private decimalLimit = 0 |
21 | private lastCurrentBottom = -1 | 16 | private lastCurrentBottom = -1 |
22 | private lastCurrentTop = 0 | ||
23 | private scrollDownSub: Subscription | 17 | private scrollDownSub: Subscription |
24 | private scrollUpSub: Subscription | ||
25 | private pageChangeSub: Subscription | ||
26 | private middleScreen: number | ||
27 | private container: HTMLElement | 18 | private container: HTMLElement |
28 | 19 | ||
29 | constructor (private el: ElementRef) { | 20 | constructor (private el: ElementRef) { |
@@ -36,8 +27,6 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy { | |||
36 | 27 | ||
37 | ngOnDestroy () { | 28 | ngOnDestroy () { |
38 | if (this.scrollDownSub) this.scrollDownSub.unsubscribe() | 29 | if (this.scrollDownSub) this.scrollDownSub.unsubscribe() |
39 | if (this.scrollUpSub) this.scrollUpSub.unsubscribe() | ||
40 | if (this.pageChangeSub) this.pageChangeSub.unsubscribe() | ||
41 | } | 30 | } |
42 | 31 | ||
43 | initialize () { | 32 | initialize () { |
@@ -45,8 +34,6 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy { | |||
45 | this.container = this.el.nativeElement | 34 | this.container = this.el.nativeElement |
46 | } | 35 | } |
47 | 36 | ||
48 | this.middleScreen = window.innerHeight / 2 | ||
49 | |||
50 | // Emit the last value | 37 | // Emit the last value |
51 | const throttleOptions = { leading: true, trailing: true } | 38 | const throttleOptions = { leading: true, trailing: true } |
52 | 39 | ||
@@ -72,40 +59,6 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy { | |||
72 | filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit) | 59 | filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit) |
73 | ) | 60 | ) |
74 | .subscribe(() => this.nearOfBottom.emit()) | 61 | .subscribe(() => this.nearOfBottom.emit()) |
75 | |||
76 | // Scroll up | ||
77 | this.scrollUpSub = scrollObservable | ||
78 | .pipe( | ||
79 | // Check we scroll up | ||
80 | filter(({ current }) => { | ||
81 | const res = this.lastCurrentTop > current | ||
82 | |||
83 | this.lastCurrentTop = current | ||
84 | return res | ||
85 | }), | ||
86 | filter(({ current, maximumScroll }) => { | ||
87 | return current !== 0 && (1 - (current / maximumScroll)) > this.decimalLimit | ||
88 | }) | ||
89 | ) | ||
90 | .subscribe(() => this.nearOfTop.emit()) | ||
91 | |||
92 | // Page change | ||
93 | this.pageChangeSub = scrollObservable | ||
94 | .pipe( | ||
95 | distinct(), | ||
96 | map(({ current }) => this.calculateCurrentPage(current)), | ||
97 | distinctUntilChanged() | ||
98 | ) | ||
99 | .subscribe(res => this.pageChanged.emit(res)) | ||
100 | } | ||
101 | |||
102 | private calculateCurrentPage (current: number) { | ||
103 | const scrollY = current + this.middleScreen | ||
104 | |||
105 | const page = Math.max(1, Math.ceil(scrollY / this.pageHeight)) | ||
106 | |||
107 | // Offset page | ||
108 | return page + (this.firstLoadedPage - 1) | ||
109 | } | 62 | } |
110 | 63 | ||
111 | private getScrollInfo () { | 64 | private getScrollInfo () { |