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