mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:27:41 -08:00
Merge remote-tracking branch 'origin/master' into agent/stalker-smart-discovery
This commit is contained in:
commit
b4c90dc185
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
|
||||
|
||||
@@ -303,7 +303,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
|
||||
@@ -1260,13 +1270,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;
|
||||
|
||||
@@ -104,7 +104,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
@@ -355,6 +355,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