From a2e2959b894933b783dea999588f64409e4de548 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 25 Apr 2026 14:11:36 +0200 Subject: [PATCH] fix(xtream): pagination behavior (back & scroll) Entire-Checkpoint: c6e522b4276c --- .../category-content-view.component.spec.ts | 123 ++++++++++++++++-- .../category-content-view.component.ts | 33 ++++- .../stalker-live-stream-layout.component.ts | 16 ++- .../features/with-selection.feature.spec.ts | 99 ++++++++++++++ .../stores/features/with-selection.feature.ts | 8 +- .../src/lib/xtream-catalog-facade.service.ts | 25 ++-- 6 files changed, 270 insertions(+), 34 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index ab36f82ea..46311d18b 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -7,11 +7,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatPaginatorModule } from '@angular/material/paginator'; import { MatTooltip } from '@angular/material/tooltip'; -import { - ActivatedRoute, - convertToParamMap, - Router, -} from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ReplaySubject, of } from 'rxjs'; import { @@ -58,6 +54,7 @@ class MockDetailComponent {} describe('CategoryContentViewComponent', () => { let fixture: ComponentFixture; + let router: { navigate: jest.Mock }; const paramMap$ = new ReplaySubject(1); const queryParamMap$ = new ReplaySubject(1); const isPaginatedContentLoading = signal(true); @@ -94,6 +91,13 @@ describe('CategoryContentViewComponent', () => { contentSortMode.set(null); catalog.initialize.mockClear(); catalog.setSearchQuery.mockClear(); + catalog.setPage.mockClear(); + catalog.setLimit.mockClear(); + catalog.selectItem.mockClear(); + catalog.selectItem.mockReturnValue(null); + router = { + navigate: jest.fn(), + }; paramMap$.next(convertToParamMap({})); queryParamMap$.next(convertToParamMap({})); @@ -146,9 +150,7 @@ describe('CategoryContentViewComponent', () => { }, { provide: Router, - useValue: { - navigate: jest.fn(), - }, + useValue: router, }, ], }) @@ -197,4 +199,109 @@ describe('CategoryContentViewComponent', () => { expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); }); + + it('restores the zero-based catalog page from the one-based page query param', () => { + fixture.detectChanges(); + catalog.setPage.mockClear(); + + queryParamMap$.next( + convertToParamMap({ + page: '3', + }) + ); + + expect(catalog.setPage).toHaveBeenCalledWith(2); + }); + + it('falls back to the first catalog page when the page query param is absent or invalid', () => { + fixture.detectChanges(); + catalog.setPage.mockClear(); + + queryParamMap$.next(convertToParamMap({})); + queryParamMap$.next( + convertToParamMap({ + page: 'not-a-page', + }) + ); + + expect(catalog.setPage).toHaveBeenNthCalledWith(1, 0); + expect(catalog.setPage).toHaveBeenNthCalledWith(2, 0); + }); + + it('writes one-based page query params when the paginator changes', () => { + fixture.detectChanges(); + + fixture.componentInstance.onPageChange({ + length: 100, + pageIndex: 1, + pageSize: 25, + previousPageIndex: 0, + }); + + expect(catalog.setPage).toHaveBeenCalledWith(1); + expect(catalog.setLimit).toHaveBeenCalledWith(25); + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.any(Object), + queryParams: { + page: 2, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + + it('removes the page query param when returning to the first page', () => { + fixture.detectChanges(); + + fixture.componentInstance.onPageChange({ + length: 100, + pageIndex: 0, + pageSize: 25, + previousPageIndex: 1, + }); + + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.any(Object), + queryParams: { + page: null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + + it('scrolls the grid list host to the top when the paginator changes', () => { + fixture.detectChanges(); + const gridList = fixture.nativeElement.querySelector( + 'app-grid-list' + ) as HTMLElement; + const scrollTo = jest.fn(); + Object.defineProperty(gridList, 'scrollTo', { + configurable: true, + value: scrollTo, + }); + + fixture.componentInstance.onPageChange({ + length: 100, + pageIndex: 1, + pageSize: 25, + previousPageIndex: 0, + }); + + expect(scrollTo).toHaveBeenCalledWith({ top: 0 }); + }); + + it('preserves query params when navigating from an item to Xtream details', () => { + catalog.selectItem.mockReturnValue(['42']); + fixture.detectChanges(); + + fixture.componentInstance.onItemClick({ + xtream_id: 42, + }); + + expect(router.navigate).toHaveBeenCalledWith(['42'], { + relativeTo: expect.any(Object), + queryParamsHandling: 'preserve', + }); + }); }); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 0f4dd309e..53ecde112 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -3,6 +3,7 @@ import { Component, computed, DestroyRef, + ElementRef, inject, OnInit, } from '@angular/core'; @@ -57,9 +58,12 @@ interface CategoryContentItem { export class CategoryContentViewComponent implements OnInit { private readonly activatedRoute = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); + private readonly hostElement = inject(ElementRef); private readonly router = inject(Router); private readonly translate = inject(TranslateService); - private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade< + private readonly catalog = inject( + PORTAL_CATALOG_FACADE + ) as PortalCatalogFacade< CategoryContentItem, CategoryContentItem, CategoryContentItem @@ -77,8 +81,7 @@ export class CategoryContentViewComponent implements OnInit { readonly selectedItem = this.catalog.selectedItem; readonly totalPages = this.catalog.totalPages; readonly contentSortMode = this.catalog.contentSortMode; - readonly isPaginatedContentLoading = - this.catalog.isPaginatedContentLoading; + readonly isPaginatedContentLoading = this.catalog.isPaginatedContentLoading; readonly isXtreamLoadingSubtitle = computed( () => this.catalog.provider === 'xtream' && @@ -121,12 +124,23 @@ export class CategoryContentViewComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { this.catalog.setSearchQuery?.(params.get('q') ?? ''); + this.catalog.setPage(this.toPageIndex(params.get('page'))); }); } onPageChange(event: PageEvent): void { this.catalog.setPage(event.pageIndex); this.catalog.setLimit(event.pageSize); + this.scrollGridToTop(); + + void this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { + page: event.pageIndex > 0 ? event.pageIndex + 1 : null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); } onItemClick(item: CategoryContentItem): void { @@ -134,10 +148,23 @@ export class CategoryContentViewComponent implements OnInit { if (navigation?.length) { this.router.navigate(navigation, { relativeTo: this.activatedRoute, + queryParamsHandling: 'preserve', }); } } + private toPageIndex(value: string | null): number { + const page = Number(value); + return Number.isInteger(page) && page > 0 ? page - 1 : 0; + } + + private scrollGridToTop(): void { + const gridList = this.hostElement.nativeElement.querySelector( + 'app-grid-list' + ) as HTMLElement | null; + gridList?.scrollTo?.({ top: 0 }); + } + private openStalkerItemFromNavigationState(): void { if (this.catalog.provider !== 'stalker') { return; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 5c5dc5685..832fe349a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -19,7 +19,12 @@ import { ResizableDirective, } from 'components'; import { PlaylistsService } from 'services'; -import { Channel, EpgItem, EpgProgram } from 'shared-interfaces'; +import { + Channel, + EpgItem, + EpgProgram, + ResolvedPortalPlayback, +} from 'shared-interfaces'; import { EpgListComponent } from '@iptvnator/ui/epg'; import { WebPlayerViewComponent } from 'shared-portals'; import { @@ -89,7 +94,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); - streamUrl = ''; + readonly activePlayback = signal(null); + readonly streamUrl = computed( + () => this.activePlayback()?.streamUrl ?? '' + ); /** EPG */ readonly fallbackEpgPrograms = signal([]); @@ -116,7 +124,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return { id: channelId, name: channelName, - url: this.streamUrl || String(selectedItem.cmd ?? ''), + url: this.streamUrl() || String(selectedItem.cmd ?? ''), group: { title: '' }, tvg: { id: channelId, @@ -294,7 +302,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { void this.loadEpgForChannel(item); if (this.usesEmbeddedPlayer()) { - this.streamUrl = playback.streamUrl; + this.activePlayback.set(playback); } else { void this.portalPlayer.openResolvedPlayback(playback, true); } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts new file mode 100644 index 000000000..ad797e478 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore, withState } from '@ngrx/signals'; +import { withSelection } from './with-selection.feature'; + +const TestSelectionStore = signalStore( + withState({ + contentLoadStateByType: { + live: 'ready', + vod: 'ready', + series: 'ready', + }, + liveCategories: [], + liveStreams: [], + vodCategories: [ + { + id: 10, + category_id: '10', + category_name: 'Movies', + type: 'vod', + }, + ], + vodStreams: [ + { + xtream_id: 1, + category_id: '10', + title: 'First', + added: '4', + }, + { + xtream_id: 2, + category_id: '10', + title: 'Second', + added: '3', + }, + { + xtream_id: 3, + category_id: '10', + title: 'Third', + added: '2', + }, + { + xtream_id: 4, + category_id: '10', + title: 'Fourth', + added: '1', + }, + ], + serialCategories: [], + serialStreams: [], + }), + withSelection() +); + +describe('withSelection', () => { + let store: InstanceType; + + beforeEach(() => { + localStorage.clear(); + + TestBed.configureTestingModule({ + providers: [TestSelectionStore], + }); + + store = TestBed.inject(TestSelectionStore); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('keeps the current page when the category search term is unchanged', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(10); + store.setLimit(2); + store.setPage(1); + + store.setCategorySearchTerm(''); + + expect(store.page()).toBe(1); + expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ + 'Third', + 'Fourth', + ]); + }); + + it('resets the current page when the category search term changes', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(10); + store.setLimit(2); + store.setPage(1); + + store.setCategorySearchTerm('first'); + + expect(store.page()).toBe(0); + expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ + 'First', + ]); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 7f48046fb..4e139b1f0 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -245,8 +245,8 @@ export function withSelection() { const selectedTypeContentReady = computed( () => selectedTypeContentState() === 'ready' ); - const selectedTypeCountsReady = computed( - () => selectedTypeContentReady() + const selectedTypeCountsReady = computed(() => + selectedTypeContentReady() ); // --------------------------------------------------------------------------- @@ -502,6 +502,10 @@ export function withSelection() { * Set selected category search term */ setCategorySearchTerm(term: string): void { + if (store.categorySearchTerm() === term) { + return; + } + patchState(store, { categorySearchTerm: term, page: 0, diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 40b81b61e..79f7c40ba 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -11,12 +11,12 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; const SORT_STORAGE_KEY = 'xtream-category-sort-mode'; @Injectable() -export class XtreamCatalogFacadeService - implements - PortalCatalogFacade, Record, unknown> -{ +export class XtreamCatalogFacadeService implements PortalCatalogFacade< + Record, + Record, + unknown +> { private readonly xtreamStore = inject(XtreamStore); - private savedPageBeforeDetail: number | null = null; private loadedPositionsPlaylistId: string | null = null; readonly provider = 'xtream' as const; @@ -34,8 +34,8 @@ export class XtreamCatalogFacadeService const category = this.selectedCategory(); return String(category?.['name'] ?? category?.['title'] ?? ''); }); - readonly categoryItemCount = computed(() => - this.xtreamStore.selectItemsFromSelectedCategory().length + readonly categoryItemCount = computed( + () => this.xtreamStore.selectItemsFromSelectedCategory().length ); readonly contentSortMode = this.xtreamStore.contentSortMode; readonly playlist = computed(() => { @@ -74,11 +74,6 @@ export class XtreamCatalogFacadeService } else { this.xtreamStore.setSelectedCategory(null); } - - if (this.savedPageBeforeDetail !== null) { - this.xtreamStore.setPage(this.savedPageBeforeDetail); - this.savedPageBeforeDetail = null; - } } clearSelectedItem(): void { @@ -103,8 +98,6 @@ export class XtreamCatalogFacadeService } selectItem(item: Record): string[] | null { - this.savedPageBeforeDetail = this.xtreamStore.page(); - const xtreamId = item['xtream_id']; if (xtreamId === undefined || xtreamId === null) { return null; @@ -123,9 +116,7 @@ export class XtreamCatalogFacadeService return [String(categoryId), String(xtreamId)]; } - getItemProgress( - item: Record - ): PortalCatalogItemProgress { + getItemProgress(item: Record): PortalCatalogItemProgress { const isSeries = this.contentType() === 'series'; const itemId = Number( item['xtream_id'] ?? item['series_id'] ?? item['stream_id']