mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat(xtream): replace catalog pagination with infinite scroll (1/2) (#1392)
* feat(xtream): replace catalog pagination with infinite scroll Xtream movie/series/live catalogs now load continuously while scrolling instead of paging. The selection store keeps a growing visibleCount render window over the in-memory catalog (initial 50, +50 per load) plus a saved scroll state, so opening a title and going back restores the exact spot. A shared InfiniteScrollDirective (portal/shared/ui) fires loadMore near the bottom (edge-triggered, mirroring search-layout) and auto-fills viewports taller than the initial window by measuring container overflow — capped at 10 self-initiated loads per list identity, with a ResizeObserver re-check. The shared CategoryContentViewComponent branches on the transitional PortalCatalogFacade.supportsInfiniteScroll flag: Xtream scrolls, Stalker keeps its server-driven paginator and ?page= round-trip untouched until its append lands (PR 2), after which the paged facade members and the flag are deleted. grid-list loses its dead built-in paginator and gains tail states (append spinner, retry-on-error) plus content-visibility on cards. The in-portal search results reuse the search layout's nearEnd hook to window their full result set instead of rendering it unbounded. Validation: portal-xtream-data-access (234), portal-xtream-feature (357), portal-catalog-feature (22), portal-shared-ui (77, incl. new directive spec), portal-stalker-* (253) unit tests green; catalog-sorting e2e 5/5 (new scroll-growth + spot-restore test against the large 200-item mock scenario, Stalker paged spec unchanged); search e2e 16/16; lint green; release note added and validated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): auto-fill search results and refresh the near-end latch Review findings from #1392: the in-portal search window could stall at its first 60-item chunk when the rendered cards did not overflow the container — nearEnd only fired on real scroll events (Greptile P1), the search layout's edge latch survived a result-set replacement (Codex), and the shared directive's latch went stale after appended content moved the bottom out of the threshold (Codex). The search layout now drives its results container through the shared InfiniteScrollDirective instead of a bespoke scroll handler: the measured auto-fill reveals further chunks on tall viewports without any scroll, the reset key (search term) and item-count changes refresh the latch, and new nearEndHasMore/nearEndAppending inputs let consumers gate emissions. Xtream search wires them for both modes — this also fixes the same latent tall-viewport stall in the global search's 100-item pages — and the Stalker search page (single capped request until PR 2) sets hasMore=false. The directive's fill check now refreshes the latch from the measured state, so an End-key jump straight to the new bottom is a genuine crossing again. New coverage: directive stale-latch regression, search-layout auto-fill + hasMore gating, in-portal window reveal/reset in search-results. Reruns: portal-shared-ui 80, portal-xtream-feature 357, portal-stalker-feature green; search e2e 16/16 (fresh Playwright report verified); lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): re-measure search auto-fill on the rendered window, not the total Round-2 review finding on #1392 (Greptile P1 + Codex P2, same defect): the search layout bound the constant result-set total to the infinite-scroll directive's item count, so once the in-portal window grew 60 -> 120 no tracked input changed, no further overflow check was scheduled, and results beyond 120 stayed unreachable on tall viewports. The layout now takes an explicit nearEndRenderedCount (falling back to resultsCount for consumers that render everything they report) and feeds THAT to the directive. Xtream search passes the windowed slice length for in-portal mode and the loaded-set length for global mode. Regression specs: layout re-measures when the rendered window grows while the total stays constant; the component exposes the rendered count following the window. portal-shared-ui 81, portal-xtream-feature 357, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): include filter state in the search reset identity Round-3 Codex P2 on #1392: the near-end latch and auto-fill budget were keyed on the search term alone, so a filter-only transition (type filters or the hidden-categories toggle) replaced the result set without resetting them — a jump straight back into the threshold could be swallowed. The search layout now accepts an explicit nearEndResetKey (defaulting to the term); Xtream search supplies term + type filters + excludeHidden. Regression specs: layout latch resets on an identity change without a new term; the component identity changes on filter-only and hidden-toggle transitions. portal-shared-ui 82, portal-xtream-feature 358, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): refuse global-search appends while an edited query debounces Round-4 Codex P2 on #1392: after the reset-identity change, the layout's auto-fill can request more results inside the 300ms search debounce. The append then ran with the freshly edited term but the old result count as offset, interleaving a page of the new query into the old query's visible results until the offset-zero search landed. An append now only continues the LAST EXECUTED search: the append guard additionally requires the effective term to equal lastGlobalSearchTerm, so pagination stays suppressed from the first keystroke until the fresh search replaces the result set. Regression spec covers the mid-debounce refusal; the two existing append specs state their precondition explicitly. portal-xtream-feature 359, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): per-selection scroll snapshots and progress-based auto-fill stop Round-5 Codex P2s on #1392: 1. The single saved-scroll slot lost the first tab's position on a VOD -> Series -> VOD round trip — the series view's destroy hook overwrote it with series coordinates. Snapshots are now kept per selection identity (bounded to the 8 most recent), so a detour's save can never destroy another list's spot. Store API is unchanged. 2. The fixed 10-load auto-fill budget could strand items on a viewport large enough that ten chunks still do not overflow — with no scrollbar, no real scroll event can ever fire. The auto-fill now terminates on lack of progress instead: loads continue while they grow scrollHeight (until genuine overflow hands off to scroll events) and stop after three consecutive loads without growth, which only a source that reports more but renders nothing can produce. Regression specs: VOD/Series round trip keeps both snapshots; growth keeps filling past the old cap and stops at overflow; no-growth stalls stop at three; reset key clears the stall guard. portal-shared-ui 83, portal-xtream-data-access 235, catalog-sorting e2e 5/5 re-run, lint clean. CLAUDE.md wording updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d5f84fb130
commit
8442747c37
47 files changed
+1709
-328
No files matched your search
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await expect(
|
||||
page.locator('.category-content-header mat-paginator')
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
function catalogCardCount(page: Page): Promise<number> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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' }
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "كل اللغات",
|
||||
"SAME_AS_ABOVE": "كما في الأعلى",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "جارٍ تحميل المزيد…",
|
||||
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
|
||||
"RETRY": "إعادة المحاولة"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "كل اللغات",
|
||||
"SAME_AS_ABOVE": "بحال اللي الفوق",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} من {{total}}"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "جارٍ تحميل المزيد…",
|
||||
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
|
||||
"RETRY": "عاود المحاولة"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "Усе мовы",
|
||||
"SAME_AS_ABOVE": "як радком вышэй",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} з {{total}}"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "Загружаем яшчэ…",
|
||||
"LOAD_MORE_FAILED": "Не атрымалася загрузіць больш элементаў",
|
||||
"RETRY": "Паўтарыць"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Інфармацыя",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "Όλες οι γλώσσες",
|
||||
"SAME_AS_ABOVE": "ίδιο με το παραπάνω",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} από {{total}}"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "Φόρτωση περισσότερων…",
|
||||
"LOAD_MORE_FAILED": "Δεν ήταν δυνατή η φόρτωση περισσότερων στοιχείων",
|
||||
"RETRY": "Επανάληψη"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Πληροφορίες",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "すべての言語",
|
||||
"SAME_AS_ABOVE": "上と同じ",
|
||||
"SHOWING_OF_TOTAL": "{{total}}件中{{visible}}件"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "さらに読み込み中…",
|
||||
"LOAD_MORE_FAILED": "追加の項目を読み込めませんでした",
|
||||
"RETRY": "再試行"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "情報",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "모든 언어",
|
||||
"SAME_AS_ABOVE": "위와 동일",
|
||||
"SHOWING_OF_TOTAL": "{{total}}개 중 {{visible}}개"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "더 불러오는 중…",
|
||||
"LOAD_MORE_FAILED": "추가 항목을 불러오지 못했습니다",
|
||||
"RETRY": "다시 시도"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "정보",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "Все языки",
|
||||
"SAME_AS_ABOVE": "как строкой выше",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} из {{total}}"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "Загружаем ещё…",
|
||||
"LOAD_MORE_FAILED": "Не удалось загрузить больше элементов",
|
||||
"RETRY": "Повторить"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Инфо",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "所有语言",
|
||||
"SAME_AS_ABOVE": "与上行相同",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "正在加载更多…",
|
||||
"LOAD_MORE_FAILED": "无法加载更多内容",
|
||||
"RETRY": "重试"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "信息",
|
||||
|
||||
@@ -1084,6 +1084,11 @@
|
||||
"ALL_LANGUAGES": "所有語言",
|
||||
"SAME_AS_ABOVE": "與上行相同",
|
||||
"SHOWING_OF_TOTAL": "{{visible}} / {{total}}"
|
||||
},
|
||||
"GRID": {
|
||||
"LOADING_MORE": "正在載入更多…",
|
||||
"LOAD_MORE_FAILED": "無法載入更多內容",
|
||||
"RETRY": "重試"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "資訊",
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-7
@@ -204,7 +204,7 @@
|
||||
}
|
||||
</mat-menu>
|
||||
}
|
||||
@if (categoryItemCount() > 0) {
|
||||
@if (!supportsInfiniteScroll && categoryItemCount() > 0) {
|
||||
<mat-paginator
|
||||
[pageIndex]="pageIndex()"
|
||||
[length]="categoryItemCount()"
|
||||
@@ -216,17 +216,20 @@
|
||||
}
|
||||
</div>
|
||||
<app-grid-list
|
||||
appInfiniteScroll
|
||||
[infiniteHasMore]="infiniteHasMore()"
|
||||
[infiniteAppending]="infiniteAppending()"
|
||||
[infiniteItemCount]="contentWithProgress().length"
|
||||
[infiniteResetKey]="gridResetKey()"
|
||||
(infiniteLoadMore)="onLoadMore()"
|
||||
[isLoading]="isPaginatedContentLoading()"
|
||||
[isAppending]="infiniteAppending()"
|
||||
[appendError]="infiniteAppendError()"
|
||||
(retryLoadMore)="onRetryAppend()"
|
||||
[items]="contentWithProgress()"
|
||||
(itemClicked)="onItemClick($event)"
|
||||
[pageIndex]="pageIndex()"
|
||||
[totalPages]="totalPages()"
|
||||
[limit]="limit()"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[showPaginator]="false"
|
||||
[searchTerm]="searchTerm()"
|
||||
[type]="contentType() ?? ''"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
+153
-6
@@ -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<boolean>();
|
||||
readonly isAppending = input<boolean>();
|
||||
readonly appendError = input<boolean>();
|
||||
readonly items = input<unknown[]>();
|
||||
readonly pageIndex = input<number>();
|
||||
readonly totalPages = input<number>();
|
||||
readonly limit = input<number>();
|
||||
readonly pageSizeOptions = input<number[]>();
|
||||
readonly showPaginator = input(true);
|
||||
readonly searchTerm = input<string>('');
|
||||
readonly type = input<string>('');
|
||||
readonly itemClicked = output<unknown>();
|
||||
readonly pageChange = output<unknown>();
|
||||
readonly retryLoadMore = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -67,8 +65,12 @@ describe('CategoryContentViewComponent', () => {
|
||||
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
|
||||
const minRating = signal<number | null>(null);
|
||||
const selectedItem = signal<Record<string, unknown> | 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<CategoryContentViewComponent> {
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
+141
-14
@@ -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<HTMLElement>);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (showPaginator() && items().length > 0) {
|
||||
<mat-paginator
|
||||
[pageIndex]="pageIndex()"
|
||||
[length]="totalPages() * limit()"
|
||||
[pageSize]="limit()"
|
||||
[pageSizeOptions]="pageSizeOptions()"
|
||||
(page)="pageChange.emit($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
} `,
|
||||
@if (isAppending()) {
|
||||
<div class="grid-list__tail" aria-live="polite">
|
||||
<mat-spinner diameter="28" />
|
||||
<span>{{ 'PORTALS.GRID.LOADING_MORE' | translate }}</span>
|
||||
</div>
|
||||
} @else if (appendError()) {
|
||||
<div class="grid-list__tail grid-list__tail--error" role="alert">
|
||||
<span>{{ 'PORTALS.GRID.LOAD_MORE_FAILED' | translate }}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
(click)="retryLoadMore.emit()"
|
||||
>
|
||||
{{ 'PORTALS.GRID.RETRY' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}`,
|
||||
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<GridListItem[]>([]);
|
||||
readonly isLoading = input<boolean>(false);
|
||||
readonly showPaginator = input(true);
|
||||
/** True while an infinite-scroll append is in flight (tail spinner). */
|
||||
readonly isAppending = input<boolean>(false);
|
||||
/** True when the latest append failed; renders the retry tail. */
|
||||
readonly appendError = input<boolean>(false);
|
||||
readonly searchTerm = input<string>('');
|
||||
readonly itemClicked = output<GridListItem>();
|
||||
readonly pageChange = output<PageEvent>();
|
||||
readonly retryLoadMore = output<void>();
|
||||
|
||||
readonly pageIndex = input<number>(0);
|
||||
readonly totalPages = input<number>(0);
|
||||
readonly limit = input<number>(25);
|
||||
readonly pageSizeOptions = input<number[]>([]);
|
||||
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);
|
||||
|
||||
@@ -61,7 +61,12 @@
|
||||
|
||||
<div
|
||||
class="results-container"
|
||||
(scroll)="onSearchContentScroll($event)"
|
||||
appInfiniteScroll
|
||||
[infiniteHasMore]="nearEndHasMore()"
|
||||
[infiniteAppending]="nearEndAppending()"
|
||||
[infiniteItemCount]="nearEndRenderedCount() ?? resultsCount()"
|
||||
[infiniteResetKey]="nearEndResetKey() ?? searchTerm()"
|
||||
(infiniteLoadMore)="nearEnd.emit()"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
<div class="loading-state">
|
||||
|
||||
+110
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string>('PORTALS.SIDEBAR.SEARCH');
|
||||
@@ -60,6 +60,38 @@ export class SearchLayoutComponent {
|
||||
/** Minimum characters required for search */
|
||||
readonly minSearchLength = input<number>(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<boolean>(true);
|
||||
|
||||
/** True while the consumer is appending; suppresses further triggers. */
|
||||
readonly nearEndAppending = input<boolean>(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<number | null>(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<string | null>(null);
|
||||
|
||||
/** Initial state description translation key */
|
||||
readonly initialDescriptionKey = input<string>(
|
||||
'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION'
|
||||
@@ -74,7 +106,11 @@ export class SearchLayoutComponent {
|
||||
/** Emitted when the back button is clicked */
|
||||
readonly backClick = output<void>();
|
||||
|
||||
/** 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<void>();
|
||||
|
||||
/** 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 (
|
||||
|
||||
@@ -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: `<div
|
||||
class="scroll-host"
|
||||
appInfiniteScroll
|
||||
[infiniteHasMore]="hasMore()"
|
||||
[infiniteAppending]="appending()"
|
||||
[infiniteItemCount]="itemCount()"
|
||||
[infiniteResetKey]="resetKey()"
|
||||
(infiniteLoadMore)="onLoadMore()"
|
||||
></div>`,
|
||||
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<TestHostComponent>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement>);
|
||||
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<void>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -33,19 +33,48 @@ export interface PortalCatalogFacade<
|
||||
TSelectedItem = unknown,
|
||||
> {
|
||||
readonly provider: PortalCatalogProvider;
|
||||
readonly pageSizeOptions: readonly number[];
|
||||
readonly contentType: Signal<string | null | undefined>;
|
||||
readonly limit: Signal<number>;
|
||||
readonly pageIndex: Signal<number>;
|
||||
readonly selectedCategory: Signal<TCategory | null | undefined>;
|
||||
readonly paginatedContent: Signal<readonly TItem[] | undefined>;
|
||||
readonly selectedItem: Signal<TSelectedItem | null | undefined>;
|
||||
readonly totalPages: Signal<number>;
|
||||
readonly isPaginatedContentLoading: Signal<boolean>;
|
||||
readonly selectedCategoryTitle: Signal<string>;
|
||||
readonly categoryItemCount: Signal<number>;
|
||||
readonly contentSortMode: Signal<PortalCatalogSortMode | null>;
|
||||
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
/** True while an asynchronous append is in flight (tail spinner). */
|
||||
readonly isAppending?: Signal<boolean>;
|
||||
/** True when the latest append failed; the tail shows a retry action. */
|
||||
readonly appendError?: Signal<boolean>;
|
||||
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<number>;
|
||||
readonly pageIndex?: Signal<number>;
|
||||
readonly totalPages?: Signal<number>;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
-2
@@ -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'"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[showSearchInput]="!isWorkspaceLayout"
|
||||
[showBackButton]="isWorkspaceLayout"
|
||||
[showDetails]="showingDetails"
|
||||
[nearEndHasMore]="false"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
(backClick)="goBack()"
|
||||
>
|
||||
|
||||
+109
-22
@@ -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<typeof TestSelectionStore>;
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<CatalogScrollState, 'visibleCount' | 'scrollTop'>
|
||||
): 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);
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
+5
-15
@@ -137,26 +137,16 @@
|
||||
{{ liveRootSubtitle() }}
|
||||
</div>
|
||||
</div>
|
||||
@if (showLiveRootPaginator()) {
|
||||
<mat-paginator
|
||||
[pageIndex]="liveRootPageIndex()"
|
||||
[length]="liveRootItemCount()"
|
||||
[pageSize]="liveRootLimit()"
|
||||
[pageSizeOptions]="liveRootPageSizeOptions"
|
||||
(page)="onLiveRootPageChange($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<app-grid-list
|
||||
class="live-all-items-grid app-scrollbar"
|
||||
appInfiniteScroll
|
||||
[infiniteHasMore]="liveRootHasMore()"
|
||||
[infiniteItemCount]="liveRootItems().length"
|
||||
[infiniteResetKey]="'live-root'"
|
||||
(infiniteLoadMore)="onLiveRootLoadMore()"
|
||||
[isLoading]="isSelectedTypeContentLoading()"
|
||||
[items]="liveRootItems()"
|
||||
[pageIndex]="liveRootPageIndex()"
|
||||
[totalPages]="liveRootTotalPages()"
|
||||
[limit]="liveRootLimit()"
|
||||
[pageSizeOptions]="liveRootPageSizeOptions"
|
||||
[showPaginator]="false"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
[variant]="'logo'"
|
||||
[type]="'live'"
|
||||
|
||||
+15
-59
@@ -42,7 +42,6 @@ import { GridListComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
@@ -68,16 +67,13 @@ class StubPortalChannelsListComponent {
|
||||
class StubGridListComponent {
|
||||
readonly items = input<unknown[]>([]);
|
||||
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<number[]>([]);
|
||||
readonly variant = input<'poster' | 'logo'>('poster');
|
||||
readonly type = input<string>();
|
||||
readonly itemClicked = output<unknown>();
|
||||
readonly pageChange = output<PageEvent>();
|
||||
readonly retryLoadMore = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -163,15 +159,13 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const currentPlaylist = signal(playlist);
|
||||
const liveStreams = signal<unknown[]>([]);
|
||||
const paginatedContent = signal<unknown[]>([]);
|
||||
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<unknown>;
|
||||
@@ -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', () => {
|
||||
|
||||
+5
-53
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
<!-- Type filters -->
|
||||
<ng-container filters>
|
||||
@@ -155,9 +159,9 @@
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Playlist-level Search Results -->
|
||||
<!-- Playlist-level Search Results (windowed; grows via nearEnd) -->
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
@for (item of visibleInPortalResults(); track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url ?? undefined"
|
||||
[title]="item.title"
|
||||
|
||||
@@ -368,6 +368,10 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<SearchResultsComponent>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown> | null>({
|
||||
id: 11,
|
||||
name: 'Movies',
|
||||
@@ -44,7 +42,7 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
{ xtream_id: 2, title: 'B' },
|
||||
]);
|
||||
const selectedItem = signal<Record<string, unknown> | null>(null);
|
||||
const totalPages = signal(1);
|
||||
const hasMoreContent = signal(false);
|
||||
const isPaginatedContentLoading = signal(false);
|
||||
const contentSortMode = signal<PortalCatalogSortMode>('date-desc');
|
||||
const minRating = signal<number | null>(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');
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user