diff --git a/.changes/stalker-infinite-scroll-catalog.md b/.changes/stalker-infinite-scroll-catalog.md new file mode 100644 index 000000000..738003614 --- /dev/null +++ b/.changes/stalker-infinite-scroll-catalog.md @@ -0,0 +1,10 @@ +--- +type: feature +area: stalker +--- + +Stalker movie and series catalogs now load continuously as you scroll — +portal pages append into one seamless list, tall windows fill themselves, and +a failed page keeps what's loaded and offers a retry. The Live TV "all +channels" grid and portal search follow the same style, and search can page +past its first hundred results. Pagination is gone from the app entirely. diff --git a/CLAUDE.md b/CLAUDE.md index 1461d33e3..6b81be8bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -422,11 +422,12 @@ Key patterns: (`libs/portal/shared/ui`) measures container overflow to auto-fill tall viewports (terminating on lack of container growth, not on a load count) and fires `loadMore` near the bottom. The search layout routes its results - container through the same directive (`nearEnd*` inputs). Transitional: - `PortalCatalogFacade.supportsInfiniteScroll` gates the shared - `CategoryContentViewComponent` — Xtream scrolls, Stalker still pages until - its server-paged append lands, after which the paged facade members and - the flag are deleted + container through the same directive (`nearEnd*` inputs). Stalker feeds the + same contract from server-paged appends: portal pages accumulate into one + deduplicated list, `hasMoreContent` derives from accumulated length vs + `total_items`, a failed append keeps loaded pages and offers a tail retry, + and the facade maps page 0 to the skeleton and later pages to the tail + spinner. No paginator remains anywhere in the app Xtream data strategies by runtime capability: diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index 35f9d02f7..f8fe41f85 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -3,7 +3,6 @@ import { addStalkerPortal, addXtreamPortal, clickCategoryByNameExact, - clickFirstGridListCard, closeElectronApp, defaultXtreamPassword, defaultXtreamUsername, @@ -289,7 +288,7 @@ test.describe('Electron Catalog Sorting', () => { } }); - test('resets Stalker VOD and series grid scroll when changing pages', async ({ + test('appends Stalker VOD and series portal pages on scroll and restores the spot after a detail round trip', async ({ dataDir, request, }) => { @@ -311,9 +310,12 @@ test.describe('Electron Catalog Sorting', () => { vodFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); + await expectNoCatalogPaginator(app.mainWindow); const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow); - await expectCatalogScrollResetAfterNextPage(app.mainWindow, { - expectContentChange: false, + + await expectStalkerCatalogAppendsOnScroll(app.mainWindow, { + categoryId: vodFixture.categoryId, + type: 'vod', }); await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, { categoryId: vodFixture.categoryId, @@ -321,10 +323,7 @@ test.describe('Electron Catalog Sorting', () => { type: 'vod', }); await clearCatalogSearch(app.mainWindow); - await expectCatalogScrollResetAfterNextPage(app.mainWindow, { - expectContentChange: false, - }); - await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow); + await expectDetailRoundTripRestoresScroll(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Series'); await clickCategoryByVisibleName( @@ -332,20 +331,12 @@ test.describe('Electron Catalog Sorting', () => { seriesFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); - const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow); - await expectCatalogScrollResetAfterNextPage(app.mainWindow, { - expectContentChange: false, - }); - await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, { + await expectNoCatalogPaginator(app.mainWindow); + await expectStalkerCatalogAppendsOnScroll(app.mainWindow, { categoryId: seriesFixture.categoryId, - title: seriesSearchTitle, type: 'series', }); - await clearCatalogSearch(app.mainWindow); - await expectCatalogScrollResetAfterNextPage(app.mainWindow, { - expectContentChange: false, - }); - await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow); + await expectDetailRoundTripRestoresScroll(app.mainWindow); } finally { await closeElectronApp(app); } @@ -420,36 +411,6 @@ async function expectCatalogGridReady(page: Page): Promise { }); } -async function expectCatalogScrollResetAfterNextPage( - page: Page, - options: { expectContentChange?: boolean } = {} -): Promise { - const grid = catalogGrid(page); - - await expect(grid).toBeVisible({ timeout: 20000 }); - await ensureCatalogCanGoNext(page); - const rangeBefore = await catalogRangeText(page); - const titlesBefore = await visibleGridTitles(page); - await grid.evaluate((element: HTMLElement) => { - element.scrollTo({ top: element.scrollHeight }); - }); - await expect.poll(() => getCatalogGridScrollTop(page)).toBeGreaterThan(0); - - await page - .locator('.category-content-header') - .getByRole('button', { name: 'Next page' }) - .click(); - await expectCatalogPageQuery(page, '2'); - await expect.poll(() => catalogRangeText(page)).not.toBe(rangeBefore); - if (options.expectContentChange !== false) { - await expect - .poll(() => visibleGridTitles(page)) - .not.toEqual(titlesBefore); - } - await expect.poll(() => getCatalogGridScrollTop(page)).toBeLessThan(2); - return visibleGridTitles(page); -} - async function expectCatalogSearchResetsToFirstPage( page: Page, title: string @@ -498,7 +459,7 @@ async function expectCatalogGrowsOnScroll(page: Page): Promise { */ async function expectDetailRoundTripRestoresScroll( page: Page, - detailPathname: RegExp + detailPathname?: RegExp ): Promise { const grownCount = await expectCatalogGrowsOnScroll(page); await expect.poll(() => getCatalogGridScrollTop(page)).toBeGreaterThan(100); @@ -506,7 +467,11 @@ async function expectDetailRoundTripRestoresScroll( // The last card is already in view at the bottom — clicking it does not // make Playwright scroll the grid back to the top first. await page.locator('.category-content-layout mat-card').last().click(); - await expectPathname(page, detailPathname); + if (detailPathname) { + // Xtream details are routed; Stalker details render inline on the + // same URL, so callers without a pathname skip the assertion. + await expectPathname(page, detailPathname); + } await goBackFromDetail(page); await expectCatalogGridReady(page); @@ -519,6 +484,52 @@ async function expectDetailRoundTripRestoresScroll( .toBeGreaterThan(100); } +/** + * Proves the Stalker grid accumulates portal pages: either the measured + * auto-fill already fetched past page one, or scrolling to the bottom does. + * Portal pages hold 14 items, so any larger count means appends happened. + */ +async function expectStalkerCatalogAppendsOnScroll( + page: Page, + options: { categoryId: string; type: 'series' | 'vod' } +): Promise { + const grid = catalogGrid(page); + await expect(grid).toBeVisible({ timeout: 20000 }); + + const totalText = await page + .locator('.category-content-header .category-subtitle') + .first() + .textContent(); + const totalItems = Number(/\d+/.exec(totalText ?? '')?.[0] ?? 0); + const countBefore = await catalogCardCount(page); + if (countBefore < totalItems) { + await grid.evaluate((element: HTMLElement) => { + element.scrollTo({ top: element.scrollHeight }); + }); + await expect + .poll(() => catalogCardCount(page), { timeout: 20000 }) + .toBeGreaterThan(countBefore); + } + + await waitForPortalDebugEvent(page, { + provider: 'stalker', + operation: 'get_ordered_list', + predicate: (event) => { + const requestPayload = event.request as { + params?: Record; + }; + + return ( + requestPayload.params?.['type'] === options.type && + String(requestPayload.params?.['category']) === + options.categoryId && + Number(requestPayload.params?.['p'] ?? 0) >= 2 + ); + }, + }); + expect(await catalogCardCount(page)).toBeGreaterThan(14); +} + async function expectStalkerCatalogSearchResetsToFirstPage( page: Page, options: { categoryId: string; title: string; type: 'series' | 'vod' } @@ -549,19 +560,6 @@ async function clearCatalogSearch(page: Page): Promise { await expectCatalogPageQuery(page, null); } -async function expectStalkerDetailBackPreservesCatalogPage( - page: Page -): Promise { - const titlesOnPage = await visibleGridTitles(page); - - await clickFirstGridListCard(page); - await goBackFromDetail(page); - - await expectCatalogPageQuery(page, '2'); - await expectCatalogGridReady(page); - await expect.poll(() => visibleGridTitles(page)).toEqual(titlesOnPage); -} - async function expectCatalogPageQuery( page: Page, expectedPage: string | null @@ -636,31 +634,6 @@ function catalogGridCardByTitle(page: Page, title: string) { }); } -async function ensureCatalogCanGoNext(page: Page): Promise { - const header = page.locator('.category-content-header'); - const nextButton = header.getByRole('button', { name: 'Next page' }); - - await expect(nextButton).toBeVisible({ timeout: 20000 }); - - if (await nextButton.isDisabled()) { - await header.getByRole('button', { name: 'Previous page' }).click(); - await expect - .poll(() => new URL(page.url()).searchParams.get('page')) - .toBe(null); - } - - await expect(nextButton).toBeEnabled({ timeout: 20000 }); -} - -async function catalogRangeText(page: Page): Promise { - return ( - (await page - .locator('.category-content-header .mat-mdc-paginator-range-label') - .first() - .textContent()) ?? '' - ).trim(); -} - async function getCatalogGridScrollTop(page: Page): Promise { return catalogGrid(page).evaluate((element: HTMLElement) => Math.round(element.scrollTop) diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index e7539d9d0..763da1b1d 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -576,9 +576,12 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({ timeout: 20_000, }); - await expect( - allItemsGrid.locator('.mat-mdc-paginator-range-label') - ).toContainText('of 320'); + // The grid is an infinite-scroll window over the cached full list — no + // paginator; the subtitle reports the complete channel count. + await expect(allItemsGrid.locator('mat-paginator')).toHaveCount(0); + await expect(allItemsGrid.locator('.category-subtitle')).toContainText( + '320' + ); await expect(categories.nth(0).locator('.item-count')).toHaveText('320', { timeout: 10_000, }); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index d7cf475ff..8c53863c2 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -418,10 +418,6 @@ shrink into what is left. Prefer removing a control over shrinking everything around it: - Keyboard-only affordances — the `⌘K` badge, the shortcuts button. -- The `mat-paginator` page-size select, which is the widest part of the - control and the least useful one on a phone. The range and arrows stay. - (Only Stalker catalog routes still render a paginator — Xtream catalogs use - infinite scroll and have none.) - Counts and subtitles that a neighbouring control already states. Never drop the only way back to a hidden surface. A collapse toggle that is diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index ebe31290d..e5dd7b246 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -241,7 +241,17 @@ Stalker store is now feature-composed: Important store responsibilities: - Selected content/category/item state -- Category and paginated content resources +- Category and content resources. VOD/series content is an infinite-scroll + append: portal pages (server-side size, typically 14) accumulate into one + deduplicated `paginatedContent` list; page 1 replaces it, `hasMoreContent` + derives from the accumulated length versus `total_items` (so a portal that + ignores requested page sizes still terminates), and a failed page > 1 sets + `appendError` while keeping the accumulated pages on screen — + `retryContentPage()` re-runs the same page via the resource's `reload()`. + The facade splits the resource's loading flag by page: page 0 is the grid + skeleton, later pages are the tail spinner, and `loadMore()` refuses to + advance past an unresolved append error (a skipped page would leave a + silent hole in the list). - ITV channel list + pagination (full-list session cache when the portal supports it, legacy 14-per-page lazy loading otherwise) - Radio category/station list + pagination @@ -1189,13 +1199,14 @@ list: from an effect in `StalkerLiveStreamLayoutComponent` — not from the first category click), so the count badges and the all-channels view are available right away. Before a category is selected, the main area shows - `StalkerItvAllItemsComponent` — a paginated card grid of every channel in - the portal (client-side pagination only; it must never touch the store's - legacy `page` state, which would re-fire portal requests). Clicking a card - runs the same `playChannel` flow as the sidebar. Portals without a usable - full list keep the "select a category" placeholder. -- Scope: ITV only. VOD/series keep server-side search; radio keeps legacy - paging (station lists are small). + `StalkerItvAllItemsComponent` — an infinite-scroll card grid of every + channel in the portal (a purely client-side render window over the cached + list; it must never touch the store's legacy `page` state, which would + re-fire portal requests). Clicking a card runs the same `playChannel` flow + as the sidebar. Portals without a usable full list keep the "select a + category" placeholder. +- Scope: ITV only. VOD/series append server pages on scroll and page their + search portal-side; radio keeps legacy paging (station lists are small). - The stalker-mock-server implements `get_all_channels` and provides the `legacy-pagination` scenario MAC (`00:1A:79:00:00:06`) to exercise the crawl fallback. diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index ef4d875a3..743881f77 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -204,16 +204,6 @@ } } - @if (!supportsInfiniteScroll && categoryItemCount() > 0) { - - } { const appendError = signal(false); const catalog = { provider: 'xtream' as 'xtream' | 'stalker', - supportsInfiniteScroll: false as boolean, - pageSizeOptions: [10, 25, 50], contentType: signal('vod'), - limit: signal(25), - pageIndex: signal(0), selectedCategory: signal({ id: 1 }), paginatedContent: signal([]), selectedCategoryTitle: signal('Movies'), categoryItemCount, selectedItem, - totalPages: signal(0), hasMore, isAppending, appendError, @@ -92,8 +86,6 @@ describe('CategoryContentViewComponent', () => { initialize: jest.fn(), setSearchQuery: jest.fn(), clearSelectedItem: jest.fn(), - setPage: jest.fn(), - setLimit: jest.fn(), loadMore: jest.fn(), retryAppend: jest.fn(), saveScrollPosition: jest.fn(), @@ -108,7 +100,6 @@ describe('CategoryContentViewComponent', () => { beforeEach(async () => { window.history.replaceState({}, '', window.location.href); catalog.provider = 'xtream'; - catalog.supportsInfiniteScroll = false; selectedItem.set(null); isPaginatedContentLoading.set(true); categoryItemCount.set(0); @@ -120,8 +111,6 @@ describe('CategoryContentViewComponent', () => { appendError.set(false); catalog.initialize.mockClear(); catalog.setSearchQuery.mockClear(); - catalog.setPage.mockClear(); - catalog.setLimit.mockClear(); catalog.loadMore.mockClear(); catalog.retryAppend.mockClear(); catalog.saveScrollPosition.mockClear(); @@ -201,7 +190,6 @@ describe('CategoryContentViewComponent', () => { MatIcon, MatButtonModule, MatMenuModule, - MatPaginatorModule, MatTooltip, TranslatePipe, ], @@ -342,155 +330,6 @@ describe('CategoryContentViewComponent', () => { ).toContain('9.0+'); }); - 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('preserves the initial search and page query params on direct route loads', () => { - queryParamMap$.next( - convertToParamMap({ - q: 'matrix', - page: '3', - }) - ); - - fixture.detectChanges(); - - expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); - expect(catalog.setPage).toHaveBeenCalledWith(2); - expect(router.navigate).not.toHaveBeenCalled(); - }); - - it('resets to the first page and removes stale page query params when search changes', () => { - fixture.detectChanges(); - catalog.setPage.mockClear(); - - queryParamMap$.next( - convertToParamMap({ - q: 'matrix', - page: '3', - }) - ); - - expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); - expect(catalog.setPage).toHaveBeenCalledWith(0); - expect(router.navigate).toHaveBeenCalledWith([], { - relativeTo: expect.any(Object), - queryParams: { - page: null, - }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); - }); - - it('restores page changes while the search query is unchanged', () => { - queryParamMap$.next( - convertToParamMap({ - q: 'matrix', - }) - ); - fixture.detectChanges(); - catalog.setPage.mockClear(); - - queryParamMap$.next( - convertToParamMap({ - q: 'matrix', - page: '3', - }) - ); - - expect(catalog.setPage).toHaveBeenCalledWith(2); - expect(router.navigate).not.toHaveBeenCalled(); - }); - - 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(); @@ -595,10 +434,9 @@ describe('CategoryContentViewComponent', () => { }); function createInfiniteFixture(): ComponentFixture { - // The outer paged fixture shares ApplicationRef: an app-wide tick - // would run its ngOnInit and let it consume the same query params. + // The outer fixture shares ApplicationRef: an app-wide tick would + // run its ngOnInit and let it consume the same query params. fixture.destroy(); - catalog.supportsInfiniteScroll = true; isPaginatedContentLoading.set(false); return TestBed.createComponent(CategoryContentViewComponent); } @@ -631,7 +469,6 @@ describe('CategoryContentViewComponent', () => { infiniteFixture.detectChanges(); - expect(catalog.setPage).not.toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith( [], expect.objectContaining({ 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 27675b5e1..5a5cf175f 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 @@ -16,7 +16,6 @@ import { map } from 'rxjs/operators'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -59,7 +58,6 @@ interface CategoryContentItem { MatButtonModule, MatIcon, MatMenuModule, - MatPaginatorModule, MatTooltip, NgComponentOutlet, PlaylistErrorViewComponent, @@ -73,8 +71,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly providerOnlyStalkerItemId = signal(null); - private hasAppliedInitialQueryParams = false; - private previousSearchQuery: string | null = null; private readonly catalog = inject( PORTAL_CATALOG_FACADE ) as PortalCatalogFacade< @@ -85,16 +81,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT); readonly contentType = this.catalog.contentType; - /** - * Transitional (pagination removal, PR 1): infinite scroll drives Xtream, - * while a facade without the capability (Stalker) keeps the paginator and - * the `?page=` round-trip below. PR 2 deletes the paged branch. - */ - readonly supportsInfiniteScroll = - this.catalog.supportsInfiniteScroll === true; - readonly limit = this.catalog.limit ?? computed(() => 0); - readonly pageIndex = this.catalog.pageIndex ?? computed(() => 0); - readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions ?? []); readonly selectedCategory = this.catalog.selectedCategory; readonly paginatedContent = this.catalog.paginatedContent; readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle; @@ -102,16 +88,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { readonly selectedItem = this.catalog.selectedItem; readonly contentSortMode = this.catalog.contentSortMode; readonly isPaginatedContentLoading = this.catalog.isPaginatedContentLoading; - readonly infiniteHasMore = computed( - () => - this.supportsInfiniteScroll && (this.catalog.hasMore?.() ?? false) - ); - readonly infiniteAppending = computed( - () => this.catalog.isAppending?.() ?? false - ); - readonly infiniteAppendError = computed( - () => this.catalog.appendError?.() ?? false - ); + readonly infiniteHasMore = this.catalog.hasMore; + readonly infiniteAppending = this.catalog.isAppending; + readonly infiniteAppendError = this.catalog.appendError; readonly isXtreamLoadingSubtitle = computed( () => this.catalog.provider === 'xtream' && @@ -215,10 +194,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { constructor() { effect(() => { const resetKey = this.gridResetKey(); - if (!this.supportsInfiniteScroll) { - return; - } - if ( this.previousGridResetKey !== null && this.previousGridResetKey !== resetKey @@ -230,13 +205,18 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { // One-shot scroll restore after a detail round-trip: once the list is // rendered (not loading, no detail overlay), ask the facade for a - // saved position matching the current selection. + // saved position matching the current selection. An open detail + // re-arms the shot — Stalker details render inline in THIS instance, + // so closing one must restore just like a route round-trip does. effect(() => { + if (this.selectedItem()) { + this.hasAttemptedScrollRestore = false; + return; + } + if ( - !this.supportsInfiniteScroll || this.hasAttemptedScrollRestore || - this.isPaginatedContentLoading() || - this.selectedItem() + this.isPaginatedContentLoading() ) { return; } @@ -278,42 +258,13 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { this.activatedRoute.queryParamMap .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { - const searchQuery = params.get('q') ?? ''; + this.catalog.setSearchQuery?.(params.get('q') ?? ''); - this.catalog.setSearchQuery?.(searchQuery); - - if (this.supportsInfiniteScroll) { - // A search change resets the render window in the store; - // only a stale `?page=` from a legacy deep link needs - // cleaning up here. - if (params.has('page')) { - this.clearPageQueryParam(); - } - return; + // A search change resets the list in the facade; only a stale + // `?page=` from a legacy deep link needs cleaning up here. + if (params.has('page')) { + this.clearPageQueryParam(); } - - const pageIndex = this.toPageIndex(params.get('page')); - - if (!this.hasAppliedInitialQueryParams) { - this.hasAppliedInitialQueryParams = true; - this.previousSearchQuery = searchQuery; - this.catalog.setPage?.(pageIndex); - return; - } - - const didSearchChange = - searchQuery !== this.previousSearchQuery; - this.previousSearchQuery = searchQuery; - - if (didSearchChange) { - this.catalog.setPage?.(0); - if (params.has('page')) { - this.clearPageQueryParam(); - } - return; - } - - this.catalog.setPage?.(pageIndex); }); } @@ -321,7 +272,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { // Leaving the list without opening a detail (e.g. switching portal // tabs) still snapshots the spot; the facade validates the selection // before ever restoring it. - if (this.supportsInfiniteScroll && !this.selectedItem()) { + if (!this.selectedItem()) { const grid = this.gridElement(); if (grid) { this.catalog.saveScrollPosition?.(grid.scrollTop); @@ -329,36 +280,19 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { } } - 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, - }); - } - onLoadMore(): void { - this.catalog.loadMore?.(); + this.catalog.loadMore(); } onRetryAppend(): void { - this.catalog.retryAppend?.(); + this.catalog.retryAppend(); } onItemClick(item: CategoryContentItem): void { - if (this.supportsInfiniteScroll) { - // Snapshot the grid offset before the detail replaces the list. - const grid = this.gridElement(); - if (grid) { - this.catalog.saveScrollPosition?.(grid.scrollTop); - } + // Snapshot the grid offset before the detail replaces the list. + const grid = this.gridElement(); + if (grid) { + this.catalog.saveScrollPosition?.(grid.scrollTop); } this.providerOnlyStalkerItemId.set(null); @@ -371,11 +305,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { } } - private toPageIndex(value: string | null): number { - const page = Number(value); - return Number.isInteger(page) && page > 0 ? page - 1 : 0; - } - private gridElement(): HTMLElement | null { return this.hostElement.nativeElement.querySelector( 'app-grid-list' diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html index 46ca841bf..2fa1ce742 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html @@ -60,6 +60,7 @@
{ expect(nearEndSpy).toHaveBeenCalledTimes(2); }); + it('exposes scroll save/restore for the results container', () => { + const resultsContainer = renderResultsContainer(); + Object.defineProperty(resultsContainer, 'scrollTop', { + configurable: true, + writable: true, + value: 640, + }); + const scrollTo = jest.fn( + (options: { top: number }) => + ((resultsContainer as unknown as { scrollTop: number }).scrollTop = + options.top) + ); + Object.defineProperty(resultsContainer, 'scrollTo', { + configurable: true, + value: scrollTo, + }); + + expect(fixture.componentInstance.getResultsScrollTop()).toBe(640); + + fixture.componentInstance.restoreResultsScrollTop(120); + expect(scrollTo).toHaveBeenCalledWith({ top: 120 }); + expect(fixture.componentInstance.getResultsScrollTop()).toBe(120); + }); + it('does not emit nearEnd when the consumer reports no more results', () => { const nearEndSpy = jest.fn(); fixture.componentInstance.nearEnd.subscribe(nearEndSpy); diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts index 96457d007..2ca68ddf3 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + ElementRef, input, output, viewChild, @@ -29,6 +30,8 @@ import { SearchFormComponent } from '../search-form/search-form.component'; }) export class SearchLayoutComponent { private readonly searchFormComponent = viewChild(SearchFormComponent); + private readonly resultsContainer = + viewChild>('resultsContainer'); /** Page title translation key */ readonly title = input('PORTALS.SIDEBAR.SEARCH'); @@ -118,6 +121,19 @@ export class SearchLayoutComponent { this.searchFormComponent()?.focusSearchInput(); } + /** + * Scroll handoff for hosts whose inline detail replaces the results + * (`showDetails`): the container is destroyed with the detail open and + * recreated at offset zero, so the host saves and restores the spot. + */ + getResultsScrollTop(): number { + return this.resultsContainer()?.nativeElement.scrollTop ?? 0; + } + + restoreResultsScrollTop(scrollTop: number): void { + this.resultsContainer()?.nativeElement.scrollTo?.({ top: scrollTop }); + } + onSearchTermChange(term: string): void { this.searchTermChange.emit(term); } diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index ef1c27c39..79202c9ae 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -43,22 +43,16 @@ export interface PortalCatalogFacade< readonly contentSortMode: Signal; readonly playlist: Signal; /** - * Infinite-scroll capability. `true` means the facade grows one continuous - * list via `loadMore()` and the catalog view renders no paginator. - * - * Transitional (PR 1 of the pagination removal): Xtream sets `true`; - * Stalker still pages and leaves it unset. Once Stalker appends too, this - * flag and the paged members below are deleted and the infinite-scroll - * members become required. + * Infinite-scroll contract: the facade grows one continuous list via + * `loadMore()`; the catalog view renders no paginator. */ - readonly supportsInfiniteScroll?: boolean; - readonly hasMore?: Signal; + readonly hasMore: Signal; /** True while an asynchronous append is in flight (tail spinner). */ - readonly isAppending?: Signal; + readonly isAppending: Signal; /** True when the latest append failed; the tail shows a retry action. */ - readonly appendError?: Signal; - loadMore?(): void; - retryAppend?(): void; + readonly appendError: Signal; + loadMore(): void; + retryAppend(): void; /** * Scroll-position handoff for detail round-trips: the view saves the grid * offset when an item opens, and consumes it (the facade restores the @@ -67,14 +61,6 @@ export interface PortalCatalogFacade< */ saveScrollPosition?(scrollTop: number): void; consumeSavedScrollPosition?(): number | null; - /** - * Legacy paged members — only implemented while `supportsInfiniteScroll` - * is not `true` (Stalker during the transition). Deleted in PR 2. - */ - readonly pageSizeOptions?: readonly number[]; - readonly limit?: Signal; - readonly pageIndex?: Signal; - readonly totalPages?: Signal; /** * Optional IMDb-rating capability (Xtream VOD/series). Providers without * structured ratings (e.g. Stalker) leave these undefined, and the rating @@ -86,8 +72,6 @@ export interface PortalCatalogFacade< initialize(categoryId?: string | null): void; setSearchQuery?(query: string): void; clearSelectedItem(): void; - setPage?(page: number): void; - setLimit?(limit: number): void; setContentSortMode(mode: PortalCatalogSortMode): void; setMinRating?(value: number | null): void; selectItem(item: TItem): string[] | null; diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts index c92727cb8..457d1b496 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts @@ -101,7 +101,10 @@ describe('StalkerStore API compatibility smoke', () => { it('exposes compatibility computed selectors', () => { const expectedComputed = [ - 'getTotalPages', + // getTotalPages was removed with catalog pagination — the grid + // appends portal pages and pages have no UI representation left. + 'hasMoreContent', + 'hasContentAppendError', 'getPaginatedContent', 'isPaginatedContentLoading', 'isPaginatedContentFailed', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index 00ab9e8da..ea631d084 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -352,6 +352,153 @@ describe('withStalkerContent failure states', () => { expect(store.hasMoreChannels()).toBe(false); }); + it('appends later VOD pages into one continuous deduplicated list', async () => { + dataService.sendIpcEvent.mockImplementation( + (_event: unknown, payload: { params?: { p?: number } }) => { + const page = Number(payload.params?.p ?? 1); + + return Promise.resolve({ + js: { + data: [ + { + id: `movie-${page}`, + name: `Movie page ${page}`, + category_id: '5', + }, + // The portal shifts this row between pages — + // the append must deduplicate it. + { + id: 'movie-shared', + name: 'Shared Movie', + category_id: '5', + }, + ], + total_items: 3, + }, + }); + } + ); + + store.setSelectedContentType('vod'); + store.setCategories('vod', [ + { category_id: '5', category_name: 'Action' }, + ]); + store.setSelectedCategory('5'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => store.getPaginatedContent().length === 2); + expect(store.hasMoreContent()).toBe(true); + + store.setPage(1); + await waitForCondition(() => store.getPaginatedContent().length === 3); + + expect( + store.getPaginatedContent().map((item) => item.name) + ).toEqual(['Movie page 1', 'Shared Movie', 'Movie page 2']); + expect(store.hasMoreContent()).toBe(false); + }); + + it('stops paging when an append adds no unique items despite total_items', async () => { + dataService.sendIpcEvent.mockImplementation( + (_event: unknown, payload: { params?: { p?: number } }) => { + const page = Number(payload.params?.p ?? 1); + + return Promise.resolve({ + js: { + // Page 2 repeats page 1's rows — after a mid-list + // portal mutation the unique list can stay shorter + // than the claimed total forever. + data: [ + { + id: 'movie-1', + name: 'Movie one', + category_id: '5', + }, + { + id: 'movie-2', + name: 'Movie two', + category_id: '5', + }, + ], + total_items: page === 1 ? 4 : 4, + }, + }); + } + ); + + store.setSelectedContentType('vod'); + store.setCategories('vod', [ + { category_id: '5', category_name: 'Action' }, + ]); + store.setSelectedCategory('5'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + + await waitForCondition(() => store.getPaginatedContent().length === 2); + expect(store.hasMoreContent()).toBe(true); + + store.setPage(1); + await waitForCondition(() => !store.hasMoreContent()); + + // The duplicate page made no progress: the total clamps to reality + // instead of leaving hasMoreContent true past the end forever. + expect(store.getPaginatedContent()).toHaveLength(2); + expect(store.totalCount()).toBe(2); + }); + + it('keeps accumulated pages when an append fails and retries the same page', async () => { + let failPageTwo = true; + dataService.sendIpcEvent.mockImplementation( + (_event: unknown, payload: { params?: { p?: number } }) => { + const page = Number(payload.params?.p ?? 1); + if (page === 2 && failPageTwo) { + return Promise.reject(new Error('portal hiccup')); + } + + return Promise.resolve({ + js: { + data: [ + { + id: `movie-${page}`, + name: `Movie page ${page}`, + category_id: '5', + }, + ], + total_items: 2, + }, + }); + } + ); + + store.setSelectedContentType('vod'); + store.setCategories('vod', [ + { category_id: '5', category_name: 'Action' }, + ]); + store.setSelectedCategory('5'); + store.setCurrentPlaylist(PLAYLIST); + void store.isPaginatedContentLoading(); + await waitForCondition(() => store.getPaginatedContent().length === 1); + + store.setPage(1); + await waitForCondition(() => store.hasContentAppendError()); + + // The failed append left page 1 on screen, not the empty state. + expect( + store.getPaginatedContent().map((item) => item.name) + ).toEqual(['Movie page 1']); + expect(store.contentError()).toBeNull(); + + failPageTwo = false; + store.retryContentPage(); + await waitForCondition(() => store.getPaginatedContent().length === 2); + + expect(store.hasContentAppendError()).toBe(false); + expect( + store.getPaginatedContent().map((item) => item.name) + ).toEqual(['Movie page 1', 'Movie page 2']); + }); + it('falls back to a synthetic all-radio category when radio categories are unavailable', async () => { dataService.sendIpcEvent.mockRejectedValue( new Error('radio categories unsupported') diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 6591af5fb..e4f8bf695 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -48,6 +48,12 @@ export interface StalkerContentState { paginatedContent: StalkerContentItem[]; categoryError: unknown; contentError: unknown; + /** + * A failed append (portal page > 1). Kept separate from `contentError` + * so already-accumulated pages stay on screen and the grid tail can + * offer a retry instead of collapsing to the empty state. + */ + appendError: unknown; } const initialContentState: StalkerContentState = { @@ -62,6 +68,7 @@ const initialContentState: StalkerContentState = { paginatedContent: [], categoryError: null, contentError: null, + appendError: null, }; interface StalkerCategoryResponseItem { @@ -171,6 +178,7 @@ function buildEmptyContentPatch( totalCount: 0, paginatedContent: [], contentError: error, + appendError: null, }; if (contentType === 'itv' || contentType === 'radio') { @@ -185,6 +193,28 @@ function buildEmptyContentPatch( return patch; } +/** + * Portals can shift items between pages while the list is being appended — + * a duplicate id would render the same card twice and break `track` hints. + */ +function dedupeContentById( + items: StalkerContentItem[] +): StalkerContentItem[] { + const seenIds = new Set(); + return items.filter((item) => { + const id = + item.id === undefined || item.id === null ? null : String(item.id); + if (id === null) { + return true; + } + if (seenIds.has(id)) { + return false; + } + seenIds.add(id); + return true; + }); +} + export function withStalkerContent() { const logger = createLogger('withStalkerContent'); @@ -498,9 +528,15 @@ export function withStalkerContent() { } try { + // Only a fresh list (page 1) blanks the grid + // for the skeleton; appends keep the already + // accumulated pages on screen. patchState(store, { - paginatedContent: [], + ...(params.pageIndex === 1 + ? { paginatedContent: [] } + : {}), contentError: null, + appendError: null, }); const response = @@ -522,6 +558,14 @@ export function withStalkerContent() { 'Invalid response structure', response ); + if (params.pageIndex > 1) { + // A broken append must not collapse + // the pages already on screen. + patchState(store, { + appendError: invalidResponseError, + }); + return store.paginatedContent(); + } patchState(store, { ...buildEmptyContentPatch( params.contentType, @@ -569,13 +613,41 @@ export function withStalkerContent() { (response.js.total_items ?? 0), }); } else { + // VOD/series pages accumulate into one + // continuous list for the infinite-scroll + // grid; page 1 replaces it. + const previousContent = + store.paginatedContent(); + const nextContent = + params.pageIndex === 1 + ? newItems + : dedupeContentById([ + ...previousContent, + ...newItems, + ]); + // An append that adds no unique items is + // the practical end of the list even when + // the portal's total_items claims more + // (dedup after mid-list mutations can + // leave the unique list short forever) — + // clamp the total so hasMoreContent turns + // false instead of requesting past the + // end on every scroll crossing. + const appendStalled = + params.pageIndex > 1 && + nextContent.length <= + previousContent.length; + patchState(store, { - totalCount: - response.js.total_items ?? 0, - paginatedContent: newItems, + totalCount: appendStalled + ? nextContent.length + : (response.js.total_items ?? 0), + paginatedContent: nextContent, contentError: null, + appendError: null, hasMoreChannels: false, }); + return nextContent; } return newItems; @@ -589,6 +661,12 @@ export function withStalkerContent() { category: params.category, error, }); + if (params.pageIndex > 1) { + // Keep the accumulated pages; the grid + // tail offers a retry for this page. + patchState(store, { appendError: error }); + return store.paginatedContent(); + } patchState( store, buildEmptyContentPatch( @@ -694,8 +772,17 @@ export function withStalkerContent() { return (itvCategoryItemCounts().get(genreId) ?? 0) > 0; }), itvCategoryItemCounts, - getTotalPages: computed(() => - Math.ceil(store.totalCount() / storeContext.limit()) + /** + * Whether the portal reports more items than the grid has + * accumulated. Derived from `total_items` versus the actual + * list length, so it stays correct even when the portal + * ignores requested page sizes. + */ + hasMoreContent: computed( + () => store.paginatedContent().length < store.totalCount() + ), + hasContentAppendError: computed( + () => store.appendError() !== null ), getSelectedCategory: computed(() => { const categoryId = storeContext.selectedCategoryId(); @@ -773,6 +860,14 @@ export function withStalkerContent() { preloadItvChannels(): void { void itvCache.ensureLoaded(storeContext.currentPlaylist()); }, + /** + * Re-runs the content loader with unchanged params — the retry + * for a failed append page. + */ + retryContentPage(): void { + patchState(store, { appendError: null }); + storeContext.getContentResource.reload(); + }, async refreshItvChannels(): Promise { await itvCache.refresh(storeContext.currentPlaylist()); }, diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts index c609a4ab1..6e12451d9 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts @@ -23,6 +23,21 @@ describe('withStalkerSelection', () => { expect(store.page()).toBe(0); }); + it('resets paging when the content type changes, but not when it repeats', () => { + // Regression: /vod -> /series with the same category id ('*' on both + // section roots) used to keep page > 1, so the new type's first + // response was treated as an append onto the old type's list. + store.setSelectedContentType('vod'); + store.setPage(3); + + store.setSelectedContentType('series'); + expect(store.page()).toBe(0); + + store.setPage(2); + store.setSelectedContentType('series'); + expect(store.page()).toBe(2); + }); + it('keeps paging when the search phrase is unchanged', () => { store.setSearchPhrase('matrix'); store.setPage(2); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index 618956b63..5ccc0f2b3 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -50,7 +50,15 @@ export function withStalkerSelection() { setSelectedContentType( type: 'vod' | 'itv' | 'series' | 'radio' ) { - patchState(store, { selectedContentType: type }); + if (store.selectedContentType() === type) { + return; + } + + // Without the page reset, switching e.g. /vod -> /series with + // the same category id ('*' on both section roots) would leave + // page > 1 in place and make the new type's FIRST response an + // append onto the old type's accumulated list. + patchState(store, { selectedContentType: type, page: 0 }); }, setSelectedCategory(id: string | number | null) { const newId = @@ -86,6 +94,10 @@ export function withStalkerSelection() { patchState(store, { page }); }, + /** Advances to the next portal page (infinite-scroll append). */ + nextPage() { + patchState(store, { page: store.page() + 1 }); + }, setSearchPhrase(phrase: string) { if (store.searchPhrase() === phrase) { return; diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts index 3240a1d6b..a89ec3dfd 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts @@ -13,6 +13,8 @@ export interface ResourceState { value(): T; isLoading(): boolean; error(): unknown; + /** Re-runs the loader with the current params (append retry). */ + reload(): boolean; } export interface StalkerPortalStoreContract { diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 8bb750b82..33601663f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -73,20 +73,22 @@ describe('StalkerCatalogFacadeService', () => { }; stalkerStoreMock = { selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'), - limit: signal(14), page: signal(0), + selectedCategoryId: signal('5'), + searchPhrase: signal(''), getSelectedCategory: signal(null), getPaginatedContent: signal([]), selectedItem: signal(null), - getTotalPages: signal(0), + hasMoreContent: signal(false), + hasContentAppendError: signal(false), isPaginatedContentLoading: signal(false), currentPlaylist: signal(playlist), getSelectedCategoryName: jest.fn(() => null), setSelectedCategory: jest.fn(), clearSelectedItem: jest.fn(), setSearchPhrase: jest.fn(), - setPage: jest.fn(), - setLimit: jest.fn(), + nextPage: jest.fn(), + retryContentPage: jest.fn(), setSelectedItem: jest.fn(), createLinkToPlayVod: jest.fn(), addToFavorites: jest.fn(), @@ -213,4 +215,95 @@ describe('StalkerCatalogFacadeService', () => { expect(playbackPositions.savePlaybackPosition).not.toHaveBeenCalled(); }); + + it('splits loading into the initial skeleton and the append tail by portal page', () => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const loading = stalkerStoreMock['isPaginatedContentLoading'] as ReturnType< + typeof signal + >; + const page = stalkerStoreMock['page'] as ReturnType< + typeof signal + >; + + loading.set(true); + page.set(0); + expect(service.isPaginatedContentLoading()).toBe(true); + expect(service.isAppending()).toBe(false); + + page.set(1); + expect(service.isPaginatedContentLoading()).toBe(false); + expect(service.isAppending()).toBe(true); + }); + + it('guards loadMore behind loading, append errors, and hasMore', () => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const loading = stalkerStoreMock['isPaginatedContentLoading'] as ReturnType< + typeof signal + >; + const hasMore = stalkerStoreMock['hasMoreContent'] as ReturnType< + typeof signal + >; + const appendError = stalkerStoreMock[ + 'hasContentAppendError' + ] as ReturnType>; + const nextPage = stalkerStoreMock['nextPage'] as jest.Mock; + + service.loadMore(); + expect(nextPage).not.toHaveBeenCalled(); + + hasMore.set(true); + loading.set(true); + service.loadMore(); + expect(nextPage).not.toHaveBeenCalled(); + + loading.set(false); + appendError.set(true); + service.loadMore(); + expect(nextPage).not.toHaveBeenCalled(); + + appendError.set(false); + service.loadMore(); + expect(nextPage).toHaveBeenCalledTimes(1); + + service.retryAppend(); + expect(stalkerStoreMock['retryContentPage']).toHaveBeenCalledTimes(1); + }); + + it('keeps scroll positions per list identity across detours', () => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const categoryId = stalkerStoreMock['selectedCategoryId'] as ReturnType< + typeof signal + >; + + categoryId.set('5'); + service.saveScrollPosition(420); + + // A detour through another category saves its own spot without + // destroying the first one. + categoryId.set('7'); + service.saveScrollPosition(50); + expect(service.consumeSavedScrollPosition()).toBe(50); + + categoryId.set('5'); + expect(service.consumeSavedScrollPosition()).toBe(420); + // One-shot: consumed positions do not restore twice. + expect(service.consumeSavedScrollPosition()).toBeNull(); + }); + + it('never restores a saved offset onto another portal', () => { + // The route provider (and this facade) survives a same-config portal + // switch — the identity must include the playlist. + const service = TestBed.inject(StalkerCatalogFacadeService); + const currentPlaylist = stalkerStoreMock['currentPlaylist'] as ReturnType< + typeof signal<{ _id: string } | undefined> + >; + + service.saveScrollPosition(420); + + currentPlaylist.set({ _id: 'portal-b' }); + expect(service.consumeSavedScrollPosition()).toBeNull(); + + currentPlaylist.set(playlist as { _id: string }); + expect(service.consumeSavedScrollPosition()).toBe(420); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index bb9863cd4..bdd768867 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -60,18 +60,35 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< private loadedPositionsForPlaylistId: string | null = null; readonly provider = 'stalker' as const; - readonly pageSizeOptions = [14] as const; readonly contentType = this.stalkerStore.selectedContentType; - readonly limit = this.stalkerStore.limit; - readonly pageIndex = this.stalkerStore.page; readonly selectedCategory = this.stalkerStore.getSelectedCategory; readonly paginatedContent = computed( () => this.stalkerStore.getPaginatedContent() ?? [] ); readonly selectedItem = this.stalkerStore.selectedItem; - readonly totalPages = this.stalkerStore.getTotalPages; - readonly isPaginatedContentLoading = - this.stalkerStore.isPaginatedContentLoading; + /** + * The store's loading flag covers every portal page; the grid skeleton + * belongs to the first page only — appends surface as the tail spinner. + */ + readonly isPaginatedContentLoading = computed( + () => + this.stalkerStore.isPaginatedContentLoading() && + this.stalkerStore.page() === 0 + ); + readonly isAppending = computed( + () => + this.stalkerStore.isPaginatedContentLoading() && + this.stalkerStore.page() > 0 + ); + readonly hasMore = this.stalkerStore.hasMoreContent; + readonly appendError = this.stalkerStore.hasContentAppendError; + /** + * Scroll offsets per list identity for inline-detail round trips. The + * accumulated portal pages already survive in the store (same-category + * re-initialisation is a no-op), so only the offset needs a home here. + * Bounded like the Xtream store's snapshot list. + */ + private readonly savedScrollPositions = new Map(); readonly selectedCategoryTitle = computed(() => { const category = this.selectedCategory(); const fromCategory = category @@ -172,12 +189,47 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< this.stalkerStore.setSearchPhrase(query); } - setPage(page: number): void { - this.stalkerStore.setPage(page); + loadMore(): void { + if ( + this.stalkerStore.isPaginatedContentLoading() || + // A failed append blocks further paging — skipping past the + // failed portal page would leave a silent hole in the list; the + // grid tail's retry re-runs it instead. + this.stalkerStore.hasContentAppendError() || + !this.stalkerStore.hasMoreContent() + ) { + return; + } + + this.stalkerStore.nextPage(); } - setLimit(limit: number): void { - this.stalkerStore.setLimit(limit); + retryAppend(): void { + this.stalkerStore.retryContentPage(); + } + + saveScrollPosition(scrollTop: number): void { + const key = this.scrollIdentity(); + // Re-insert so Map order stays oldest-first for the bound below. + this.savedScrollPositions.delete(key); + this.savedScrollPositions.set(key, scrollTop); + if (this.savedScrollPositions.size > 8) { + const oldestKey = this.savedScrollPositions.keys().next().value; + if (oldestKey !== undefined) { + this.savedScrollPositions.delete(oldestKey); + } + } + } + + consumeSavedScrollPosition(): number | null { + const key = this.scrollIdentity(); + const saved = this.savedScrollPositions.get(key); + if (saved === undefined) { + return null; + } + + this.savedScrollPositions.delete(key); + return saved; } setContentSortMode(mode: PortalCatalogSortMode): void { @@ -185,6 +237,18 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< // Stalker catalog content is server-paginated and does not support local sort modes. } + private scrollIdentity(): string { + return [ + // The playlist belongs to the identity: the route provider (and + // this map with it) survives a same-config portal switch, and a + // portal A offset must never restore onto portal B's catalog. + this.stalkerStore.currentPlaylist()?._id ?? '', + this.stalkerStore.selectedContentType(), + String(this.stalkerStore.selectedCategoryId() ?? ''), + this.stalkerStore.searchPhrase(), + ].join('|'); + } + selectItem(item: StalkerVodSource): string[] | null { const needsSeriesFetch = this.contentType() === 'vod' && isStalkerSeriesFlag(item.is_series); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss index a5025ae2c..6976f0297 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.scss @@ -12,10 +12,6 @@ .category-content-header { @include panel.standard-panel-header($sticky: true); - - // Same reason as the Xtream live header: the paginator does not shrink, - // so it must be able to drop to its own line rather than crowd the meta - // out of existence. flex-wrap: wrap; } @@ -50,21 +46,6 @@ } } -// Let the paginator inherit the panel header's tinted background instead of -// painting its own (mismatched) Material surface color. -mat-paginator { - background: transparent !important; - // Its own container already wraps internally, so letting it shrink turns - // a clipped next-page arrow into a second line of the paginator itself. - min-width: 0; - --mat-paginator-container-size: 40px; - --mat-paginator-enabled-icon-color: var( - --app-body-color, - var(--mat-sys-on-surface-variant) - ); - --mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18); -} - app-grid-list.all-items-grid { --cover-grid-min-width: var(--live-channel-grid-min-width, 148px); @@ -78,9 +59,6 @@ app-grid-list.all-items-grid { transparent; } -// Mirrors the Xtream live header: on a phone the page-size select is the -// widest part of the paginator and the least useful one, and the count it -// costs is already stated by the range beside it. @media (max-width: 640px) { .category-content-header { padding-inline: 12px; @@ -90,11 +68,4 @@ app-grid-list.all-items-grid { display: none; } - mat-paginator { - min-width: 0; - - ::ng-deep .mat-mdc-paginator-page-size { - display: none; - } - } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts index fa54f9531..005163aa1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts @@ -30,53 +30,51 @@ describe('StalkerItvAllItemsComponent', () => { component = fixture.componentInstance; }); - it('renders the first client-side page of channels with a paginator', () => { - fixture.componentRef.setInput('channels', buildChannels(60)); + it('renders the first window of channels with no paginator', () => { + fixture.componentRef.setInput('channels', buildChannels(120)); fixture.detectChanges(); - expect(component.pagedGridItems()).toHaveLength(25); + expect(component.visibleGridItems()).toHaveLength(50); expect(fixture.nativeElement.querySelectorAll('mat-card')).toHaveLength( - 25 + 50 ); - expect(fixture.nativeElement.querySelector('mat-paginator')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull(); + expect(component.hasMoreItems()).toBe(true); expect( fixture.nativeElement .querySelector('.category-subtitle') ?.textContent?.trim() - ).toContain('60'); + ).toContain('120'); }); - it('slices the next page on paginator change without touching the source', () => { - fixture.componentRef.setInput('channels', buildChannels(60)); + it('grows the render window with loadMore until everything is visible', () => { + fixture.componentRef.setInput('channels', buildChannels(120)); fixture.detectChanges(); - component.onPageChange({ - pageIndex: 2, - pageSize: 25, - length: 60, - } as never); - fixture.detectChanges(); + component.loadMore(); + expect(component.visibleGridItems()).toHaveLength(100); - // Third page holds the remaining 10 channels. - expect(component.pagedGridItems()).toHaveLength(10); - expect(component.pagedGridItems()[0]['id']).toBe('ch-50'); + component.loadMore(); + expect(component.visibleGridItems()).toHaveLength(120); + expect(component.hasMoreItems()).toBe(false); + + // Covered — a further loadMore is a no-op. + component.loadMore(); + expect(component.renderLimit()).toBe(150); }); - it('filters by the search term across ALL channels and resets to page one', () => { - fixture.componentRef.setInput('channels', buildChannels(60)); + it('filters by the search term across ALL channels and resets the window', () => { + fixture.componentRef.setInput('channels', buildChannels(120)); fixture.detectChanges(); - component.onPageChange({ - pageIndex: 1, - pageSize: 25, - length: 60, - } as never); + component.loadMore(); + expect(component.renderLimit()).toBe(100); fixture.componentRef.setInput('searchTerm', 'needle'); fixture.detectChanges(); - expect(component.pageIndex()).toBe(0); + expect(component.renderLimit()).toBe(50); expect( - component.pagedGridItems().map((item) => item['name']) + component.visibleGridItems().map((item) => item['name']) ).toEqual(['Needle TV']); }); @@ -84,7 +82,7 @@ describe('StalkerItvAllItemsComponent', () => { fixture.componentRef.setInput('channels', buildChannels(1)); fixture.detectChanges(); - const [item] = component.pagedGridItems(); + const [item] = component.visibleGridItems(); expect(item['stream_icon']).toBe('logo-0.png'); expect('is_series' in item).toBe(false); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts index 26e0e2431..9e7303ab6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.ts @@ -5,28 +5,33 @@ import { input, linkedSignal, output, - signal, } from '@angular/core'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; -import { GridListComponent } from '@iptvnator/portal/shared/ui'; +import { + GridListComponent, + InfiniteScrollDirective, +} from '@iptvnator/portal/shared/ui'; import { StalkerItvChannel, StalkerItvLoadProgress, } from '@iptvnator/portal/stalker/data-access'; +/** Initial render window and per-`loadMore` growth over the cached list. */ +const RENDER_CHUNK = 50; + /** * "All channels" grid shown in the Live TV main area before a category is * selected — mirrors the Xtream live "All Items" view. Fed by the full ITV - * channel list cache; pagination is purely client-side so it never touches the - * store's legacy page state (which would re-fire portal requests). + * channel list cache; the render window is purely client-side so growing it + * never touches the store's legacy page state (which would re-fire portal + * requests). */ @Component({ selector: 'app-stalker-itv-all-items', imports: [ GridListComponent, - MatPaginatorModule, + InfiniteScrollDirective, MatProgressSpinnerModule, TranslatePipe, ], @@ -60,21 +65,16 @@ import { > }
- @if (!loading() && filteredChannels().length > 0) { - - } (); - readonly pageSizeOptions = [10, 25, 50, 100]; - readonly pageSize = signal(25); - /** Resets to the first page whenever the source list or search changes. */ - readonly pageIndex = linkedSignal({ + /** Resets to the first chunk whenever the source list or search changes. */ + readonly renderLimit = linkedSignal({ source: () => ({ term: this.searchTerm(), channelCount: this.channels().length, }), - computation: () => 0, + computation: () => RENDER_CHUNK, }); readonly filteredChannels = computed(() => { @@ -116,11 +114,14 @@ export class StalkerItvAllItemsComponent { ); }); - /** The current page, mapped so GridListComponent can resolve the logo. */ - readonly pagedGridItems = computed(() => { - const start = this.pageIndex() * this.pageSize(); - return this.filteredChannels() - .slice(start, start + this.pageSize()) + readonly hasMoreItems = computed( + () => this.filteredChannels().length > this.renderLimit() + ); + + /** The visible window, mapped so GridListComponent can resolve the logo. */ + readonly visibleGridItems = computed(() => + this.filteredChannels() + .slice(0, this.renderLimit()) .map((channel) => { // GridListItem forbids null is_series; Stalker payloads may // carry it — drop the nullish form (same as toPlayableChannel). @@ -130,12 +131,15 @@ export class StalkerItvAllItemsComponent { ...(is_series == null ? {} : { is_series }), stream_icon: channel.logo, }; - }); - }); + }) + ); - onPageChange(event: PageEvent): void { - this.pageSize.set(event.pageSize); - this.pageIndex.set(event.pageIndex); + loadMore(): void { + if (!this.hasMoreItems()) { + return; + } + + this.renderLimit.update((limit) => limit + RENDER_CHUNK); } onItemClicked(item: unknown): void { diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index d8f29392a..426ac5ad1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -1,14 +1,18 @@ @if (showingDetails) {
- @for (item of searchResultsResource.value(); track item.id) { + @for (item of searchResults(); track item.id) { }
+ + @if (searchAppendError()) { + + }
diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.scss b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.scss index b97890404..5c51feda6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.scss @@ -18,3 +18,12 @@ @include grid.content-grid; padding-bottom: 1rem; } + +.search-append-error { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 18px 0 6px; + color: var(--mat-sys-error, inherit); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts index ec57b8f43..6f4068d8d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts @@ -5,7 +5,7 @@ import { By } from '@angular/platform-browser'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PORTAL_EXTERNAL_PLAYBACK, @@ -271,3 +271,265 @@ describe('StalkerSearchComponent playback session key', () => { expect(fixture.componentInstance.inlinePlayback()).toBe(playback); }); }); + +describe('StalkerSearchComponent result paging', () => { + let component: StalkerSearchComponent; + const activePlaylist = signal({ + _id: 'playlist|one', + title: 'Search portal', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + }); + + function searchItems(prefix: string, count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `${prefix}-${index + 1}`, + name: `${prefix} ${index + 1}`, + })); + } + + beforeEach(() => { + activePlaylist.set({ + _id: 'playlist|one', + title: 'Search portal', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', + }); + TestBed.configureTestingModule({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of(convertToParamMap({})), + snapshot: { + data: {}, + queryParamMap: convertToParamMap({}), + routeConfig: { path: 'search' }, + }, + }, + }, + { provide: Location, useValue: { back: jest.fn() } }, + { provide: DataService, useValue: {} }, + { + provide: PlaylistContextFacade, + useValue: { activePlaylist }, + }, + { + provide: PlaylistsService, + useValue: { getPortalFavorites: () => of([]) }, + }, + { + provide: StalkerStore, + useValue: { + selectedItem: signal(null), + setSelectedContentType: jest.fn(), + setSelectedItem: jest.fn(), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + resolveVodPlayback: jest.fn(), + }, + }, + { provide: StalkerSessionService, useValue: {} }, + { provide: StalkerPortalRepairService, useValue: {} }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn().mockResolvedValue(null), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + component = TestBed.runInInjectionContext( + () => new StalkerSearchComponent() + ); + }); + + it('accumulates deduplicated pages and derives hasMore from the total', () => { + const pageOne = [ + ...searchItems('page1', 3), + { id: 'shared', name: 'Shared item' }, + ]; + component.applySearchPageSuccess(1, pageOne, 7); + expect(component.searchResults()).toHaveLength(4); + expect(component.searchHasMore()).toBe(true); + + // The portal shifted `shared` between pages — it must not duplicate. + component.applySearchPageSuccess( + 2, + [...searchItems('page2', 2), { id: 'shared', name: 'Shared item' }], + 7 + ); + expect(component.searchResults()).toHaveLength(6); + expect(component.searchHasMore()).toBe(true); + + component.applySearchPageSuccess(3, searchItems('page3', 1), 7); + expect(component.searchResults()).toHaveLength(7); + expect(component.searchHasMore()).toBe(false); + }); + + it('stops paging when a total-backed append makes no progress', () => { + component.applySearchPageSuccess(1, searchItems('page1', 3), 10); + expect(component.searchHasMore()).toBe(true); + + // The portal repeats page 1 under a larger claimed total — dedupe + // yields no growth, which must still end the paging loop. + component.applySearchPageSuccess(2, searchItems('page1', 3), 10); + expect(component.searchResults()).toHaveLength(3); + expect(component.searchHasMore()).toBe(false); + }); + + it('stops paging without a total once pages stop making progress', () => { + component.applySearchPageSuccess(1, searchItems('page1', 3), undefined); + expect(component.searchHasMore()).toBe(true); + + // The portal ignores paging and repeats the same page — dedupe + // yields no growth, which must terminate the loop. + component.applySearchPageSuccess(2, searchItems('page1', 3), undefined); + expect(component.searchHasMore()).toBe(false); + }); + + it('keeps accumulated pages on a failed append and retries the SAME page', () => { + component.applySearchPageSuccess(1, searchItems('page1', 3), 6); + expect(component.searchHasMore()).toBe(true); + + component.applySearchPageFailure(2); + // The failed append kept page 1 on screen and flagged the error. + expect(component.searchResults()).toHaveLength(3); + expect(component.searchAppendError()).toBe(true); + expect(component.searchHasMore()).toBe(true); + + // The real resource never settles in this template-less harness — + // substitute a deterministic stand-in for the guard checks. + const reload = jest.fn(() => true); + Object.defineProperty(component, 'searchResultsResource', { + configurable: true, + value: { isLoading: () => false, reload }, + }); + + // The next near-end must RETRY page 2 (page stays put, the error is + // consumed) instead of advancing to page 3 and skipping results. + const pageBefore = component.searchPage(); + component.loadMoreSearchResults(); + expect(component.searchPage()).toBe(pageBefore); + expect(component.searchAppendError()).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + + // With the error cleared, the following near-end advances normally. + component.loadMoreSearchResults(); + expect(component.searchPage()).toBe(pageBefore + 1); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("clears the previous query's results when a fresh search fails", () => { + component.applySearchPageSuccess(1, searchItems('matrix', 3), 3); + expect(component.searchResults()).toHaveLength(3); + + component.applySearchPageFailure(1); + + expect(component.searchResults()).toHaveLength(0); + expect(component.searchHasMore()).toBe(false); + expect(component.searchAppendError()).toBe(false); + }); + + it('restores the results scroll after an inline detail round trip', () => { + // Regression: the layout destroys the results container while an + // inline detail is open and recreates it at offset zero. + const rafCallbacks: FrameRequestCallback[] = []; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + } + ); + try { + const layout = { + getResultsScrollTop: jest.fn(() => 860), + restoreResultsScrollTop: jest.fn(), + }; + Object.defineProperty(component, 'searchLayout', { + configurable: true, + value: () => layout, + }); + + component.selectItem({ id: '42', name: 'Deep result' }); + expect(layout.getResultsScrollTop).toHaveBeenCalledTimes(1); + + component.onVodBack(); + while (rafCallbacks.length) { + const callback = rafCallbacks.shift(); + callback?.(0); + } + expect(layout.restoreResultsScrollTop).toHaveBeenCalledWith(860); + + // The captured offset is one-shot. + component.onVodBack(); + while (rafCallbacks.length) { + const callback = rafCallbacks.shift(); + callback?.(0); + } + expect(layout.restoreResultsScrollTop).toHaveBeenCalledTimes(1); + } finally { + jest.restoreAllMocks(); + } + }); + + it('empties the accumulator and paging flags for unsearchable portals', () => { + // The loader calls this on every no-portal early return (deleted or + // malformed playlist on a reused route, short term) so the previous + // portal's cards cannot keep rendering under the new context. + component.applySearchPageSuccess(1, searchItems('portalA', 3), 6); + component.applySearchPageFailure(2); + expect(component.searchResults()).toHaveLength(3); + + component.resetSearchAccumulator(); + + expect(component.searchResults()).toHaveLength(0); + expect(component.searchHasMore()).toBe(false); + expect(component.searchAppendError()).toBe(false); + }); + + it('resets paging when the active playlist changes on a reused route', () => { + // Regression: /stalker/A/search -> /stalker/B/search reuses the + // component; a surviving page number would append portal B's later + // page onto portal A's results and skip B's first page. + Object.defineProperty(component, 'searchResultsResource', { + configurable: true, + value: { isLoading: () => false, reload: jest.fn(() => true) }, + }); + component.applySearchPageSuccess( + 1, + searchItems('portalA', 3), + 6 + ); + component.loadMoreSearchResults(); + expect(component.searchPage()).toBe(2); + + activePlaylist.set({ + _id: 'playlist|two', + title: 'Other portal', + portalUrl: 'http://other.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:02', + }); + + expect(component.searchPage()).toBe(1); + expect(component.searchScrollResetKey()).toContain('playlist|two'); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index e2bb6f0b1..8f055dea1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -4,12 +4,15 @@ import { computed, effect, inject, + linkedSignal, resource, signal, untracked, + viewChild, } from '@angular/core'; import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute } from '@angular/router'; @@ -71,16 +74,35 @@ type StalkerSearchContentType = 'vod' | 'series'; interface StalkerSearchResponse { js?: { data?: StalkerVodSource[]; + total_items?: number; }; message?: string; status?: number; } +/** Portals can shift items between pages mid-append — drop duplicate ids. */ +function dedupeSearchResults(items: StalkerVodSource[]): StalkerVodSource[] { + const seenIds = new Set(); + return items.filter((item) => { + const id = + item.id === undefined || item.id === null ? null : String(item.id); + if (id === null) { + return true; + } + if (seenIds.has(id)) { + return false; + } + seenIds.add(id); + return true; + }); +} + @Component({ selector: 'app-stalker-search', imports: [ ContentCardComponent, FormsModule, + MatButtonModule, MatCheckboxModule, SearchLayoutComponent, StalkerInlineDetailComponent, @@ -105,6 +127,13 @@ export class StalkerSearchComponent { private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly logger = createLogger('StalkerSearch'); + private readonly searchLayout = viewChild(SearchLayoutComponent); + /** + * The results offset captured when an inline detail opens: the layout + * destroys the results container while the detail is shown and recreates + * it at zero, so closing the detail must restore the spot explicitly. + */ + private savedResultsScrollTop = 0; private currentPlaybackOwnerKey = ''; readonly filters = signal>({ @@ -176,20 +205,54 @@ export class StalkerSearchComponent { () => this.favoritesRefresh.refreshVersion() ); + /** + * Portal page for the current term+filter+portal; resets when any of + * them changes. The playlist belongs to the identity: Angular reuses the + * search route across `/stalker/A/search` -> `/stalker/B/search`, and a + * surviving page number would append portal B's later page onto portal + * A's accumulated results while skipping B's first page. + */ + readonly searchPage = linkedSignal({ + source: () => ({ + term: this.searchTerm(), + type: this.selectedFilterType(), + playlistId: this.currentPlaylist()?._id ?? null, + }), + computation: () => 1, + }); + /** Pages accumulated into one continuous, deduplicated result list. */ + private readonly accumulatedSearchResults = signal([]); + readonly searchResults = this.accumulatedSearchResults.asReadonly(); + readonly searchHasMore = signal(false); + /** + * A failed append page. The next near-end RETRIES that page instead of + * advancing — incrementing past it would silently omit its results. + */ + readonly searchAppendError = signal(false); + readonly searchResultsResource = resource({ params: () => ({ contentType: this.selectedFilterType(), search: this.searchTerm(), + page: this.searchPage(), + playlistId: this.currentPlaylist()?._id ?? null, action: StalkerPortalActions.GetOrderedList, }), loader: async ({ params }) => { if (params.search.length < 3) { + this.resetSearchAccumulator(); return []; } const playlist = this.currentPlaylist(); - if (!playlist) return []; + if (!playlist) { + // A reused route can land on a deleted/unresolved portal — + // the previous portal's cards must not keep rendering. + this.resetSearchAccumulator(); + return []; + } const { portalUrl, macAddress } = playlist; if (!portalUrl || !macAddress) { + this.resetSearchAccumulator(); return []; } const contentType = params.contentType; @@ -197,36 +260,158 @@ export class StalkerSearchComponent { // Mirror the catalog request shape: many Ministra portals // return an empty list for get_ordered_list without the // category/genre/sortby params the STB client always sends. + // `max_page_items` is a HINT — plenty of portals ignore it and + // return their own page size, which is why paging cannot rely + // on it (progress and `total_items` decide hasMore instead). const requestParams: Record = { action: StalkerContentTypes[contentType].getContentAction, type: contentType, sortby: 'added', search: params.search, - p: 1, + p: params.page, max_page_items: 100, category: '*', ...(contentType === 'vod' ? { genre: '0' } : {}), }; - // executeStalkerRequest owns the portal-mode decision (shared - // predicate with URL fallback for legacy rows) and the lazy - // portal repair, so search cannot drift from the catalog paths. - const response = await executeStalkerRequest( - { - dataService: this.dataService, - stalkerSession: this.stalkerSession, - portalRepair: this.portalRepair, - }, - playlist, - requestParams - ); - const items = response.js?.data || []; - return items.map((item: StalkerVodSource) => - this.processItemUrls(item, portalUrl) - ); + // A stale response (term/filter/page/portal moved on while this + // page was in flight) must not clobber the accumulated list. + const isCurrent = (): boolean => + params.search === this.searchTerm() && + params.contentType === this.selectedFilterType() && + params.page === this.searchPage() && + params.playlistId === (this.currentPlaylist()?._id ?? null); + + try { + // executeStalkerRequest owns the portal-mode decision (shared + // predicate with URL fallback for legacy rows) and the lazy + // portal repair, so search cannot drift from the catalog + // paths. + const response = + await executeStalkerRequest( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, + playlist, + requestParams + ); + const items = (response.js?.data || []).map( + (item: StalkerVodSource) => + this.processItemUrls(item, portalUrl) + ); + + if (!isCurrent()) { + return items; + } + + return this.applySearchPageSuccess( + params.page, + items, + response.js?.total_items + ); + } catch (error) { + this.logger.warn('Stalker search page failed', { + page: params.page, + error, + }); + if (!isCurrent()) { + return this.accumulatedSearchResults(); + } + + return this.applySearchPageFailure(params.page); + } }, }); + /** + * Empties the accumulator and every paging flag — used whenever there is + * no searchable portal (short term, missing playlist, malformed row). + */ + resetSearchAccumulator(): void { + this.accumulatedSearchResults.set([]); + this.searchHasMore.set(false); + this.searchAppendError.set(false); + } + + /** Merges a successful portal page into the accumulated result list. */ + applySearchPageSuccess( + page: number, + items: StalkerVodSource[], + totalItems: number | undefined + ): StalkerVodSource[] { + const previous = page === 1 ? [] : this.accumulatedSearchResults(); + const merged = + page === 1 ? items : dedupeSearchResults([...previous, ...items]); + // Paging continues only while pages make progress — with OR without + // a reported total. Dedup after mid-list portal mutations can leave + // the unique list permanently shorter than total_items, and a + // repeated page dedupes to no growth; either way a no-progress + // append is the practical end of the results. + const madeProgress = page === 1 || merged.length > previous.length; + this.searchHasMore.set( + madeProgress && + (typeof totalItems === 'number' && totalItems >= 0 + ? merged.length < totalItems + : items.length > 0) + ); + this.searchAppendError.set(false); + this.accumulatedSearchResults.set(merged); + return merged; + } + + /** + * A failed FRESH search (page 1) must not keep rendering the previous + * query's cards; a failed append keeps the accumulated pages and flags + * the error so the next near-end retries this page instead of advancing. + */ + applySearchPageFailure(page: number): StalkerVodSource[] { + if (page === 1) { + this.accumulatedSearchResults.set([]); + this.searchHasMore.set(false); + this.searchAppendError.set(false); + return []; + } + + this.searchAppendError.set(true); + return this.accumulatedSearchResults(); + } + + /** + * Result-set identity for the layout's near-end latch and auto-fill + * budget — term, filter, and portal, mirroring the paging identity. + */ + readonly searchScrollResetKey = computed(() => + [ + this.searchTerm(), + this.selectedFilterType(), + this.currentPlaylist()?._id ?? '', + ].join('|') + ); + + readonly isInitialSearchLoading = computed( + () => this.searchResultsResource.isLoading() && this.searchPage() === 1 + ); + readonly isAppendingSearchResults = computed( + () => this.searchResultsResource.isLoading() && this.searchPage() > 1 + ); + + loadMoreSearchResults(): void { + if (this.searchResultsResource.isLoading() || !this.searchHasMore()) { + return; + } + + if (this.searchAppendError()) { + // Retry the SAME page — advancing would permanently omit it. + this.searchAppendError.set(false); + this.searchResultsResource.reload(); + return; + } + + this.searchPage.update((page) => page + 1); + } + readonly isSelectedVodFavorite = signal(false); constructor() { @@ -287,7 +472,7 @@ export class StalkerSearchComponent { /** Get results count for layout */ get resultsCount(): number { - return this.searchResultsResource.value()?.length ?? 0; + return this.searchResults().length; } updateSearchTerm(term: string) { @@ -312,6 +497,8 @@ export class StalkerSearchComponent { } selectItem(item: StalkerVodSource) { + this.savedResultsScrollTop = + this.searchLayout()?.getResultsScrollTop() ?? 0; const filterType = this.selectedFilterType(); const hasEmbeddedSeries = (item.series?.length ?? 0) > 0; const needsSeriesFetch = @@ -410,6 +597,18 @@ export class StalkerSearchComponent { this.isSelectedVodFavorite.set(false); this.selectedVodPosition.set(null); this.closeInlinePlayer(); + + const scrollTop = this.savedResultsScrollTop; + this.savedResultsScrollTop = 0; + if (scrollTop > 0) { + // Two frames: one for change detection to recreate the results + // container, one to apply the offset to it. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.searchLayout()?.restoreResultsScrollTop(scrollTop); + }); + }); + } } handleInlineTimeUpdate(event: { diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts index e310065af..302c363ff 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts @@ -134,7 +134,6 @@ describe('XtreamCatalogFacadeService', () => { }); it('exposes store-driven windowed content, hasMore, and category counts', () => { - expect(service.supportsInfiniteScroll).toBe(true); expect(service.paginatedContent()).toEqual([ { xtream_id: 1, title: 'A' }, ]); 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 00d34261d..21c8f156f 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 @@ -33,7 +33,6 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< private loadedPositionsPlaylistId: string | null = null; readonly provider = 'xtream' as const; - readonly supportsInfiniteScroll = true; readonly contentType = this.xtreamStore.selectedContentType; readonly selectedCategory = this.xtreamStore.getSelectedCategory; readonly paginatedContent = this.xtreamStore.getPaginatedContent;