diff --git a/.changes/xtream-infinite-scroll-catalog.md b/.changes/xtream-infinite-scroll-catalog.md new file mode 100644 index 000000000..2d99ebc2c --- /dev/null +++ b/.changes/xtream-infinite-scroll-catalog.md @@ -0,0 +1,10 @@ +--- +type: feature +area: xtream +--- + +Xtream movie, series, and live catalogs now load continuously as you scroll +instead of flipping through pages — more items appear automatically as you near +the bottom, and taller windows fill themselves on open. Opening a title and +going back returns you to the exact spot in the list. In-category search and +the in-portal search results follow the same continuous style. diff --git a/CLAUDE.md b/CLAUDE.md index de83ab1b7..62c2eb44f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -377,7 +377,7 @@ libs/portal/xtream/ │ │ ├── features/ │ │ │ ├── with-portal.feature.ts # Playlist & portal status │ │ │ ├── with-content.feature.ts # Categories & streams -│ │ │ ├── with-selection.feature.ts # UI selection & pagination +│ │ │ ├── with-selection.feature.ts # UI selection & infinite-scroll window │ │ │ ├── with-search.feature.ts # Search functionality │ │ │ ├── with-epg.feature.ts # EPG data │ │ │ ├── with-player.feature.ts # Stream URLs & player @@ -415,6 +415,18 @@ Key patterns: `ElectronXtreamDataSource` only when `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it selects `PwaXtreamDataSource` +- **Catalog lazy loading**: catalog grids scroll infinitely instead of paging. + `withSelection` keeps a `visibleCount` render window over the in-memory + catalog plus bounded per-selection scroll snapshots for detail/tab + round-trips; the shared `InfiniteScrollDirective` + (`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 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 6f32acbd7..35f9d02f7 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -222,23 +222,28 @@ test.describe('Electron Catalog Sorting', () => { } }); - test('keeps Xtream catalog page after opening VOD and series details, and resets grid scroll on page changes', async ({ + test('loads more Xtream catalog items on scroll and restores the spot after opening VOD and series details', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); + // The 'large' scenario has 200 items per category — more than the + // initial render window, so scrolling genuinely has to load more. const vodFixture = await fetchXtreamVodFixture( request, - xtreamCredentials + largeXtreamCredentials ); const seriesFixture = await fetchXtreamSeriesFixture( request, - xtreamCredentials + largeXtreamCredentials ); const app = await launchElectronApp(dataDir); try { - await addXtreamPortal(app.mainWindow); + await addXtreamPortal(app.mainWindow, { + username: largeXtreamCredentials.username, + password: largeXtreamCredentials.password, + }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Movies'); @@ -247,22 +252,25 @@ 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); + await expectCatalogGrowsOnScroll(app.mainWindow); + + // In-category search filters the whole list and resets the scroll. await expectCatalogSearchResetsToFirstPage( app.mainWindow, vodSearchTitle ); + await expect + .poll(() => getCatalogGridScrollTop(app.mainWindow)) + .toBeLessThan(2); await clearCatalogSearch(app.mainWindow); - await expectCatalogScrollResetAfterNextPage(app.mainWindow); - await clickFirstGridListCard(app.mainWindow); - await expectPathname( + + await expectDetailRoundTripRestoresScroll( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ ); - await goBackFromDetail(app.mainWindow); - await expectCatalogPageQuery(app.mainWindow, '2'); - await expectCatalogGridReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Series'); await clickCategoryByNameExact( @@ -270,22 +278,12 @@ test.describe('Electron Catalog Sorting', () => { seriesFixture.categoryName ); await expectCatalogGridReady(app.mainWindow); - const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow); - await expectCatalogScrollResetAfterNextPage(app.mainWindow); - await expectCatalogSearchResetsToFirstPage( - app.mainWindow, - seriesSearchTitle - ); - await clearCatalogSearch(app.mainWindow); - await expectCatalogScrollResetAfterNextPage(app.mainWindow); - await clickFirstGridListCard(app.mainWindow); - await expectPathname( + await expectNoCatalogPaginator(app.mainWindow); + await expectCatalogGrowsOnScroll(app.mainWindow); + await expectDetailRoundTripRestoresScroll( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/ ); - await goBackFromDetail(app.mainWindow); - await expectCatalogPageQuery(app.mainWindow, '2'); - await expectCatalogGridReady(app.mainWindow); } finally { await closeElectronApp(app); } @@ -363,6 +361,12 @@ const xtreamCredentials = { username: defaultXtreamUsername, password: defaultXtreamPassword, }; +// Mock-server scenario with 200 items per category (see xtream-mock-server +// scenarios.ts) — large enough that the infinite-scroll window must grow. +const largeXtreamCredentials = { + username: 'large', + password: 'large', +}; async function setLiveSortMode( page: Page, @@ -458,6 +462,63 @@ async function expectCatalogSearchResetsToFirstPage( }); } +async function expectNoCatalogPaginator(page: Page): Promise { + await expect( + page.locator('.category-content-header mat-paginator') + ).toHaveCount(0); +} + +function catalogCardCount(page: Page): Promise { + return page.locator('.category-content-layout mat-card').count(); +} + +/** + * Scrolls the catalog grid to its bottom and expects the infinite-scroll + * window to append more cards. Returns the grown card count. + */ +async function expectCatalogGrowsOnScroll(page: Page): Promise { + const grid = catalogGrid(page); + + await expect(grid).toBeVisible({ timeout: 20000 }); + const countBefore = await catalogCardCount(page); + await grid.evaluate((element: HTMLElement) => { + element.scrollTo({ top: element.scrollHeight }); + }); + await expect + .poll(() => catalogCardCount(page), { timeout: 20000 }) + .toBeGreaterThan(countBefore); + + return catalogCardCount(page); +} + +/** + * From a grown, scrolled-down grid: opens a visible (bottom) card's detail, + * navigates back, and expects both the render window and the scroll offset to + * be restored instead of landing back at the top of page one. + */ +async function expectDetailRoundTripRestoresScroll( + page: Page, + detailPathname: RegExp +): Promise { + const grownCount = await expectCatalogGrowsOnScroll(page); + await expect.poll(() => getCatalogGridScrollTop(page)).toBeGreaterThan(100); + + // 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); + await goBackFromDetail(page); + + await expectCatalogGridReady(page); + await expectCatalogPageQuery(page, null); + await expect + .poll(() => catalogCardCount(page), { timeout: 20000 }) + .toBeGreaterThanOrEqual(grownCount); + await expect + .poll(() => getCatalogGridScrollTop(page), { timeout: 20000 }) + .toBeGreaterThan(100); +} + async function expectStalkerCatalogSearchResetsToFirstPage( page: Page, options: { categoryId: string; title: string; type: 'series' | 'vod' } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b86d52c6a..6985c5e0b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "كل اللغات", "SAME_AS_ABOVE": "كما في الأعلى", "SHOWING_OF_TOTAL": "{{visible}} من {{total}}" + }, + "GRID": { + "LOADING_MORE": "جارٍ تحميل المزيد…", + "LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر", + "RETRY": "إعادة المحاولة" } }, "INFORMATION": "معلومات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 919812c5c..a49b3ec9d 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "كل اللغات", "SAME_AS_ABOVE": "بحال اللي الفوق", "SHOWING_OF_TOTAL": "{{visible}} من {{total}}" + }, + "GRID": { + "LOADING_MORE": "جارٍ تحميل المزيد…", + "LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر", + "RETRY": "عاود المحاولة" } }, "INFORMATION": "معلومات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 6f3176e29..e1b95deaa 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Усе мовы", "SAME_AS_ABOVE": "як радком вышэй", "SHOWING_OF_TOTAL": "{{visible}} з {{total}}" + }, + "GRID": { + "LOADING_MORE": "Загружаем яшчэ…", + "LOAD_MORE_FAILED": "Не атрымалася загрузіць больш элементаў", + "RETRY": "Паўтарыць" } }, "INFORMATION": "Інфармацыя", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 54c20b104..fedf12e47 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Alle Sprachen", "SAME_AS_ABOVE": "wie oben", "SHOWING_OF_TOTAL": "{{visible}} von {{total}}" + }, + "GRID": { + "LOADING_MORE": "Lade mehr…", + "LOAD_MORE_FAILED": "Weitere Einträge konnten nicht geladen werden", + "RETRY": "Erneut versuchen" } }, "INFORMATION": "Information", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 9807e876d..04f06a43d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Όλες οι γλώσσες", "SAME_AS_ABOVE": "ίδιο με το παραπάνω", "SHOWING_OF_TOTAL": "{{visible}} από {{total}}" + }, + "GRID": { + "LOADING_MORE": "Φόρτωση περισσότερων…", + "LOAD_MORE_FAILED": "Δεν ήταν δυνατή η φόρτωση περισσότερων στοιχείων", + "RETRY": "Επανάληψη" } }, "INFORMATION": "Πληροφορίες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7424a26b8..c8f540f98 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "All languages", "SAME_AS_ABOVE": "same as above", "SHOWING_OF_TOTAL": "{{visible}} of {{total}}" + }, + "GRID": { + "LOADING_MORE": "Loading more…", + "LOAD_MORE_FAILED": "Couldn't load more items", + "RETRY": "Retry" } }, "INFORMATION": "Information", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a709cbeec..4ab2372df 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Todos los idiomas", "SAME_AS_ABOVE": "igual que la anterior", "SHOWING_OF_TOTAL": "{{visible}} de {{total}}" + }, + "GRID": { + "LOADING_MORE": "Cargando más…", + "LOAD_MORE_FAILED": "No se pudieron cargar más elementos", + "RETRY": "Reintentar" } }, "INFORMATION": "Información", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d79ac9f7c..0625168e5 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Toutes les langues", "SAME_AS_ABOVE": "identique à la ligne au-dessus", "SHOWING_OF_TOTAL": "{{visible}} sur {{total}}" + }, + "GRID": { + "LOADING_MORE": "Chargement de la suite…", + "LOAD_MORE_FAILED": "Impossible de charger plus d'éléments", + "RETRY": "Réessayer" } }, "INFORMATION": "Information", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 6bbc22a7a..ba496ef2a 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Minden nyelv", "SAME_AS_ABOVE": "ugyanaz, mint fent", "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" + }, + "GRID": { + "LOADING_MORE": "További elemek betöltése…", + "LOAD_MORE_FAILED": "Nem sikerült több elemet betölteni", + "RETRY": "Újra" } }, "INFORMATION": "Információk", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index b5d2ef763..f9b5739f4 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Tutte le lingue", "SAME_AS_ABOVE": "uguale alla riga sopra", "SHOWING_OF_TOTAL": "{{visible}} di {{total}}" + }, + "GRID": { + "LOADING_MORE": "Caricamento di altri elementi…", + "LOAD_MORE_FAILED": "Impossibile caricare altri elementi", + "RETRY": "Riprova" } }, "INFORMATION": "Informazioni", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 721577a17..5538244c3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "すべての言語", "SAME_AS_ABOVE": "上と同じ", "SHOWING_OF_TOTAL": "{{total}}件中{{visible}}件" + }, + "GRID": { + "LOADING_MORE": "さらに読み込み中…", + "LOAD_MORE_FAILED": "追加の項目を読み込めませんでした", + "RETRY": "再試行" } }, "INFORMATION": "情報", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 11656c3bb..747f036cd 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "모든 언어", "SAME_AS_ABOVE": "위와 동일", "SHOWING_OF_TOTAL": "{{total}}개 중 {{visible}}개" + }, + "GRID": { + "LOADING_MORE": "더 불러오는 중…", + "LOAD_MORE_FAILED": "추가 항목을 불러오지 못했습니다", + "RETRY": "다시 시도" } }, "INFORMATION": "정보", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index e8de42878..e1c7b1287 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Alle talen", "SAME_AS_ABOVE": "zelfde als hierboven", "SHOWING_OF_TOTAL": "{{visible}} van {{total}}" + }, + "GRID": { + "LOADING_MORE": "Meer laden…", + "LOAD_MORE_FAILED": "Kan geen extra items laden", + "RETRY": "Opnieuw proberen" } }, "INFORMATION": "Informatie", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1113ddebb..dcbf370a8 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Wszystkie języki", "SAME_AS_ABOVE": "tak samo jak wyżej", "SHOWING_OF_TOTAL": "{{visible}} z {{total}}" + }, + "GRID": { + "LOADING_MORE": "Wczytywanie kolejnych…", + "LOAD_MORE_FAILED": "Nie udało się wczytać kolejnych pozycji", + "RETRY": "Ponów" } }, "INFORMATION": "Informacje", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index d29fbe486..e2ca58bfd 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Todos os idiomas", "SAME_AS_ABOVE": "igual à linha acima", "SHOWING_OF_TOTAL": "{{visible}} de {{total}}" + }, + "GRID": { + "LOADING_MORE": "A carregar mais…", + "LOAD_MORE_FAILED": "Não foi possível carregar mais itens", + "RETRY": "Tentar novamente" } }, "INFORMATION": "Informação", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 7f4762105..569d5657c 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Все языки", "SAME_AS_ABOVE": "как строкой выше", "SHOWING_OF_TOTAL": "{{visible}} из {{total}}" + }, + "GRID": { + "LOADING_MORE": "Загружаем ещё…", + "LOAD_MORE_FAILED": "Не удалось загрузить больше элементов", + "RETRY": "Повторить" } }, "INFORMATION": "Инфо", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index e6c4c0353..bee89036e 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "Tüm diller", "SAME_AS_ABOVE": "üsttekiyle aynı", "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" + }, + "GRID": { + "LOADING_MORE": "Daha fazla yükleniyor…", + "LOAD_MORE_FAILED": "Daha fazla öğe yüklenemedi", + "RETRY": "Yeniden dene" } }, "INFORMATION": "Bilgi", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index a70d12f72..0829df4ac 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "所有语言", "SAME_AS_ABOVE": "与上行相同", "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" + }, + "GRID": { + "LOADING_MORE": "正在加载更多…", + "LOAD_MORE_FAILED": "无法加载更多内容", + "RETRY": "重试" } }, "INFORMATION": "信息", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c1f745555..551895e4b 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1084,6 +1084,11 @@ "ALL_LANGUAGES": "所有語言", "SAME_AS_ABOVE": "與上行相同", "SHOWING_OF_TOTAL": "{{visible}} / {{total}}" + }, + "GRID": { + "LOADING_MORE": "正在載入更多…", + "LOAD_MORE_FAILED": "無法載入更多內容", + "RETRY": "重試" } }, "INFORMATION": "資訊", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 734b276b4..d7cf475ff 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -420,6 +420,8 @@ 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/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 584c86464..ef4d875a3 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,7 +204,7 @@ } } - @if (categoryItemCount() > 0) { + @if (!supportsInfiniteScroll && categoryItemCount() > 0) { } 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 f4e9ff8fc..97e10b4cd 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 @@ -11,6 +11,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EMPTY, ReplaySubject, of } from 'rxjs'; +import { InfiniteScrollDirective } from '@iptvnator/portal/shared/ui'; import { PORTAL_CATALOG_DETAIL_COMPONENT, PORTAL_CATALOG_FACADE, @@ -25,16 +26,13 @@ import { CategoryContentViewComponent } from './category-content-view.component' }) class MockGridListComponent { readonly isLoading = input(); + readonly isAppending = input(); + readonly appendError = input(); readonly items = input(); - readonly pageIndex = input(); - readonly totalPages = input(); - readonly limit = input(); - readonly pageSizeOptions = input(); - readonly showPaginator = input(true); readonly searchTerm = input(''); readonly type = input(''); readonly itemClicked = output(); - readonly pageChange = output(); + readonly retryLoadMore = output(); } @Component({ @@ -67,8 +65,12 @@ describe('CategoryContentViewComponent', () => { const contentSortMode = signal(null); const minRating = signal(null); const selectedItem = signal | null>(null); + const hasMore = signal(false); + const isAppending = signal(false); + 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), @@ -79,6 +81,9 @@ describe('CategoryContentViewComponent', () => { categoryItemCount, selectedItem, totalPages: signal(0), + hasMore, + isAppending, + appendError, contentSortMode, supportsRatingSort: true, minRating, @@ -89,6 +94,10 @@ describe('CategoryContentViewComponent', () => { clearSelectedItem: jest.fn(), setPage: jest.fn(), setLimit: jest.fn(), + loadMore: jest.fn(), + retryAppend: jest.fn(), + saveScrollPosition: jest.fn(), + consumeSavedScrollPosition: jest.fn().mockReturnValue(null), setContentSortMode: jest.fn(), setMinRating: jest.fn(), selectItem: jest.fn().mockReturnValue(null), @@ -99,16 +108,25 @@ 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); contentSortMode.set(null); catalog.supportsRatingSort = true; minRating.set(null); + hasMore.set(false); + isAppending.set(false); + appendError.set(false); catalog.initialize.mockClear(); catalog.setSearchQuery.mockClear(); catalog.setPage.mockClear(); catalog.setLimit.mockClear(); + catalog.loadMore.mockClear(); + catalog.retryAppend.mockClear(); + catalog.saveScrollPosition.mockClear(); + catalog.consumeSavedScrollPosition.mockClear(); + catalog.consumeSavedScrollPosition.mockReturnValue(null); catalog.setContentSortMode.mockClear(); catalog.setMinRating.mockClear(); catalog.selectItem.mockClear(); @@ -177,6 +195,7 @@ describe('CategoryContentViewComponent', () => { set: { imports: [ NgComponentOutlet, + InfiniteScrollDirective, MockGridListComponent, MockPlaylistErrorViewComponent, MatIcon, @@ -554,4 +573,132 @@ describe('CategoryContentViewComponent', () => { .componentInstance as MockDetailComponent; expect(detail.providerOnly()).toBe(false); }); + + describe('infinite scroll mode', () => { + let rafCallbacks: FrameRequestCallback[]; + + beforeEach(() => { + rafCallbacks = []; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + } + ); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation( + () => undefined + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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. + fixture.destroy(); + catalog.supportsInfiniteScroll = true; + isPaginatedContentLoading.set(false); + return TestBed.createComponent(CategoryContentViewComponent); + } + + function flushAnimationFrames(): void { + while (rafCallbacks.length) { + const callback = rafCallbacks.shift(); + callback?.(0); + } + } + + it('renders no paginator and delegates loadMore to the facade', () => { + const infiniteFixture = createInfiniteFixture(); + categoryItemCount.set(12); + catalog.paginatedContent.set([{ xtream_id: 1, title: 'A' }]); + + infiniteFixture.detectChanges(); + + expect( + infiniteFixture.nativeElement.querySelector('mat-paginator') + ).toBeNull(); + + infiniteFixture.componentInstance.onLoadMore(); + expect(catalog.loadMore).toHaveBeenCalledTimes(1); + }); + + it('strips a stale page query param instead of paging', () => { + const infiniteFixture = createInfiniteFixture(); + queryParamMap$.next(convertToParamMap({ page: '3' })); + + infiniteFixture.detectChanges(); + + expect(catalog.setPage).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ + queryParams: { page: null }, + replaceUrl: true, + }) + ); + }); + + it('saves the grid scroll position before opening a detail', () => { + const infiniteFixture = createInfiniteFixture(); + infiniteFixture.detectChanges(); + const grid = infiniteFixture.nativeElement.querySelector( + 'app-grid-list' + ) as HTMLElement; + Object.defineProperty(grid, 'scrollTop', { + configurable: true, + value: 333, + }); + + infiniteFixture.componentInstance.onItemClick({ xtream_id: 42 }); + + expect(catalog.saveScrollPosition).toHaveBeenCalledWith(333); + }); + + it('consumes a saved scroll position once the list is rendered', () => { + catalog.consumeSavedScrollPosition.mockReturnValue(500); + const infiniteFixture = createInfiniteFixture(); + + infiniteFixture.detectChanges(); + + expect(catalog.consumeSavedScrollPosition).toHaveBeenCalledTimes( + 1 + ); + + const grid = infiniteFixture.nativeElement.querySelector( + 'app-grid-list' + ) as HTMLElement; + const scrollTo = jest.fn(); + Object.defineProperty(grid, 'scrollTo', { + configurable: true, + value: scrollTo, + }); + + flushAnimationFrames(); + + expect(scrollTo).toHaveBeenCalledWith({ top: 500 }); + }); + + it('scrolls the grid to the top when the reset key changes', () => { + const infiniteFixture = createInfiniteFixture(); + contentSortMode.set('date-desc'); + infiniteFixture.detectChanges(); + + const grid = infiniteFixture.nativeElement.querySelector( + 'app-grid-list' + ) as HTMLElement; + const scrollTo = jest.fn(); + Object.defineProperty(grid, 'scrollTo', { + configurable: true, + value: scrollTo, + }); + + contentSortMode.set('name-asc'); + infiniteFixture.detectChanges(); + + expect(scrollTo).toHaveBeenCalledWith({ top: 0 }); + }); + }); }); 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 da4de7d9c..27675b5e1 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 @@ -4,8 +4,10 @@ import { Component, computed, DestroyRef, + effect, ElementRef, inject, + OnDestroy, OnInit, signal, } from '@angular/core'; @@ -20,6 +22,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { GridListComponent, + InfiniteScrollDirective, PlaylistErrorViewComponent, } from '@iptvnator/portal/shared/ui'; import { @@ -52,6 +55,7 @@ interface CategoryContentItem { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GridListComponent, + InfiniteScrollDirective, MatButtonModule, MatIcon, MatMenuModule, @@ -62,7 +66,7 @@ interface CategoryContentItem { TranslatePipe, ], }) -export class CategoryContentViewComponent implements OnInit { +export class CategoryContentViewComponent implements OnInit, OnDestroy { private readonly activatedRoute = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); private readonly hostElement = inject(ElementRef); @@ -81,17 +85,33 @@ export class CategoryContentViewComponent implements OnInit { readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT); readonly contentType = this.catalog.contentType; - readonly limit = this.catalog.limit; - readonly pageIndex = this.catalog.pageIndex; - readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions); + /** + * 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; readonly categoryItemCount = this.catalog.categoryItemCount; readonly selectedItem = this.catalog.selectedItem; - readonly totalPages = this.catalog.totalPages; 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 isXtreamLoadingSubtitle = computed( () => this.catalog.provider === 'xtream' && @@ -172,6 +192,71 @@ export class CategoryContentViewComponent implements OnInit { ...this.catalog.getItemProgress(item), })) ); + /** + * Identity of the rendered list. A change means the grid shows a + * different result set: the scroll resets to the top and the infinite + * scroll directive gets a fresh auto-fill budget. Appends keep the key + * stable, so they never scroll. + */ + readonly gridResetKey = computed(() => { + const category = this.selectedCategory(); + const categoryId = category?.category_id ?? category?.id ?? ''; + return [ + this.contentType() ?? '', + String(categoryId), + this.searchTerm(), + this.contentSortMode() ?? '', + String(this.minRating() ?? ''), + ].join('|'); + }); + private previousGridResetKey: string | null = null; + private hasAttemptedScrollRestore = false; + + constructor() { + effect(() => { + const resetKey = this.gridResetKey(); + if (!this.supportsInfiniteScroll) { + return; + } + + if ( + this.previousGridResetKey !== null && + this.previousGridResetKey !== resetKey + ) { + this.scrollGridToTop(); + } + this.previousGridResetKey = resetKey; + }); + + // 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. + effect(() => { + if ( + !this.supportsInfiniteScroll || + this.hasAttemptedScrollRestore || + this.isPaginatedContentLoading() || + this.selectedItem() + ) { + return; + } + + this.hasAttemptedScrollRestore = true; + const scrollTop = + this.catalog.consumeSavedScrollPosition?.() ?? null; + if (scrollTop === null || scrollTop <= 0) { + return; + } + + // Two frames: one for change detection to render the restored + // window, one to apply the offset to the grown grid. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.gridElement()?.scrollTo?.({ top: scrollTop }); + }); + }); + }); + } setContentSortMode(mode: PortalCatalogSortMode): void { this.catalog.setContentSortMode(mode); @@ -194,14 +279,25 @@ export class CategoryContentViewComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { const searchQuery = params.get('q') ?? ''; - const pageIndex = this.toPageIndex(params.get('page')); 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; + } + + const pageIndex = this.toPageIndex(params.get('page')); + if (!this.hasAppliedInitialQueryParams) { this.hasAppliedInitialQueryParams = true; this.previousSearchQuery = searchQuery; - this.catalog.setPage(pageIndex); + this.catalog.setPage?.(pageIndex); return; } @@ -210,20 +306,32 @@ export class CategoryContentViewComponent implements OnInit { this.previousSearchQuery = searchQuery; if (didSearchChange) { - this.catalog.setPage(0); + this.catalog.setPage?.(0); if (params.has('page')) { this.clearPageQueryParam(); } return; } - this.catalog.setPage(pageIndex); + this.catalog.setPage?.(pageIndex); }); } + ngOnDestroy(): void { + // 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()) { + const grid = this.gridElement(); + if (grid) { + this.catalog.saveScrollPosition?.(grid.scrollTop); + } + } + } + onPageChange(event: PageEvent): void { - this.catalog.setPage(event.pageIndex); - this.catalog.setLimit(event.pageSize); + this.catalog.setPage?.(event.pageIndex); + this.catalog.setLimit?.(event.pageSize); this.scrollGridToTop(); void this.router.navigate([], { @@ -236,7 +344,23 @@ export class CategoryContentViewComponent implements OnInit { }); } + onLoadMore(): void { + this.catalog.loadMore?.(); + } + + onRetryAppend(): void { + 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); + } + } + this.providerOnlyStalkerItemId.set(null); const navigation = this.catalog.selectItem(item); if (navigation?.length) { @@ -252,11 +376,14 @@ export class CategoryContentViewComponent implements OnInit { return Number.isInteger(page) && page > 0 ? page - 1 : 0; } - private scrollGridToTop(): void { - const gridList = this.hostElement.nativeElement.querySelector( + private gridElement(): HTMLElement | null { + return this.hostElement.nativeElement.querySelector( 'app-grid-list' ) as HTMLElement | null; - gridList?.scrollTo?.({ top: 0 }); + } + + private scrollGridToTop(): void { + this.gridElement()?.scrollTo?.({ top: 0 }); } private clearPageQueryParam(): void { diff --git a/libs/portal/shared/ui/src/index.ts b/libs/portal/shared/ui/src/index.ts index 4b6df6e72..0f3a180f3 100644 --- a/libs/portal/shared/ui/src/index.ts +++ b/libs/portal/shared/ui/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/components/category-view/category-view.component'; +export * from './lib/directives/infinite-scroll.directive'; export * from './lib/components/content-card/content-card.component'; export * from './lib/components/content-rail-shell/content-rail-shell.component'; export * from './lib/components/favorites-layout/favorites-layout.component'; diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index 27fb49d51..ab0852775 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -202,3 +202,24 @@ mat-card:hover { } } } + +// ─── Infinite scroll ───────────────────────────────────────────────────────── +// Off-screen cards skip rendering work; the intrinsic-size hint keeps the +// scrollbar stable while they are skipped (poster 2/3 ratio + title row). +.grid-list__grid mat-card { + content-visibility: auto; + contain-intrinsic-size: auto 270px; +} + +.grid-list__tail { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 18px 0 6px; + color: var(--app-muted-color, inherit); + + &.grid-list__tail--error { + color: var(--mat-sys-error, inherit); + } +} diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index 2d77d89ec..ab32cc20a 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -7,9 +7,10 @@ import { output, signal, } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIcon } from '@angular/material/icon'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; @@ -220,24 +221,32 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { } } - @if (showPaginator() && items().length > 0) { - - } `, + @if (isAppending()) { +
+ + {{ 'PORTALS.GRID.LOADING_MORE' | translate }} +
+ } @else if (appendError()) { + + }`, styleUrl: './grid-list.component.scss', imports: [ TranslatePipe, PlaylistErrorViewComponent, + MatButtonModule, MatCardModule, MatIcon, + MatProgressSpinnerModule, MatTooltip, - MatPaginatorModule, ProgressCapsuleComponent, WatchedBadgeComponent, ], @@ -249,15 +258,14 @@ export class GridListComponent { readonly items = input([]); readonly isLoading = input(false); - readonly showPaginator = input(true); + /** True while an infinite-scroll append is in flight (tail spinner). */ + readonly isAppending = input(false); + /** True when the latest append failed; renders the retry tail. */ + readonly appendError = input(false); readonly searchTerm = input(''); readonly itemClicked = output(); - readonly pageChange = output(); + readonly retryLoadMore = output(); - readonly pageIndex = input(0); - readonly totalPages = input(0); - readonly limit = input(25); - readonly pageSizeOptions = input([]); readonly variant = input<'poster' | 'logo'>('poster'); readonly type = input<'vod' | 'series' | 'live' | string>(''); protected readonly resolveRating = resolveGridRating; @@ -293,11 +301,9 @@ export class GridListComponent { return stripped || 'No name'; }; - readonly skeletonRows = computed(() => { - const preferredCount = this.limit() ?? 12; - const count = Math.max(8, Math.min(18, preferredCount)); - return Array.from({ length: count }, (_, index) => index); - }); + readonly skeletonRows = computed(() => + Array.from({ length: 12 }, (_, index) => index) + ); protected hasArtworkFailed(poster: string): boolean { return this.failedArtworkUrls().has(poster); 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 419b28f15..46ca841bf 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 @@ -61,7 +61,12 @@
@if (isLoading()) {
diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts index 67c72e15e..074597517 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts @@ -113,4 +113,114 @@ describe('SearchLayoutComponent', () => { resultsContainer.dispatchEvent(new Event('scroll')); expect(nearEndSpy).toHaveBeenCalledTimes(2); }); + + it('resets the near-end latch when the reset identity changes without a new term', () => { + // Regression: a filter-only search transition replaces the result set + // while the term stays the same; the latch must not survive it. + const nearEndSpy = jest.fn(); + fixture.componentInstance.nearEnd.subscribe(nearEndSpy); + const resultsContainer = renderResultsContainer(); + + setScrollMetrics(resultsContainer, 700); + resultsContainer.dispatchEvent(new Event('scroll')); + expect(nearEndSpy).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('nearEndResetKey', 'matrix|movie-only'); + fixture.detectChanges(); + + // Still inside the threshold — a stale latch would swallow this. + resultsContainer.dispatchEvent(new Event('scroll')); + expect(nearEndSpy).toHaveBeenCalledTimes(2); + }); + + it('does not emit nearEnd when the consumer reports no more results', () => { + const nearEndSpy = jest.fn(); + fixture.componentInstance.nearEnd.subscribe(nearEndSpy); + fixture.componentRef.setInput('nearEndHasMore', false); + const resultsContainer = renderResultsContainer(); + + setScrollMetrics(resultsContainer, 500); + resultsContainer.dispatchEvent(new Event('scroll')); + setScrollMetrics(resultsContainer, 700); + resultsContainer.dispatchEvent(new Event('scroll')); + + expect(nearEndSpy).not.toHaveBeenCalled(); + }); + + it('re-measures when the rendered window grows while the total stays constant', () => { + // Regression (bots, round 2): binding the constant result-set total to + // the directive left no tracked input changing after the consumer + // revealed a chunk, so the auto-fill stopped after one expansion. + const rafCallbacks: FrameRequestCallback[] = []; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + } + ); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation( + () => undefined + ); + + try { + const nearEndSpy = jest.fn(); + fixture.componentInstance.nearEnd.subscribe(nearEndSpy); + fixture.componentRef.setInput('searchTerm', 'matrix'); + fixture.componentRef.setInput('resultsCount', 130); + fixture.componentRef.setInput('nearEndRenderedCount', 60); + fixture.detectChanges(); + + const flush = () => { + while (rafCallbacks.length) { + const callback = rafCallbacks.shift(); + callback?.(0); + } + }; + + flush(); + expect(nearEndSpy).toHaveBeenCalledTimes(1); + + // The consumer reveals the next chunk; the total does not change, + // but the rendered count must schedule another overflow check. + fixture.componentRef.setInput('nearEndRenderedCount', 120); + fixture.detectChanges(); + flush(); + expect(nearEndSpy).toHaveBeenCalledTimes(2); + } finally { + jest.restoreAllMocks(); + } + }); + + it('auto-fills via nearEnd when the rendered results do not overflow', () => { + // Regression: a result window larger than the viewport-visible chunk + // must not stall when the rendered cards never create a scrollbar — + // the overflow check has to reveal further chunks without any scroll. + const rafCallbacks: FrameRequestCallback[] = []; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + } + ); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation( + () => undefined + ); + + try { + const nearEndSpy = jest.fn(); + fixture.componentInstance.nearEnd.subscribe(nearEndSpy); + renderResultsContainer(); + + // JSDOM default geometry (all zeros) models a non-overflowing + // container; flushing the scheduled overflow check must emit. + while (rafCallbacks.length) { + const callback = rafCallbacks.shift(); + callback?.(0); + } + + expect(nearEndSpy).toHaveBeenCalled(); + } finally { + jest.restoreAllMocks(); + } + }); }); 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 2e121e660..96457d007 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 @@ -9,12 +9,14 @@ import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; +import { InfiniteScrollDirective } from '../../directives/infinite-scroll.directive'; import { SearchFormComponent } from '../search-form/search-form.component'; @Component({ selector: 'app-search-layout', standalone: true, imports: [ + InfiniteScrollDirective, MatIcon, MatIconButton, MatProgressSpinner, @@ -27,8 +29,6 @@ import { SearchFormComponent } from '../search-form/search-form.component'; }) export class SearchLayoutComponent { private readonly searchFormComponent = viewChild(SearchFormComponent); - private readonly nearEndThresholdPx = 240; - private isWithinNearEndThreshold = false; /** Page title translation key */ readonly title = input('PORTALS.SIDEBAR.SEARCH'); @@ -60,6 +60,38 @@ export class SearchLayoutComponent { /** Minimum characters required for search */ readonly minSearchLength = input(3); + /** + * Whether the consumer can supply more results than are rendered. Drives + * the results container's infinite scroll: near-end crossings and the + * measured auto-fill (which reveals further chunks even when the current + * ones do not overflow a tall viewport) both emit `nearEnd` only while + * this is true. Defaults to true, matching the historical unconditional + * `nearEnd` emission for consumers that manage their own guards. + */ + readonly nearEndHasMore = input(true); + + /** True while the consumer is appending; suppresses further triggers. */ + readonly nearEndAppending = input(false); + + /** + * Number of currently RENDERED results — the infinite scroll re-measures + * overflow when this changes, so it must grow with the revealed window. + * `resultsCount` cannot serve here: it is the total result-set size and + * stays constant while a consumer reveals chunks of it, which would stop + * the auto-fill after the first chunk. Defaults to `resultsCount` for + * consumers that always render everything they report. + */ + readonly nearEndRenderedCount = input(null); + + /** + * Identity of the current result set for the infinite scroll's latch and + * auto-fill budget. Must change whenever the result set is replaced — + * including filter-only transitions where the term stays the same, or the + * stale latch can swallow the first jump back into the threshold. + * Defaults to the search term for consumers without extra filters. + */ + readonly nearEndResetKey = input(null); + /** Initial state description translation key */ readonly initialDescriptionKey = input( 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' @@ -74,7 +106,11 @@ export class SearchLayoutComponent { /** Emitted when the back button is clicked */ readonly backClick = output(); - /** Emitted when the scroll container is close to the bottom */ + /** + * Emitted when more results should be revealed — on scrolling near the + * bottom and by the auto-fill overflow check (see `InfiniteScrollDirective` + * on the results container). + */ readonly nearEnd = output(); /** Focus the search input */ @@ -94,23 +130,6 @@ export class SearchLayoutComponent { this.backClick.emit(); } - onSearchContentScroll(event: Event): void { - const target = event.target as HTMLElement | null; - if (!target) { - return; - } - - const distanceToBottom = - target.scrollHeight - target.scrollTop - target.clientHeight; - const isNearEnd = distanceToBottom <= this.nearEndThresholdPx; - - if (isNearEnd && !this.isWithinNearEndThreshold) { - this.nearEnd.emit(); - } - - this.isWithinNearEndThreshold = isNearEnd; - } - /** Check if we should show the "no results" state */ get showNoResults(): boolean { return ( diff --git a/libs/portal/shared/ui/src/lib/directives/infinite-scroll.directive.spec.ts b/libs/portal/shared/ui/src/lib/directives/infinite-scroll.directive.spec.ts new file mode 100644 index 000000000..bd82fe389 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/directives/infinite-scroll.directive.spec.ts @@ -0,0 +1,235 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { InfiniteScrollDirective } from './infinite-scroll.directive'; + +@Component({ + template: `
`, + imports: [InfiniteScrollDirective], +}) +class TestHostComponent { + readonly hasMore = signal(true); + readonly appending = signal(false); + readonly itemCount = signal(0); + readonly resetKey = signal('a'); + loads = 0; + + onLoadMore(): void { + this.loads += 1; + } +} + +describe('InfiniteScrollDirective', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let scrollHost: HTMLElement; + let rafCallbacks: FrameRequestCallback[]; + + beforeEach(async () => { + rafCallbacks = []; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + } + ); + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation( + () => undefined + ); + + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + scrollHost = fixture.debugElement.query(By.css('.scroll-host')) + .nativeElement as HTMLElement; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function setScrollMetrics( + scrollTop: number, + scrollHeight = 1000, + clientHeight = 120 + ): void { + Object.defineProperties(scrollHost, { + scrollHeight: { configurable: true, value: scrollHeight }, + scrollTop: { configurable: true, value: scrollTop }, + clientHeight: { configurable: true, value: clientHeight }, + }); + } + + function flushScheduledChecks(): void { + while (rafCallbacks.length) { + const callback = rafCallbacks.shift(); + callback?.(0); + } + } + + it('emits loadMore only when crossing into the bottom threshold', () => { + flushScheduledChecks(); + host.loads = 0; + + setScrollMetrics(500); + scrollHost.dispatchEvent(new Event('scroll')); + expect(host.loads).toBe(0); + + setScrollMetrics(700); + scrollHost.dispatchEvent(new Event('scroll')); + expect(host.loads).toBe(1); + + // Still inside the threshold — no re-fire without leaving it first. + setScrollMetrics(710); + scrollHost.dispatchEvent(new Event('scroll')); + expect(host.loads).toBe(1); + + setScrollMetrics(500); + scrollHost.dispatchEvent(new Event('scroll')); + setScrollMetrics(760); + scrollHost.dispatchEvent(new Event('scroll')); + expect(host.loads).toBe(2); + }); + + it('does not emit on scroll while appending or when nothing more exists', () => { + flushScheduledChecks(); + host.loads = 0; + + host.appending.set(true); + fixture.detectChanges(); + setScrollMetrics(700); + scrollHost.dispatchEvent(new Event('scroll')); + expect(host.loads).toBe(0); + + host.appending.set(false); + host.hasMore.set(false); + fixture.detectChanges(); + flushScheduledChecks(); + host.loads = 0; + setScrollMetrics(0); + scrollHost.dispatchEvent(new Event('scroll')); + setScrollMetrics(700); + scrollHost.dispatchEvent(new Event('scroll')); + expect(host.loads).toBe(0); + }); + + it('auto-fills when the container does not overflow the viewport', () => { + // JSDOM default geometry (all zeros) models an unfilled container. + setScrollMetrics(0, 0, 0); + flushScheduledChecks(); + + expect(host.loads).toBe(1); + }); + + it('keeps auto-filling while loads grow the container, until it overflows', () => { + // A huge viewport: many chunks fit before a scrollbar appears. The + // auto-fill must not stop at an arbitrary load budget — only overflow + // (or lack of progress) may end it, or the tail stays unreachable. + let contentHeight = 0; + setScrollMetrics(0, contentHeight, 5000); + flushScheduledChecks(); + expect(host.loads).toBe(1); + + for (let round = 0; round < 14; round++) { + contentHeight += 300; + setScrollMetrics(0, contentHeight, 5000); + host.itemCount.update((count) => count + 14); + fixture.detectChanges(); + flushScheduledChecks(); + } + expect(host.loads).toBe(15); + + // The container finally overflows past the threshold — no more + // self-initiated loads; scrolling takes over from here. + setScrollMetrics(0, 6000, 5000); + host.itemCount.update((count) => count + 14); + fixture.detectChanges(); + flushScheduledChecks(); + expect(host.loads).toBe(15); + }); + + it('stops auto-filling after consecutive loads without container growth', () => { + setScrollMetrics(0, 0, 0); + flushScheduledChecks(); + expect(host.loads).toBe(1); + + // Each "append" changes the item count but never grows the container + // — a degenerate source; the stall guard ends the loop. + for (let round = 0; round < 20; round++) { + host.itemCount.update((count) => count + 14); + fixture.detectChanges(); + flushScheduledChecks(); + } + + expect(host.loads).toBe(3); + }); + + it('resets the stall guard when the reset key changes', () => { + setScrollMetrics(0, 0, 0); + flushScheduledChecks(); + for (let round = 0; round < 20; round++) { + host.itemCount.update((count) => count + 14); + fixture.detectChanges(); + flushScheduledChecks(); + } + expect(host.loads).toBe(3); + + host.resetKey.set('b'); + fixture.detectChanges(); + flushScheduledChecks(); + + expect(host.loads).toBe(4); + }); + + it('clears a stale near-end latch when appended content moves the bottom away', () => { + // Empty container: the auto-fill fires and latches "within threshold". + setScrollMetrics(0, 0, 0); + flushScheduledChecks(); + expect(host.loads).toBe(1); + + // The append grows the content well past the threshold; the fill + // check must refresh the latch from the measured state. + setScrollMetrics(0, 2000, 400); + host.itemCount.set(50); + fixture.detectChanges(); + flushScheduledChecks(); + expect(host.loads).toBe(1); + + // Jumping straight to the bottom (End key / scrollbar drag) is a + // genuine crossing again — a stale latch would swallow it. + setScrollMetrics(1600, 2000, 400); + scrollHost.dispatchEvent(new Event('scroll')); + expect(host.loads).toBe(2); + }); + + it('does not auto-fill while an append is in flight', () => { + setScrollMetrics(0, 0, 0); + host.appending.set(true); + fixture.detectChanges(); + flushScheduledChecks(); + host.loads = 0; + + host.itemCount.set(14); + fixture.detectChanges(); + flushScheduledChecks(); + expect(host.loads).toBe(0); + + // The append settling schedules the next overflow check. + host.appending.set(false); + fixture.detectChanges(); + flushScheduledChecks(); + expect(host.loads).toBe(1); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/directives/infinite-scroll.directive.ts b/libs/portal/shared/ui/src/lib/directives/infinite-scroll.directive.ts new file mode 100644 index 000000000..9b60d0eda --- /dev/null +++ b/libs/portal/shared/ui/src/lib/directives/infinite-scroll.directive.ts @@ -0,0 +1,165 @@ +import { + DestroyRef, + Directive, + ElementRef, + effect, + inject, + input, + output, + untracked, +} from '@angular/core'; + +/** + * Matches the `nearEnd` threshold already used by `SearchLayoutComponent` so + * both infinite-scroll surfaces trigger at the same distance from the bottom. + */ +const NEAR_END_THRESHOLD_PX = 240; + +/** + * How many consecutive self-initiated loads may complete WITHOUT growing the + * container before the auto-fill stops. Progress (a growing `scrollHeight`) + * always resets the count, so a huge viewport keeps filling until it actually + * overflows — a fixed load budget would strand the remaining items with no + * scrollbar to reach them. Only a degenerate source that reports more items + * but renders nothing trips this guard. + */ +const MAX_AUTO_FILL_STALLS = 3; + +/** + * Infinite scroll for a scrollable list container. + * + * Two triggers emit `infiniteLoadMore`: + * - edge-triggered near-end detection while the user scrolls (fires once per + * crossing into the threshold, mirroring `SearchLayoutComponent`); + * - an auto-fill check that measures the container instead of guessing item + * sizes: whenever the rendered list changes (or the container resizes) and + * the bottom is still within the threshold, another load is requested. This + * fills viewports taller than one provider page without any card-size math. + * + * The host element must be the scroll container itself (`overflow-y: auto`). + */ +@Directive({ + selector: '[appInfiniteScroll]', + standalone: true, + host: { '(scroll)': 'onScroll()' }, +}) +export class InfiniteScrollDirective { + private readonly host = inject(ElementRef); + private readonly destroyRef = inject(DestroyRef); + + /** Whether the data source can supply more items. */ + readonly infiniteHasMore = input(false); + /** True while an asynchronous append is in flight; suppresses triggers. */ + readonly infiniteAppending = input(false); + /** Rendered item count; a change schedules an overflow re-check. */ + readonly infiniteItemCount = input(0); + /** Identity of the rendered list; a change resets the auto-fill budget. */ + readonly infiniteResetKey = input(''); + readonly infiniteLoadMore = output(); + + private isWithinNearEndThreshold = false; + private autoFillStalls = 0; + private lastAutoFillScrollHeight: number | null = null; + private pendingCheckFrame: number | null = null; + + constructor() { + effect(() => { + this.infiniteResetKey(); + untracked(() => { + this.autoFillStalls = 0; + this.lastAutoFillScrollHeight = null; + this.isWithinNearEndThreshold = false; + this.scheduleFillCheck(); + }); + }); + + effect(() => { + this.infiniteItemCount(); + this.infiniteHasMore(); + const appending = this.infiniteAppending(); + untracked(() => { + if (!appending) { + this.scheduleFillCheck(); + } + }); + }); + + const resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => this.scheduleFillCheck()); + resizeObserver?.observe(this.host.nativeElement); + + this.destroyRef.onDestroy(() => { + resizeObserver?.disconnect(); + if (this.pendingCheckFrame !== null) { + cancelAnimationFrame(this.pendingCheckFrame); + this.pendingCheckFrame = null; + } + }); + } + + protected onScroll(): void { + const isNearEnd = this.isNearEnd(); + + if (isNearEnd && !this.isWithinNearEndThreshold && this.canLoad()) { + this.infiniteLoadMore.emit(); + } + + this.isWithinNearEndThreshold = isNearEnd; + } + + private scheduleFillCheck(): void { + if (this.pendingCheckFrame !== null) { + return; + } + + this.pendingCheckFrame = requestAnimationFrame(() => { + this.pendingCheckFrame = null; + this.runFillCheck(); + }); + } + + private runFillCheck(): void { + // Refresh the edge latch from the measured state: appended content can + // move the bottom out of the threshold without any scroll event, and a + // stale latch would swallow the next genuine crossing (End key, + // scrollbar drag straight to the bottom). + const isNearEnd = this.isNearEnd(); + this.isWithinNearEndThreshold = isNearEnd; + + if (!isNearEnd || !this.canLoad()) { + return; + } + + // Terminate on lack of progress, not on a load count: keep requesting + // while loads grow the container (until it overflows and real scroll + // events take over), stop once they demonstrably stop growing it. + const { scrollHeight } = this.host.nativeElement; + if ( + this.lastAutoFillScrollHeight !== null && + scrollHeight <= this.lastAutoFillScrollHeight + ) { + this.autoFillStalls += 1; + } else { + this.autoFillStalls = 0; + } + + if (this.autoFillStalls >= MAX_AUTO_FILL_STALLS) { + return; + } + + this.lastAutoFillScrollHeight = scrollHeight; + this.infiniteLoadMore.emit(); + } + + private isNearEnd(): boolean { + const { scrollHeight, scrollTop, clientHeight } = + this.host.nativeElement; + return scrollHeight - scrollTop - clientHeight <= NEAR_END_THRESHOLD_PX; + } + + private canLoad(): boolean { + return this.infiniteHasMore() && !this.infiniteAppending(); + } +} 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 b70d3b184..ef1c27c39 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -33,19 +33,48 @@ export interface PortalCatalogFacade< TSelectedItem = unknown, > { readonly provider: PortalCatalogProvider; - readonly pageSizeOptions: readonly number[]; readonly contentType: Signal; - readonly limit: Signal; - readonly pageIndex: Signal; readonly selectedCategory: Signal; readonly paginatedContent: Signal; readonly selectedItem: Signal; - readonly totalPages: Signal; readonly isPaginatedContentLoading: Signal; readonly selectedCategoryTitle: Signal; readonly categoryItemCount: Signal; 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. + */ + readonly supportsInfiniteScroll?: boolean; + readonly hasMore?: Signal; + /** True while an asynchronous append is in flight (tail spinner). */ + readonly isAppending?: Signal; + /** True when the latest append failed; the tail shows a retry action. */ + 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 + * matching list window first) when the same list is shown again. Returns + * null when the saved position no longer matches the current selection. + */ + 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 @@ -57,8 +86,8 @@ export interface PortalCatalogFacade< initialize(categoryId?: string | null): void; setSearchQuery?(query: string): void; clearSelectedItem(): void; - setPage(page: number): void; - setLimit(limit: number): 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/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 fc596bc1a..26e0e2431 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 @@ -75,8 +75,6 @@ import { class="all-items-grid app-scrollbar" [isLoading]="loading()" [items]="pagedGridItems()" - [limit]="pageSize()" - [showPaginator]="false" [searchTerm]="searchTerm()" [variant]="'logo'" [type]="'live'" 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 0bdf141a6..d8f29392a 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 @@ -6,6 +6,7 @@ [showSearchInput]="!isWorkspaceLayout" [showBackButton]="isWorkspaceLayout" [showDetails]="showingDetails" + [nearEndHasMore]="false" (searchTermChange)="updateSearchTerm($event)" (backClick)="goBack()" > 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 index fc15c84f7..16e07a691 100644 --- 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 @@ -1,6 +1,19 @@ import { TestBed } from '@angular/core/testing'; import { signalStore, withState } from '@ngrx/signals'; -import { withSelection } from './with-selection.feature'; +import { + CATALOG_INITIAL_WINDOW, + CATALOG_WINDOW_CHUNK, + withSelection, +} from './with-selection.feature'; + +/** A category larger than two render windows, for window-growth tests. */ +const BULK_CATEGORY_SIZE = CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK + 20; +const bulkVodStreams = Array.from({ length: BULK_CATEGORY_SIZE }, (_, i) => ({ + xtream_id: 1000 + i, + category_id: '90', + title: `Bulk ${i + 1}`, + added: String(1000 - i), +})); const TestSelectionStore = signalStore( withState({ @@ -53,6 +66,12 @@ const TestSelectionStore = signalStore( category_name: 'Documentaries', type: 'vod', }, + { + id: 90, + category_id: '90', + category_name: 'Bulk', + type: 'vod', + }, ], vodStreams: [ { @@ -91,6 +110,7 @@ const TestSelectionStore = signalStore( title: 'Cosmos', added: '6', }, + ...bulkVodStreams, ], serialCategories: [ { @@ -140,8 +160,6 @@ describe('withSelection', () => { let store: InstanceType; beforeEach(() => { - localStorage.clear(); - TestBed.configureTestingModule({ providers: [TestSelectionStore], }); @@ -149,37 +167,106 @@ describe('withSelection', () => { store = TestBed.inject(TestSelectionStore); }); - afterEach(() => { - localStorage.clear(); + it('reveals the first window and grows it with loadMoreContent', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(90); + + expect(store.getPaginatedContent().length).toBe( + CATALOG_INITIAL_WINDOW + ); + expect(store.hasMoreContent()).toBe(true); + + store.loadMoreContent(); + expect(store.getPaginatedContent().length).toBe( + CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK + ); + + store.loadMoreContent(); + expect(store.getPaginatedContent().length).toBe(BULK_CATEGORY_SIZE); + expect(store.hasMoreContent()).toBe(false); + + // No-op once the window covers everything. + const coveredCount = store.visibleCount(); + store.loadMoreContent(); + expect(store.visibleCount()).toBe(coveredCount); }); - it('keeps the current page when the category search term is unchanged', () => { + it('keeps the render window when the category search term is unchanged', () => { store.setSelectedContentType('vod'); - store.setSelectedCategory(10); - store.setLimit(2); - store.setPage(1); + store.setSelectedCategory(90); + store.loadMoreContent(); store.setCategorySearchTerm(''); - expect(store.page()).toBe(1); - expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ - 'Third', - 'Fourth', - ]); + expect(store.visibleCount()).toBe( + CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK + ); }); - it('resets the current page when the category search term changes', () => { + it('resets the render window when the category search term changes', () => { store.setSelectedContentType('vod'); + store.setSelectedCategory(90); + store.loadMoreContent(); + + store.setCategorySearchTerm('bulk'); + + expect(store.visibleCount()).toBe(CATALOG_INITIAL_WINDOW); + expect(store.getPaginatedContent().length).toBe( + CATALOG_INITIAL_WINDOW + ); + }); + + it('resets the render window when the category changes', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(90); + store.loadMoreContent(); + store.setSelectedCategory(10); - store.setLimit(2); - store.setPage(1); - store.setCategorySearchTerm('first'); + expect(store.visibleCount()).toBe(CATALOG_INITIAL_WINDOW); + }); - expect(store.page()).toBe(0); - expect(store.getPaginatedContent().map((item) => item.title)).toEqual([ - 'First', - ]); + it('keeps per-selection snapshots so a tab detour cannot overwrite them', () => { + // Regression: VOD (scrolled) → Series → back to VOD. The series view + // saves its own spot on destroy; with a single slot that save used to + // replace the VOD snapshot and the round trip lost the position. + store.setSelectedContentType('vod'); + store.setSelectedCategory(90); + store.loadMoreContent(); + store.saveCatalogScrollState(1234); + + store.setSelectedContentType('series'); + store.saveCatalogScrollState(0); + + store.setSelectedContentType('vod'); + store.setSelectedCategory(90); + expect(store.consumeCatalogScrollState()).toBe(1234); + expect(store.visibleCount()).toBe( + CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK + ); + + // The series snapshot survived too, under its own identity. + store.setSelectedContentType('series'); + expect(store.consumeCatalogScrollState()).toBe(0); + }); + + it('restores a saved scroll state only for the matching selection', () => { + store.setSelectedContentType('vod'); + store.setSelectedCategory(90); + store.loadMoreContent(); + store.saveCatalogScrollState(1234); + + // A detour to another category must not consume the slot. + store.setSelectedCategory(10); + expect(store.consumeCatalogScrollState()).toBeNull(); + + // Returning to the saved selection restores window + offset once. + store.setSelectedCategory(90); + expect(store.consumeCatalogScrollState()).toBe(1234); + expect(store.visibleCount()).toBe( + CATALOG_INITIAL_WINDOW + CATALOG_WINDOW_CHUNK + ); + expect(store.consumeCatalogScrollState()).toBeNull(); }); it('filters all VOD items when no category is selected', () => { 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 7f4804b94..531067e9b 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 @@ -26,14 +26,49 @@ export type XtreamCategorySortMode = | 'rating-asc'; /** - * Selection state for managing UI selection and pagination + * First render window of the infinite-scroll catalog grid. Growing the window + * is a free in-memory slice, so the initial batch is a generous constant — the + * shared scroll directive tops it up by measuring container overflow rather + * than by computing card counts from the viewport. + */ +export const CATALOG_INITIAL_WINDOW = 50; + +/** How many more items each `loadMoreContent()` reveals. */ +export const CATALOG_WINDOW_CHUNK = 50; + +/** + * Grid scroll position captured when a list view goes away (detail opened, + * tab switched), so returning to the same list restores both the render + * window and the scroll offset. The selection coordinates identify the + * snapshot: only an exact match may restore it. + */ +export interface CatalogScrollState { + contentType: ContentType; + categoryId: number | null; + searchTerm: string; + sortMode: XtreamCategorySortMode; + minRating: number | null; + visibleCount: number; + scrollTop: number; +} + +/** + * Snapshots are kept per selection identity (one slot would let a tab detour + * — VOD → Series → VOD — overwrite the first tab's spot with the second's on + * destroy). Bounded so a long browsing session cannot accumulate one entry + * per category visited. + */ +const MAX_SAVED_CATALOG_SCROLLS = 8; + +/** + * Selection state for managing UI selection and the infinite-scroll window */ export interface SelectionState { selectedContentType: ContentType; selectedCategoryId: number | null; selectedItem: XtreamSelectionItem | null; - page: number; - limit: number; + visibleCount: number; + savedCatalogScrolls: CatalogScrollState[]; contentSortMode: XtreamCategorySortMode; categorySearchTerm: string; minRating: number | null; @@ -48,8 +83,8 @@ const initialSelectionState: SelectionState = { selectedContentType: 'vod', selectedCategoryId: null, selectedItem: null, - page: 0, - limit: Number(localStorage.getItem('xtream-page-size') ?? 25), + visibleCount: CATALOG_INITIAL_WINDOW, + savedCatalogScrolls: [], contentSortMode: 'date-desc', categorySearchTerm: '', minRating: null, @@ -57,6 +92,16 @@ const initialSelectionState: SelectionState = { detailsError: null, }; +const matchesCurrentSelection = ( + snapshot: CatalogScrollState, + current: Omit +): boolean => + snapshot.contentType === current.contentType && + snapshot.categoryId === current.categoryId && + snapshot.searchTerm === current.searchTerm && + snapshot.sortMode === current.sortMode && + snapshot.minRating === current.minRating; + interface XtreamSelectionCategory { readonly [key: string]: unknown; readonly category_name?: string; @@ -174,12 +219,12 @@ export const filterByMinRating = ( }; /** - * Selection feature store for managing UI selection and pagination. - * Handles: + * Selection feature store for managing UI selection and the infinite-scroll + * render window. Handles: * - Content type selection (live, vod, series) * - Category selection * - Item selection - * - Pagination (page, limit) + * - Infinite-scroll window (visibleCount) + detail-round-trip scroll restore */ export function withSelection() { return signalStoreFeature( @@ -431,17 +476,13 @@ export function withSelection() { }), /** - * Get paginated content for the selected category. - * Slices from the stable `filteredAndSortedContent` intermediate so - * page navigation never triggers a full re-sort of the array. + * The visible slice of the selected category — the first + * `visibleCount` items of the stable `filteredAndSortedContent` + * intermediate, so growing the window never re-sorts the array. */ - getPaginatedContent: computed(() => { - const start = store.page() * store.limit(); - return filteredAndSortedContent().slice( - start, - start + store.limit() - ); - }), + getPaginatedContent: computed(() => + filteredAndSortedContent().slice(0, store.visibleCount()) + ), /** * Get all items from the selected category (without pagination). @@ -453,11 +494,12 @@ export function withSelection() { ), /** - * Get total pages for the selected category. - * Derives length from the shared `filteredAndSortedContent` intermediate. + * Whether the filtered list extends beyond the current render + * window. */ - getTotalPages: computed(() => - Math.ceil(filteredAndSortedContent().length / store.limit()) + hasMoreContent: computed( + () => + filteredAndSortedContent().length > store.visibleCount() ), /** @@ -518,7 +560,7 @@ export function withSelection() { patchState(store, { selectedContentType: type, selectedCategoryId: null, - page: 0, + visibleCount: CATALOG_INITIAL_WINDOW, categorySearchTerm: '', minRating: null, }); @@ -526,18 +568,18 @@ export function withSelection() { /** * Set the selected category - * Only resets page to 0 when category actually changes + * Only resets the render window when the category actually changes */ setSelectedCategory(categoryId: number | null): void { const newCategoryId = categoryId !== null ? Number(categoryId) : null; const currentCategoryId = store.selectedCategoryId(); - // Only reset page if category actually changed + // Only reset the window if the category actually changed if (currentCategoryId !== newCategoryId) { patchState(store, { selectedCategoryId: newCategoryId, - page: 0, + visibleCount: CATALOG_INITIAL_WINDOW, categorySearchTerm: '', // Clear the rating filter on category change too, mirroring // categorySearchTerm and setSelectedContentType — otherwise a @@ -569,18 +611,82 @@ export function withSelection() { }, /** - * Set the current page + * Reveal the next chunk of the filtered list. No-op once the + * window already covers everything. */ - setPage(page: number): void { - patchState(store, { page }); + loadMoreContent(): void { + const total = store.selectItemsFromSelectedCategory().length; + if (store.visibleCount() >= total) { + return; + } + + patchState(store, { + visibleCount: store.visibleCount() + CATALOG_WINDOW_CHUNK, + }); }, /** - * Set the page limit (items per page) + * Capture the grid scroll offset together with the selection + * coordinates it belongs to. One snapshot per selection identity: + * re-saving the same list replaces its entry, saving another list + * (a tab detour's destroy hook) leaves it intact, and the oldest + * entry falls out past the bound. */ - setLimit(limit: number): void { - patchState(store, { limit }); - localStorage.setItem('xtream-page-size', String(limit)); + saveCatalogScrollState(scrollTop: number): void { + const snapshot: CatalogScrollState = { + contentType: store.selectedContentType(), + categoryId: store.selectedCategoryId(), + searchTerm: store.categorySearchTerm(), + sortMode: store.contentSortMode(), + minRating: store.minRating(), + visibleCount: store.visibleCount(), + scrollTop, + }; + + patchState(store, { + savedCatalogScrolls: [ + ...store + .savedCatalogScrolls() + .filter( + (saved) => + !matchesCurrentSelection(saved, snapshot) + ), + snapshot, + ].slice(-MAX_SAVED_CATALOG_SCROLLS), + }); + }, + + /** + * If a snapshot exists for the current selection, restore its + * render window, remove it, and return the scroll offset to + * re-apply. Returns null (leaving other snapshots intact) + * otherwise, so a detour through another list never destroys a + * saved spot. + */ + consumeCatalogScrollState(): number | null { + const current = { + contentType: store.selectedContentType(), + categoryId: store.selectedCategoryId(), + searchTerm: store.categorySearchTerm(), + sortMode: store.contentSortMode(), + minRating: store.minRating(), + }; + const saved = store + .savedCatalogScrolls() + .find((snapshot) => + matchesCurrentSelection(snapshot, current) + ); + if (!saved) { + return null; + } + + patchState(store, { + visibleCount: saved.visibleCount, + savedCatalogScrolls: store + .savedCatalogScrolls() + .filter((snapshot) => snapshot !== saved), + }); + return saved.scrollTop; }, /** @@ -592,7 +698,7 @@ export function withSelection() { } patchState(store, { contentSortMode: mode, - page: 0, + visibleCount: CATALOG_INITIAL_WINDOW, }); }, @@ -607,7 +713,7 @@ export function withSelection() { } patchState(store, { minRating: normalized, - page: 0, + visibleCount: CATALOG_INITIAL_WINDOW, }); }, @@ -621,7 +727,7 @@ export function withSelection() { patchState(store, { categorySearchTerm: term, - page: 0, + visibleCount: CATALOG_INITIAL_WINDOW, }); }, @@ -629,12 +735,7 @@ export function withSelection() { * Reset selection state */ resetSelection(): void { - patchState(store, { - ...initialSelectionState, - limit: Number( - localStorage.getItem('xtream-page-size') ?? 25 - ), - }); + patchState(store, initialSelectionState); }, })) ); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 61b006185..cb0446e31 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -137,26 +137,16 @@ {{ liveRootSubtitle() }}
- @if (showLiveRootPaginator()) { - - } ([]); readonly isLoading = input(false); - readonly showPaginator = input(true); + readonly isAppending = input(false); + readonly appendError = input(false); readonly searchTerm = input(''); - readonly pageIndex = input(0); - readonly totalPages = input(0); - readonly limit = input(25); - readonly pageSizeOptions = input([]); readonly variant = input<'poster' | 'logo'>('poster'); readonly type = input(); readonly itemClicked = output(); - readonly pageChange = output(); + readonly retryLoadMore = output(); } @Component({ @@ -163,15 +159,13 @@ describe('LiveStreamLayoutComponent', () => { const currentPlaylist = signal(playlist); const liveStreams = signal([]); const paginatedContent = signal([]); - const totalPages = signal(0); - const page = signal(0); - const limit = signal(25); + const hasMoreContent = signal(false); const xtreamStore = { getCategoriesBySelectedType: categories, getCategoryItemCounts: categoryItemCounts, getPaginatedContent: paginatedContent, - getTotalPages: totalPages, + hasMoreContent, epgItems, currentEpgItem, isLoadingEpg, @@ -181,15 +175,12 @@ describe('LiveStreamLayoutComponent', () => { selectedItem, currentPlaylist, liveStreams, - page, - limit, selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]), constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), openPlayer: jest.fn(), setSelectedItem: jest.fn(), setSelectedCategory: jest.fn(), - setPage: jest.fn((nextPage: number) => page.set(nextPage)), - setLimit: jest.fn((nextLimit: number) => limit.set(nextLimit)), + loadMoreContent: jest.fn(), }; let routerEvents: Subject; @@ -237,16 +228,13 @@ describe('LiveStreamLayoutComponent', () => { xtreamStore.openPlayer.mockClear(); xtreamStore.setSelectedItem.mockClear(); xtreamStore.setSelectedCategory.mockClear(); - xtreamStore.setPage.mockClear(); - xtreamStore.setLimit.mockClear(); + xtreamStore.loadMoreContent.mockClear(); xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ sampleChannel, ]); liveStreams.set([]); paginatedContent.set([]); - totalPages.set(0); - page.set(0); - limit.set(25); + hasMoreContent.set(false); favoritesService.getFavorites.mockClear(); xtreamUrlService.resolveCatchupUrl.mockClear(); portalPlayer.isEmbeddedPlayer.mockReset(); @@ -497,7 +485,7 @@ describe('LiveStreamLayoutComponent', () => { ).toBeNull(); }); - it('shows live all-items content with the shared grid and header paginator before a category is selected', () => { + it('shows live all-items content with the shared grid and no paginator before a category is selected', () => { const firstChannel = { xtream_id: 301, name: 'First Channel', @@ -524,9 +512,7 @@ describe('LiveStreamLayoutComponent', () => { }; selectedCategoryId.set(null); selectedTypeContentLoading.set(false); - limit.set(25); - page.set(0); - totalPages.set(2); + hasMoreContent.set(true); paginatedContent.set([firstChannel, secondChannel]); xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ firstChannel, @@ -546,13 +532,6 @@ describe('LiveStreamLayoutComponent', () => { ]); expect(grid.componentInstance.variant()).toBe('logo'); expect(grid.componentInstance.type()).toBe('live'); - expect(grid.componentInstance.showPaginator()).toBe(false); - expect(grid.componentInstance.pageIndex()).toBe(0); - expect(grid.componentInstance.totalPages()).toBe(2); - expect(grid.componentInstance.limit()).toBe(25); - expect(grid.componentInstance.pageSizeOptions()).toEqual([ - 10, 25, 50, 100, - ]); expect( fixture.nativeElement.querySelector('.category-title').textContent ).toContain('All Items'); @@ -562,7 +541,7 @@ describe('LiveStreamLayoutComponent', () => { ).toContain('3 channels'); expect( fixture.nativeElement.querySelector('mat-paginator') - ).not.toBeNull(); + ).toBeNull(); expect( fixture.debugElement.query( By.directive(StubPortalChannelsListComponent) @@ -654,9 +633,10 @@ describe('LiveStreamLayoutComponent', () => { expect(forwardedPlayer).toBe('mpv'); }); - it('updates live root pagination from the header paginator', () => { + it('delegates live root loadMore to the store window', () => { selectedCategoryId.set(null); selectedTypeContentLoading.set(false); + hasMoreContent.set(true); xtreamStore.selectItemsFromSelectedCategory.mockReturnValue( Array.from({ length: 80 }, (_, index) => ({ xtream_id: index + 1, @@ -667,33 +647,9 @@ describe('LiveStreamLayoutComponent', () => { fixture.detectChanges(); - const paginator = fixture.debugElement.query(By.css('mat-paginator')); - expect(paginator).not.toBeNull(); - paginator.triggerEventHandler('page', { - pageIndex: 1, - pageSize: 50, - length: 80, - previousPageIndex: 0, - } satisfies PageEvent); + component.onLiveRootLoadMore(); - expect(xtreamStore.setPage).toHaveBeenCalledWith(1); - expect(xtreamStore.setLimit).toHaveBeenCalledWith(50); - expect(router.navigate).toHaveBeenCalledWith([], { - relativeTo: expect.any(Object), - queryParams: { page: 2 }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); - }); - - it('applies the live root page query parameter', () => { - selectedCategoryId.set(null); - selectedTypeContentLoading.set(false); - - routeQueryParamMap.next(convertToParamMap({ page: '3' })); - fixture.detectChanges(); - - expect(xtreamStore.setPage).toHaveBeenCalledWith(2); + expect(xtreamStore.loadMoreContent).toHaveBeenCalledTimes(1); }); it('shows the cross-category live channel list while searching from the live root', () => { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index bbb34c0c4..4833d8835 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -17,12 +17,12 @@ import { filter } from 'rxjs'; 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 { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { ResizableDirective } from '@iptvnator/ui/components'; import { GridListComponent, + InfiniteScrollDirective, PortalEmptyStateComponent, } from '@iptvnator/portal/shared/ui'; import { @@ -98,11 +98,11 @@ interface XtreamLiveChannelItem { MatButtonModule, MatIcon, MatMenuModule, - MatPaginatorModule, MatProgressSpinnerModule, MatTooltipModule, NgTemplateOutlet, GridListComponent, + InfiniteScrollDirective, PortalChannelsListComponent, PortalEmptyStateComponent, ResizableDirective, @@ -144,11 +144,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { 'q', (value) => (value ?? '').trim() ); - private readonly routePageIndex = queryParamSignal( - this.route, - 'page', - (value) => this.toPageIndex(value) - ); readonly workspaceSearchTerm = computed(() => this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); @@ -230,7 +225,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly liveChannelSortLabel = computed(() => getPortalChannelSortModeLabel(this.liveChannelSortMode()) ); - readonly liveRootPageSizeOptions = [10, 25, 50, 100]; readonly liveRootItems = computed( () => this.xtreamStore.getPaginatedContent() as unknown as Record< @@ -245,12 +239,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { const count = this.liveRootItemCount(); return `${count} ${count === 1 ? 'channel' : 'channels'}`; }); - readonly liveRootPageIndex = this.xtreamStore.page; - readonly liveRootLimit = this.xtreamStore.limit; - readonly liveRootTotalPages = this.xtreamStore.getTotalPages; - readonly showLiveRootPaginator = computed( - () => this.liveRootItemCount() > 0 - ); + readonly liveRootHasMore = this.xtreamStore.hasMoreContent; readonly selectedCategoryInfo = computed(() => { const categoryId = this.selectedCategoryId(); @@ -298,20 +287,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { onCleanup(() => clearInterval(intervalId)); }); - effect(() => { - if ( - this.xtreamStore.selectedContentType() !== 'live' || - this.showLiveChannelSidebar() - ) { - return; - } - - const pageIndex = this.routePageIndex(); - if (this.liveRootPageIndex() !== pageIndex) { - this.xtreamStore.setPage(pageIndex); - } - }); - // Read pending auto-open state on every NavigationEnd — covers both the // initial navigation (Angular fires NavigationEnd after component creation) // and re-navigation to the same /live route when the component is reused. @@ -488,19 +463,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); } - onLiveRootPageChange(event: PageEvent): void { - this.xtreamStore.setPage(event.pageIndex); - this.xtreamStore.setLimit(event.pageSize); - this.scrollLiveRootGridToTop(); - - void this.router.navigate([], { - relativeTo: this.route, - queryParams: { - page: event.pageIndex > 0 ? event.pageIndex + 1 : null, - }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + onLiveRootLoadMore(): void { + this.xtreamStore.loadMoreContent(); } onLiveRootItemClick(item: unknown): void { @@ -611,18 +575,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return this.xtreamStore.liveStreams() as unknown as XtreamLiveChannelItem[]; } - private toPageIndex(value: string | null): number { - const page = Number(value); - return Number.isInteger(page) && page > 0 ? page - 1 : 0; - } - - private scrollLiveRootGridToTop(): void { - const gridList = this.hostElement.nativeElement.querySelector( - 'app-grid-list' - ) as HTMLElement | null; - gridList?.scrollTo?.({ top: 0 }); - } - private getVisibleChannels(): XtreamLiveChannelItem[] { return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[]; } diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html index e4c93bbb9..9e33832f6 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html @@ -8,10 +8,14 @@ [showSearchInput]="showInlineSearchInput" [minSearchLength]="minSearchLength" [initialDescriptionKey]="initialDescriptionKey" + [nearEndHasMore]="hasMoreSearchResults()" + [nearEndAppending]="isAppendingSearchResults()" + [nearEndRenderedCount]="renderedSearchResultsCount()" + [nearEndResetKey]="searchResetIdentity()" (searchTermChange)="updateSearchTerm($event)" (closeClick)="onCloseDialog()" (backClick)="goBack()" - (nearEnd)="loadMoreGlobalResults()" + (nearEnd)="onResultsNearEnd()" > @@ -155,9 +159,9 @@ } } @else { - +
- @for (item of xtreamStore.searchResults(); track item.id) { + @for (item of visibleInPortalResults(); track item.id) { { const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; store.searchResults.set([createSearchItem()]); component.hasMoreGlobalResults.set(true); + // Appends only continue the last executed search. + ( + component as unknown as { lastGlobalSearchTerm: string } + ).lastGlobalSearchTerm = 'matrix'; const appendPromise = component.searchGlobal( 'matrix', @@ -418,6 +422,10 @@ describe('SearchResultsComponent initialQuery contract', () => { } ).globalSearchResults = signal([]); component.hasMoreGlobalResults.set(true); + // Appends only continue the last executed search. + ( + component as unknown as { lastGlobalSearchTerm: string } + ).lastGlobalSearchTerm = 'matrix'; await component.searchGlobal('matrix', ['movie'], false, true); @@ -433,4 +441,142 @@ describe('SearchResultsComponent initialQuery contract', () => { ); expect(store.searchResults()).toHaveLength(2); }); + + it('refuses to append pages while an edited query is still debouncing', async () => { + // Regression: the auto-fill can request more results right after the + // reset identity changes, i.e. inside the 300ms search debounce. An + // append with the edited term would fetch a page of the NEW query at + // the OLD offset and interleave it into the old query's results. + const firstPage = Array.from({ length: 101 }, (_, index) => + createSearchItem({ + id: index + 1, + title: `Result ${index + 1}`, + xtream_id: index + 1, + }) + ); + const databaseService = TestBed.inject(DatabaseService) as { + globalSearchContent: jest.Mock; + }; + databaseService.globalSearchContent.mockResolvedValueOnce(firstPage); + + const component = TestBed.runInInjectionContext( + () => + new SearchResultsComponent( + { + isGlobalSearch: true, + }, + undefined + ) + ); + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + + await component.searchGlobal('matrix', ['movie'], false); + expect(component.hasMoreGlobalResults()).toBe(true); + databaseService.globalSearchContent.mockClear(); + + // The user edits the query; the fresh offset-zero search has not run + // yet (debounce pending), so appending must be refused entirely. + store.searchTerm.set('matrix reloaded'); + await component.loadMoreGlobalResults(); + + expect(databaseService.globalSearchContent).not.toHaveBeenCalled(); + expect(component.isLoadingMoreGlobalResults()).toBe(false); + }); +}); + +describe('SearchResultsComponent in-portal result window', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: XtreamStore, + useClass: MockXtreamStore, + }, + { + provide: Router, + useValue: { navigate: jest.fn() }, + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: { layout: 'workspace' }, + queryParamMap: convertToParamMap({ q: '' }), + }, + queryParamMap: of(convertToParamMap({ q: '' })), + }, + }, + { + provide: DatabaseService, + useValue: { + globalSearchContent: jest.fn().mockResolvedValue([]), + }, + }, + ], + }); + }); + + it('windows the full result set, reveals further chunks, and resets on a new result set', () => { + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + const component = TestBed.runInInjectionContext( + () => new SearchResultsComponent(null, undefined) + ); + const items = Array.from({ length: 130 }, (_, index) => + createSearchItem({ + id: index + 1, + xtream_id: index + 1, + title: `Item ${index + 1}`, + }) + ); + store.searchResults.set(items); + + expect(component.isGlobalSearch).toBe(false); + expect(component.visibleInPortalResults()).toHaveLength(60); + expect(component.renderedSearchResultsCount()).toBe(60); + expect(component.hasMoreSearchResults()).toBe(true); + + component.onResultsNearEnd(); + expect(component.visibleInPortalResults()).toHaveLength(120); + // The layout re-measures on the RENDERED count — it must follow the + // window, not the constant 130-item total. + expect(component.renderedSearchResultsCount()).toBe(120); + + component.onResultsNearEnd(); + expect(component.visibleInPortalResults()).toHaveLength(130); + expect(component.hasMoreSearchResults()).toBe(false); + + // Growing past the total is a no-op. + component.onResultsNearEnd(); + expect(component.visibleInPortalResults()).toHaveLength(130); + + // A replaced result set resets the window to the first chunk. + store.searchResults.set(items.slice(0, 90)); + expect(component.visibleInPortalResults()).toHaveLength(60); + expect(component.hasMoreSearchResults()).toBe(true); + }); + + it('changes the reset identity on filter-only transitions', () => { + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + const component = TestBed.runInInjectionContext( + () => new SearchResultsComponent(null, undefined) + ); + + const initialIdentity = component.searchResetIdentity(); + + // Same term, different type filters — the result set is replaced, so + // the identity feeding the layout's near-end latch must change. + store.searchFilters.set({ live: false, movie: true, series: true }); + expect(component.searchResetIdentity()).not.toBe(initialIdentity); + + const filteredIdentity = component.searchResetIdentity(); + try { + component.toggleExcludeHidden(true); + expect(component.searchResetIdentity()).not.toBe( + filteredIdentity + ); + } finally { + // toggleExcludeHidden persists; do not leak into other tests. + localStorage.removeItem('xtream-search-exclude-hidden'); + } + }); }); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index dff327772..117a8252d 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -6,6 +6,7 @@ import { effect, inject, Inject, + linkedSignal, Optional, signal, viewChild, @@ -56,6 +57,13 @@ interface GlobalSearchResultGroup { const GLOBAL_SEARCH_PAGE_SIZE = 100; +/** + * Render window for the in-portal (single playlist) search, which receives its + * complete result set in one response. The window grows via the layout's + * `nearEnd` scroll hook, mirroring the global search's paged appends. + */ +const IN_PORTAL_RESULTS_CHUNK = 60; + function groupResultsByPlaylistId( items: XtreamSearchResultItem[] ): GlobalSearchResultGroup[] { @@ -170,6 +178,73 @@ export class SearchResultsComponent implements AfterViewInit { return groupResultsByPlaylistId(results); }); + /** Resets to the first chunk whenever a new result set replaces the old. */ + private readonly inPortalVisibleCount = linkedSignal({ + source: () => this.xtreamStore.searchResults(), + computation: () => IN_PORTAL_RESULTS_CHUNK, + }); + + /** Windowed slice of the in-portal search results. */ + readonly visibleInPortalResults = computed(() => + this.xtreamStore.searchResults().slice(0, this.inPortalVisibleCount()) + ); + + /** + * Feeds the search layout's infinite scroll so its auto-fill can reveal + * further chunks even when the rendered ones do not overflow the viewport. + */ + readonly hasMoreSearchResults = computed(() => + this.isGlobalSearch + ? this.hasMoreGlobalResults() + : this.xtreamStore.searchResults().length > + this.inPortalVisibleCount() + ); + + readonly isAppendingSearchResults = computed( + () => this.isGlobalSearch && this.isLoadingMoreGlobalResults() + ); + + /** + * RENDERED result count for the layout's overflow re-measure: the + * windowed slice for in-portal mode (grows chunk by chunk), the full + * loaded set for global mode (server pages append into it). + */ + readonly renderedSearchResultsCount = computed(() => + this.isGlobalSearch + ? this.xtreamStore.searchResults().length + : this.visibleInPortalResults().length + ); + + /** + * Result-set identity for the layout's near-end latch and auto-fill + * budget. Type filters and the hidden-categories toggle replace the + * result set without changing the term, so they are part of the identity. + */ + readonly searchResetIdentity = computed(() => { + const filters = this.filters(); + return [ + this.searchTerm(), + String(filters.live), + String(filters.movie), + String(filters.series), + String(this.excludeHidden()), + ].join('|'); + }); + + onResultsNearEnd(): void { + if (this.isGlobalSearch) { + void this.loadMoreGlobalResults(); + return; + } + + const total = this.xtreamStore.searchResults().length; + if (this.inPortalVisibleCount() < total) { + this.inPortalVisibleCount.update( + (count) => count + IN_PORTAL_RESULTS_CHUNK + ); + } + } + constructor( @Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null, @Optional() public dialogRef: MatDialogRef @@ -322,7 +397,12 @@ export class SearchResultsComponent implements AfterViewInit { append && (!this.hasMoreGlobalResults() || this.isLoadingMoreGlobalResults() || - this.xtreamStore.isSearching()) + this.xtreamStore.isSearching() || + // An append continues the LAST EXECUTED search. While an + // edited query is still debouncing, the visible results + // belong to the old term — fetching a new-term page at the + // old offset would interleave two different queries. + trimmedTerm !== this.lastGlobalSearchTerm) ) { return; } 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 e7045538c..e310065af 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 @@ -29,8 +29,6 @@ const PLAYLIST_TWO: XtreamPlaylistData = { describe('XtreamCatalogFacadeService', () => { let service: XtreamCatalogFacadeService; const contentType = signal<'live' | 'vod' | 'series'>('vod'); - const limit = signal(25); - const page = signal(0); const selectedCategory = signal | null>({ id: 11, name: 'Movies', @@ -44,7 +42,7 @@ describe('XtreamCatalogFacadeService', () => { { xtream_id: 2, title: 'B' }, ]); const selectedItem = signal | null>(null); - const totalPages = signal(1); + const hasMoreContent = signal(false); const isPaginatedContentLoading = signal(false); const contentSortMode = signal('date-desc'); const minRating = signal(null); @@ -52,14 +50,12 @@ describe('XtreamCatalogFacadeService', () => { const xtreamStore = { selectedContentType: contentType, - limit, - page, getSelectedCategory: selectedCategory, selectedCategoryId, getPaginatedContent: paginatedContent, selectItemsFromSelectedCategory: selectedCategoryItems, selectedItem, - getTotalPages: totalPages, + hasMoreContent, isPaginatedContentLoading, contentSortMode, minRating, @@ -72,12 +68,9 @@ describe('XtreamCatalogFacadeService', () => { setSelectedCategory: jest.fn((categoryId: number | null) => { selectedCategoryId.set(categoryId); }), - setPage: jest.fn((nextPage: number) => { - page.set(nextPage); - }), - setLimit: jest.fn((nextLimit: number) => { - limit.set(nextLimit); - }), + loadMoreContent: jest.fn(), + saveCatalogScrollState: jest.fn(), + consumeCatalogScrollState: jest.fn().mockReturnValue(null), setContentSortMode: jest.fn((mode: PortalCatalogSortMode) => { contentSortMode.set(mode); }), @@ -92,8 +85,6 @@ describe('XtreamCatalogFacadeService', () => { beforeEach(() => { localStorage.removeItem('xtream-category-sort-mode'); contentType.set('vod'); - limit.set(25); - page.set(0); selectedCategory.set({ id: 11, name: 'Movies' }); selectedCategoryId.set(11); paginatedContent.set([{ xtream_id: 1, title: 'A' }]); @@ -102,7 +93,7 @@ describe('XtreamCatalogFacadeService', () => { { xtream_id: 2, title: 'B' }, ]); selectedItem.set(null); - totalPages.set(1); + hasMoreContent.set(false); isPaginatedContentLoading.set(false); contentSortMode.set('date-desc'); minRating.set(null); @@ -112,8 +103,9 @@ describe('XtreamCatalogFacadeService', () => { xtreamStore.setCategorySearchTerm.mockClear(); xtreamStore.setSelectedItem.mockClear(); xtreamStore.setSelectedCategory.mockClear(); - xtreamStore.setPage.mockClear(); - xtreamStore.setLimit.mockClear(); + xtreamStore.loadMoreContent.mockClear(); + xtreamStore.saveCatalogScrollState.mockClear(); + xtreamStore.consumeCatalogScrollState.mockClear(); xtreamStore.setContentSortMode.mockClear(); xtreamStore.setMinRating.mockClear(); xtreamStore.hasSeriesProgress.mockClear(); @@ -141,11 +133,12 @@ describe('XtreamCatalogFacadeService', () => { ); }); - it('exposes store-driven paginated content, total pages, and category counts', () => { + it('exposes store-driven windowed content, hasMore, and category counts', () => { + expect(service.supportsInfiniteScroll).toBe(true); expect(service.paginatedContent()).toEqual([ { xtream_id: 1, title: 'A' }, ]); - expect(service.totalPages()).toBe(1); + expect(service.hasMore()).toBe(false); expect(service.categoryItemCount()).toBe(2); paginatedContent.set([ @@ -157,16 +150,32 @@ describe('XtreamCatalogFacadeService', () => { { xtream_id: 4, title: 'D' }, { xtream_id: 5, title: 'E' }, ]); - totalPages.set(4); + hasMoreContent.set(true); expect(service.paginatedContent()).toEqual([ { xtream_id: 3, title: 'C' }, { xtream_id: 4, title: 'D' }, ]); - expect(service.totalPages()).toBe(4); + expect(service.hasMore()).toBe(true); expect(service.categoryItemCount()).toBe(3); }); + it('delegates loadMore and the scroll-position handoff to the store', () => { + service.loadMore(); + expect(xtreamStore.loadMoreContent).toHaveBeenCalledTimes(1); + + service.saveScrollPosition(420); + expect(xtreamStore.saveCatalogScrollState).toHaveBeenCalledWith(420); + + xtreamStore.consumeCatalogScrollState.mockReturnValueOnce(420); + expect(service.consumeSavedScrollPosition()).toBe(420); + expect(service.consumeSavedScrollPosition()).toBeNull(); + + // Synchronous in-memory appends never surface async tail states. + expect(service.isAppending()).toBe(false); + expect(service.appendError()).toBe(false); + }); + it('restores saved sort mode, sets the selected category, and loads positions once per playlist', () => { localStorage.setItem('xtream-category-sort-mode', 'name-asc'); 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 551248497..00d34261d 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,14 +33,15 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< private loadedPositionsPlaylistId: string | null = null; readonly provider = 'xtream' as const; - readonly pageSizeOptions = [10, 25, 50, 100] as const; + readonly supportsInfiniteScroll = true; readonly contentType = this.xtreamStore.selectedContentType; - readonly limit = this.xtreamStore.limit; - readonly pageIndex = this.xtreamStore.page; readonly selectedCategory = this.xtreamStore.getSelectedCategory; readonly paginatedContent = this.xtreamStore.getPaginatedContent; readonly selectedItem = this.xtreamStore.selectedItem; - readonly totalPages = this.xtreamStore.getTotalPages; + readonly hasMore = this.xtreamStore.hasMoreContent; + /** Appends are synchronous in-memory slices — never pending, never failing. */ + readonly isAppending = computed(() => false); + readonly appendError = computed(() => false); readonly isPaginatedContentLoading = this.xtreamStore.isPaginatedContentLoading; readonly selectedCategoryTitle = computed(() => { @@ -105,12 +106,20 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< this.xtreamStore.setCategorySearchTerm(query); } - setPage(page: number): void { - this.xtreamStore.setPage(page); + loadMore(): void { + this.xtreamStore.loadMoreContent(); } - setLimit(limit: number): void { - this.xtreamStore.setLimit(limit); + retryAppend(): void { + // In-memory appends cannot fail; nothing to retry. + } + + saveScrollPosition(scrollTop: number): void { + this.xtreamStore.saveCatalogScrollState(scrollTop); + } + + consumeSavedScrollPosition(): number | null { + return this.xtreamStore.consumeCatalogScrollState(); } setContentSortMode(mode: PortalCatalogSortMode): void {