From 8885b7c0db4d651e061469a174b3c7fee921be63 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 6 Aug 2025 01:18:35 +0200 Subject: [PATCH 1/3] perf: Add virtual scrolling Attempting to improve table performance by only render visible dom elements. --- package.json | 1 + src-angular/app/app.module.ts | 4 + .../result-table/result-table.component.html | 101 ++++++++++-------- .../result-table/result-table.component.ts | 70 ++++++++---- 4 files changed, 112 insertions(+), 64 deletions(-) diff --git a/package.json b/package.json index 4dfd309..a3ddfef 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@angular/animations": "19.2.1", + "@angular/cdk": "^19.2.19", "@angular/common": "19.2.1", "@angular/compiler": "19.2.1", "@angular/core": "19.2.1", diff --git a/src-angular/app/app.module.ts b/src-angular/app/app.module.ts index 8511388..986553e 100644 --- a/src-angular/app/app.module.ts +++ b/src-angular/app/app.module.ts @@ -1,3 +1,5 @@ +import { ScrollingModule } from '@angular/cdk/scrolling' +import { CommonModule } from '@angular/common' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { NgModule } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' @@ -38,9 +40,11 @@ import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe' ], bootstrap: [AppComponent], imports: [ BrowserModule, + CommonModule, AppRoutingModule, FormsModule, ReactiveFormsModule, + ScrollingModule, ], providers: [provideHttpClient(withInterceptorsFromDi())], }) export class AppModule { } diff --git a/src-angular/app/components/browse/result-table/result-table.component.html b/src-angular/app/components/browse/result-table/result-table.component.html index 01485aa..62ad9ce 100644 --- a/src-angular/app/components/browse/result-table/result-table.component.html +++ b/src-angular/app/components/browse/result-table/result-table.component.html @@ -1,51 +1,66 @@ -
- - - - - - - - - - - - - - - - - @for (song of songs; track song) { +
+
+
- - - Name - - Artist - - - Album - - Genre - - Year - - Charter - - Length (min) - Difficulty - Upload Date -
+ + + +
+
+ + + + + + + + - } - -
+ + +
+ + + + + + + + Name + + + Artist + + + + Album + + + Genre + + + Year + + + Charter + + + Length (min) + + Difficulty + + Upload Date + + + diff --git a/src-angular/app/components/browse/result-table/result-table.component.ts b/src-angular/app/components/browse/result-table/result-table.component.ts index 763ff3e..090d913 100644 --- a/src-angular/app/components/browse/result-table/result-table.component.ts +++ b/src-angular/app/components/browse/result-table/result-table.component.ts @@ -1,12 +1,13 @@ -import { Component, ElementRef, EventEmitter, HostBinding, HostListener, OnInit, Output, QueryList, ViewChild, ViewChildren } from '@angular/core' +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling' +import { Component, EventEmitter, HostBinding, HostListener, OnInit, Output, ViewChild } from '@angular/core' import { Router } from '@angular/router' +import { Subscription } from 'rxjs' import { SettingsService } from 'src-angular/app/core/services/settings.service' import { ChartData } from 'src-shared/interfaces/search.interface' import { SearchService } from '../../../core/services/search.service' import { SelectionService } from '../../../core/services/selection.service' -import { ResultTableRowComponent } from './result-table-row/result-table-row.component' @Component({ selector: 'app-result-table', @@ -18,12 +19,14 @@ export class ResultTableComponent implements OnInit { @Output() rowClicked = new EventEmitter() - @ViewChild('resultTableDiv', { static: true }) resultTableDiv: ElementRef - @ViewChildren('tableRow') tableRows: QueryList + @ViewChild('viewport', { static: true }) viewport: CdkVirtualScrollViewport activeSong: ChartData[] | null = null sortDirection: 'asc' | 'desc' = 'asc' sortColumn: 'name' | 'artist' | 'album' | 'genre' | 'year' | 'charter' | 'length' | 'modifiedTime' | null = null + isLoadingMore = false + songs: ChartData[][] = [] + subscription: Subscription[] = [] constructor( public searchService: SearchService, @@ -33,18 +36,48 @@ export class ResultTableComponent implements OnInit { ) { } ngOnInit() { - this.searchService.newSearch.subscribe(() => { - this.resultTableDiv.nativeElement.scrollTop = 0 - this.activeSong = null - setTimeout(() => this.tableScrolled(), 0) - }) - this.searchService.updateSearch.subscribe(() => { - setTimeout(() => this.tableScrolled(), 0) - }) + this.subscription.push( + this.searchService.newSearch.subscribe(() => { + if (this.viewport) { + this.viewport.scrollToIndex(0) + } + this.activeSong = null + this.isLoadingMore = false + this.songs = [...this.searchService.groupedSongs] + }) + ) + + this.subscription.push( + this.searchService.updateSearch.subscribe(() => { + this.isLoadingMore = false + this.songs = [...this.searchService.groupedSongs] + }) + ) + } + + onViewportScroll(): void { + if (!this.viewport || this.router.url !== '/browse' || this.isLoadingMore) { + return + } + + const viewportElement = this.viewport.elementRef.nativeElement + const scrollTop = viewportElement.scrollTop + const scrollHeight = viewportElement.scrollHeight + const clientHeight = viewportElement.clientHeight + const threshold = 100 + + if (scrollHeight - (scrollTop + clientHeight) < threshold) { + this.isLoadingMore = true + this.searchService.getNextSearchPage() + } + } + + trackByFn(_: number, song: ChartData[]): number { + return song[0].groupId } - get songs() { - return this.searchService.groupedSongs + get tableRowHeight(): number { + return this.settingsService.isCompactTable ? 32 : 48 } hasColumn(column: string) { @@ -88,13 +121,8 @@ export class ResultTableComponent implements OnInit { @HostListener('window:resize', ['$event']) onResize() { - this.tableScrolled() - } - tableScrolled(): void { - const table = this.resultTableDiv.nativeElement - if (this.router.url === '/browse' && table.scrollHeight - (table.scrollTop + table.clientHeight) < 100) { - // Scrolled near the bottom of the table - this.searchService.getNextSearchPage() + if (this.viewport) { + this.viewport.checkViewportSize() } } } From 57e8cea5d086b81a9970bf8b73dd8e1319b1db62 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 6 Aug 2025 01:25:35 +0200 Subject: [PATCH 2/3] Fix missing unsubscribe prevent potential performance issue when switching tabs too many times. --- .../browse/result-table/result-table.component.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src-angular/app/components/browse/result-table/result-table.component.ts b/src-angular/app/components/browse/result-table/result-table.component.ts index 090d913..cc05ac0 100644 --- a/src-angular/app/components/browse/result-table/result-table.component.ts +++ b/src-angular/app/components/browse/result-table/result-table.component.ts @@ -1,5 +1,5 @@ import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling' -import { Component, EventEmitter, HostBinding, HostListener, OnInit, Output, ViewChild } from '@angular/core' +import { Component, EventEmitter, HostBinding, HostListener, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' import { Router } from '@angular/router' import { Subscription } from 'rxjs' @@ -14,7 +14,7 @@ import { SelectionService } from '../../../core/services/selection.service' templateUrl: './result-table.component.html', standalone: false, }) -export class ResultTableComponent implements OnInit { +export class ResultTableComponent implements OnInit, OnDestroy { @HostBinding('class.contents') contents = true @Output() rowClicked = new EventEmitter() @@ -125,4 +125,8 @@ export class ResultTableComponent implements OnInit { this.viewport.checkViewportSize() } } + + ngOnDestroy(): void { + this.subscription.forEach(sub => sub.unsubscribe()) + } } From 2925a5a2370ce8c59b1663acc78ab3617f311919 Mon Sep 17 00:00:00 2001 From: Myx Date: Wed, 6 Aug 2025 03:19:51 +0200 Subject: [PATCH 3/3] fix: Column spaceing and selection --- .../result-table/result-table.component.html | 79 ++++++++----------- .../result-table/result-table.component.ts | 9 +++ .../app/core/services/selection.service.ts | 6 +- 3 files changed, 45 insertions(+), 49 deletions(-) diff --git a/src-angular/app/components/browse/result-table/result-table.component.html b/src-angular/app/components/browse/result-table/result-table.component.html index 62ad9ce..10d8154 100644 --- a/src-angular/app/components/browse/result-table/result-table.component.html +++ b/src-angular/app/components/browse/result-table/result-table.component.html @@ -1,21 +1,42 @@
-
- - - - -
-
- - - - - +
+ + + + + + + + + + + + + - - - - - - - - - - - - - - - diff --git a/src-angular/app/components/browse/result-table/result-table.component.ts b/src-angular/app/components/browse/result-table/result-table.component.ts index cc05ac0..0d2cde1 100644 --- a/src-angular/app/components/browse/result-table/result-table.component.ts +++ b/src-angular/app/components/browse/result-table/result-table.component.ts @@ -119,6 +119,15 @@ export class ResultTableComponent implements OnInit, OnDestroy { } } + public get tableHeaderPosition(): string { + if (!this.viewport) { + return '-0px' + } + const offset = this.viewport.getOffsetToRenderedContentStart() + + return `-${offset}px` + } + @HostListener('window:resize', ['$event']) onResize() { if (this.viewport) { diff --git a/src-angular/app/core/services/selection.service.ts b/src-angular/app/core/services/selection.service.ts index 67d2f69..4979b23 100644 --- a/src-angular/app/core/services/selection.service.ts +++ b/src-angular/app/core/services/selection.service.ts @@ -12,7 +12,7 @@ export class SelectionService { public selections: { [groupId: number]: boolean | undefined } = {} - constructor(searchService: SearchService) { + constructor(private searchService: SearchService) { searchService.newSearch.subscribe(() => { this.selections = {} this.deselectAll() @@ -33,8 +33,8 @@ export class SelectionService { selectAll() { this.allSelected = true - for (const groupId in this.selections) { - this.selections[groupId] = true + for (const song of this.searchService.groupedSongs) { + this.selections[song[0].groupId] = true } this.selectAllChangedEmitter.emit(true) }
+ + + Name + + Artist + + + Album + + Genre + + Year + + Charter + + Length (min) + Difficulty + Upload Date +
- - - Name - - Artist - - - Album - - Genre - - Year - - Charter - - Length (min) - Difficulty - Upload Date -