Merge remote-tracking branch 'origin/master' into agent/stalker-smart-discovery

This commit is contained in:
4gray committed 2026-08-09 15:12:27 +02:00
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.
+6 -5
View File
@@ -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)
+6 -3
View File
@@ -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
+19 -8
View File
@@ -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.
@@ -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
@@ -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 ─────────────────────────────────────────────────────────
@@ -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({
@@ -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()"
@@ -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',
@@ -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')
@@ -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());
},
@@ -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);
@@ -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);
@@ -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;
}
}
}
@@ -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);
});
@@ -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);
}
@@ -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;