diff --git a/.changes/stalker-category-search.md b/.changes/stalker-category-search.md new file mode 100644 index 000000000..a0a52e132 --- /dev/null +++ b/.changes/stalker-category-search.md @@ -0,0 +1,7 @@ +--- +type: fix +area: stalker +issues: [1543] +--- + +Stalker Live TV search stays within the selected category in the sidebar and fullscreen panel, including channels beyond the first page. All Items searches the whole catalog, and the two search fields work independently. diff --git a/AGENTS.md b/AGENTS.md index fafb342ea..e00bab066 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,6 +240,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## Stalker Live Search + +ITV sidebar and fullscreen searches independently filter the complete selected +category; only All Items searches the whole public catalog. Cached categories +search before windowing; missing/censored genres keep provider pagination, +including automatic continuation for short or empty search results. ITV search +never narrows shared provider pages or resets their index. Category changes +reset list windows and retain playback/active EPG. Contract: +`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache). + ## Channel and Detail Keyboard Scrolling Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection diff --git a/CLAUDE.md b/CLAUDE.md index b7af8b73f..c63701691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1725,6 +1725,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## Stalker Live Search + +ITV sidebar and fullscreen searches independently filter the complete selected +category; only All Items searches the whole public catalog. Cached categories +search before windowing; missing/censored genres keep provider pagination, +including automatic continuation for short or empty search results. ITV search +never narrows shared provider pages or resets their index. Category changes +reset list windows and retain playback/active EPG. Contract: +`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache). + ## Channel and Detail Keyboard Scrolling Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection diff --git a/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts index c8e7e8f1d..e787ce990 100644 --- a/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts +++ b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts @@ -51,9 +51,7 @@ test('@electron @stalker built-in player plays an auth-gated portal stream', asy await waitForStalkerCatalog(app.mainWindow); // The portal lands on Movies; live playback lives in the ITV layout. - await app.mainWindow - .getByRole('link', { name: /live|itv/i }) - .click(); + await app.mainWindow.getByRole('link', { name: /live|itv/i }).click(); await app.mainWindow.waitForURL(/stalker.*itv/); // The ITV view renders channels only after a category is selected; @@ -84,6 +82,40 @@ test('@electron @stalker built-in player plays an auth-gated portal stream', asy { timeout: 20_000 } ) .toBeGreaterThan(0.5); + // Search/category changes must keep the playing native-hosted engine + // while both ITV categories contain the same search word (#1543). + const playingVideo = await video.elementHandle(); + await categories.nth(1).click(); + const search = app.mainWindow.locator('input[type="search"]').first(); + await search.fill('TV'); + await search.press('Enter'); + await expect(app.mainWindow).toHaveURL(/q=TV/); + await expect( + app.mainWindow.locator('#live-channels .channel-name') + ).toHaveCount(5); + const firstNames = await app.mainWindow + .locator('#live-channels .channel-name') + .allTextContents(); + await categories.nth(2).click(); + await expect( + app.mainWindow.locator('#live-channels .channel-name') + ).toHaveCount(5); + await expect + .poll(async () => { + const names = await app.mainWindow + .locator('#live-channels .channel-name') + .allTextContents(); + return names.every((name) => !firstNames.includes(name)); + }) + .toBe(true); + expect( + await playingVideo?.evaluate((element) => element.isConnected) + ).toBe(true); + await expect + .poll(() => + video.evaluate((element: HTMLVideoElement) => element.paused) + ) + .toBe(false); await expect( app.mainWindow.getByTestId('playback-diagnostic-banner') ).toBeHidden(); diff --git a/apps/web-e2e/src/stalker-category-search.fixture.ts b/apps/web-e2e/src/stalker-category-search.fixture.ts new file mode 100644 index 000000000..166380797 --- /dev/null +++ b/apps/web-e2e/src/stalker-category-search.fixture.ts @@ -0,0 +1,146 @@ +import { expect, type Page } from '@playwright/test'; + +/** Category scope, independent fullscreen search and playback continuity (#1543). */ +export async function verifyStalkerCategorySearch(page: Page): Promise { + const sidebarSearch = page.locator('input[type="search"]').first(); + const categories = page.locator('.category-item'); + const sidebarRows = page.locator( + '#live-channels [data-test-id="channel-item"]' + ); + await sidebarSearch.fill('TV'); + await sidebarSearch.press('Enter'); + await expect(page).toHaveURL(/q=TV/); + await expect(sidebarRows).toHaveCount(40); + const firstNames = await sidebarRows + .locator('.channel-name') + .allTextContents(); + // All Items already started playback earlier in this workflow. Retain that + // settled selection; clicking again would introduce a pending replacement. + const player = page.locator('app-web-player-view'); + await expect(player).toBeVisible(); + const video = player.locator('video').first(); + const videoNode = await video.elementHandle(); + const playerNode = await player.elementHandle(); + + await player.hover(); + await player.getByRole('button', { name: 'Enter fullscreen' }).click(); + await page + .locator('[data-test-id="fullscreen-channel-panel-hot-zone"]') + .hover(); + const panel = page.locator('[data-test-id="fullscreen-channel-panel"]'); + await expect(panel).toHaveAttribute('aria-hidden', 'false'); + const panelSearch = panel.getByRole('searchbox'); + const panelRows = panel.locator('[data-test-id="channel-item"]'); + await panelSearch.fill('TV'); + await expect(panelRows).toHaveCount(40); + await expect(panelRows.locator('.channel-name')).toHaveText(firstNames); + await panelSearch.fill(firstNames[30].trim()); + await expect(panelRows).toHaveCount(1); + await expect(sidebarSearch).toHaveValue('TV'); + await expect(sidebarRows).toHaveCount(40); + await page.evaluate(() => document.exitFullscreen()); + + // Navigation retains the applied term and playing engine, including a channel + // outside the new category. Its separate selection UX is tracked in #1520. + await categories.nth(2).click(); + await expect(sidebarSearch).toHaveValue('TV'); + await expect(sidebarRows).toHaveCount(40); + await expect + .poll(async () => { + const names = await sidebarRows + .locator('.channel-name') + .allTextContents(); + return names.every((name) => !firstNames.includes(name)); + }) + .toBe(true); + expect(await playerNode?.evaluate((element) => element.isConnected)).toBe( + true + ); + expect(await videoNode?.evaluate((element) => element.isConnected)).toBe(true); + + await sidebarSearch.fill(''); + await sidebarSearch.press('Enter'); + await expect(page).not.toHaveURL(/q=/); + await expect(sidebarRows).toHaveCount(40); + // The category-to-channel keyboard hand-off still targets the sidebar. + await categories.nth(2).focus(); + await page.keyboard.press('ArrowRight'); + await expect(page.locator('#live-channels')).toBeFocused(); + + await categories.first().click(); + await sidebarSearch.fill('TV'); + await sidebarSearch.press('Enter'); + await expect(page).toHaveURL(/q=TV/); + // Explicit All Items is a window over all 320 public channels. + await expect(page.locator('.category-subtitle').first()).toContainText( + '320' + ); +} + +/** A category absent from get_all_channels must page for a late local match. */ +export async function verifyUncachedStalkerSearch( + page: Page, + mockServer: string +): Promise { + const response = await page.request.get(`${mockServer}/stalker`, { + params: { + url: `${mockServer}/portal.php`, + action: 'get_ordered_list', + type: 'itv', + genre: '1099', + category: '1099', + p: '3', + macAddress: '00:1A:79:00:00:01', + }, + }); + const body = await response.json(); + const lateName = String(body.payload.js.data[0].name); + const search = page.locator('input[type="search"]').first(); + const requests: URL[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.searchParams.get('genre') === '1099') requests.push(url); + }); + await search.fill(lateName); + await search.press('Enter'); + await expect(page.locator('#live-channels .channel-name')).toHaveText( + [lateName], + { timeout: 20_000 } + ); + expect(requests.some((url) => Number(url.searchParams.get('p')) >= 3)).toBe( + true + ); + expect(requests.every((url) => !url.searchParams.has('search'))).toBe(true); + await search.fill('TV'); + await search.press('Enter'); + await expect(page).toHaveURL(/q=TV/); + await expect( + page.locator('#live-channels [data-test-id="channel-item"]') + ).toHaveCount(40); + await verifyStalkerPanelCategory(page, search); +} + +async function verifyStalkerPanelCategory( + page: Page, + search: ReturnType +): Promise { + const rows = page.locator('#live-channels [data-test-id="channel-item"]'); + const names = await rows.locator('.channel-name').allTextContents(); + await rows.first().click(); + await search.fill(names[0].trim()); + await search.press('Enter'); + await expect(rows).toHaveCount(1); + const player = page.locator('app-web-player-view'); + await player.hover(); + await player.getByRole('button', { name: 'Enter fullscreen' }).click(); + await page + .locator('[data-test-id="fullscreen-channel-panel-hot-zone"]') + .hover(); + const panel = page.locator('[data-test-id="fullscreen-channel-panel"]'); + await expect(panel).toHaveAttribute('aria-hidden', 'false'); + await expect(panel.locator('.channel-name')).toHaveText(names); + await panel.getByRole('searchbox').fill(names[30].trim()); + await expect(panel.locator('.channel-name')).toHaveText([names[30]]); + await expect(search).toHaveValue(names[0].trim()); + await page.evaluate(() => document.exitFullscreen()); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index cf7b71fae..2cc2c0609 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1,5 +1,9 @@ import { type APIRequestContext, type Page } from '@playwright/test'; import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers'; +import { + verifyStalkerCategorySearch, + verifyUncachedStalkerSearch, +} from './stalker-category-search.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; import { expect, test } from './fixtures'; import { @@ -189,7 +193,9 @@ async function resetMockServer( for (let attempt = 0; attempt < 3; attempt += 1) { try { - const response = await request.post(`${MOCK_SERVER}/reset?${query}`); + const response = await request.post( + `${MOCK_SERVER}/reset?${query}` + ); if (response.ok()) { return; } @@ -701,7 +707,7 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover await categories.nth(1).click(); - const channels = page.locator('[data-test-id="channel-item"]'); + const channels = page.locator('#live-channels [data-test-id="channel-item"]'); await expect(channels.first()).toBeVisible({ timeout: 20_000 }); // Regression for "search only finds the first 14 loaded items": once the @@ -711,6 +717,7 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover timeout: 20_000, }); await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0); + const firstCategoryNames = await channels.locator('.channel-name').allTextContents(); // Regression: switching to another category once the full list is cached // must serve that category from the cache, not get stuck on an empty @@ -733,6 +740,10 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover timeout: 20_000, }); + // Counts are identical across categories; wait for the actual category + // rows before choosing a search term, or it may come from the previous one. + await expect(channels.locator('.channel-name')).toHaveText(firstCategoryNames); + // Search a channel from deep in the list (beyond the first 14 items). const deepChannelName = ( await channels.nth(30).locator('.channel-name').textContent() @@ -751,6 +762,7 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover ).toBeVisible({ timeout: 10_000 }); // The "loaded only" degraded-search hint must be gone in full-list mode. await expect(page.locator('.search-chip--status')).toHaveCount(0); + await verifyStalkerCategorySearch(page); }); test('@stalker ITV censored category pages from the portal and hides its badge', async ({ @@ -792,6 +804,7 @@ test('@stalker ITV censored category pages from the portal and hides its badge', const channels = page.locator('[data-test-id="channel-item"]'); await expect(channels.first()).toBeVisible({ timeout: 20_000 }); await expect.poll(() => adultListRequests.length).toBeGreaterThan(0); + await verifyUncachedStalkerSearch(page, MOCK_SERVER); }); test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({ @@ -1183,9 +1196,7 @@ test('@stalker series watched toggle — embedded series marks and clears from t const menuTrigger = page.locator('[data-test-id="series-watch-menu"]'); await expect(menuTrigger).toBeVisible(); await menuTrigger.click(); - const seriesToggle = page.locator( - '[data-test-id="toggle-series-watched"]' - ); + const seriesToggle = page.locator('[data-test-id="toggle-series-watched"]'); await expect(seriesToggle).toBeVisible(); await expect(seriesToggle).toContainText( `Mark series as watched (${episodeCount})` diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index ea178440f..abc177838 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -378,11 +378,11 @@ panel copy's scroll — because in full-list mode a broad term matches most of a multi-thousand-channel portal and the list has no virtual scroll; on a paged portal the panel copy also keeps requesting pages while its matches do not fill it — an empty or short result cannot scroll, and the term may match -channels on pages never fetched; the sidebar's own search term does not stop -it, since that term only gates the sidebar copy's automatic fill, and a page -landing resets the in-flight flag whether or not the sidebar shows any of it -— while in full-list mode it never pages, since its search already sees the -whole catalog; closing the panel pauses window growth and automatic page +channels on pages never fetched. Both ITV fields search the selected category, +and only All Items searches the portal. The store's ITV pages are unfiltered +by either field; a short sidebar search also continues uncached category pages. +A page landing resets the in-flight flag whether or not the sidebar shows any +of it; a cached category never pages, since its entire category is searchable; closing the panel pauses window growth and automatic page requests while preserving the mounted list, and an observer of the aside's `inert` attribute resumes filling on reopen and disconnects with the list; inline video keeps the selected channel paired with its retained playback diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index d8e193534..217e29466 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1324,20 +1324,29 @@ list: - Loading state contract (important — regressions here strand the sidebar on a skeleton): in full-list mode the content loader serves the filtered list **synchronously** from the cache. The category-change reset effect therefore - must NOT `setItvChannels([])` while `itvFullListActive()` is true — it runs + must NOT `setItvChannels([])` while `itvSelectedCategoryFromCache()` is true — it runs after the store resource and would clobber the freshly served list, leaving every category after the first stuck on a skeleton. The initial-loading skeleton (`isInitialChannelsLoading`) must key off an actual in-flight load (`itvFullListLoading()` or `isPaginatedContentLoading()`), not merely an empty channel list; an empty result once loading has settled is an empty category and renders `PORTALS.NO_CHANNELS_IN_CATEGORY`, not a spinner. -- Search: with the cache active, the header search spans the ENTIRE portal - (all genres) — filtering the store's `itvFullChannelList`, not just the - selected category — so searching "CNN" while a "Sports" genre is selected - still finds it; clearing the term returns to the selected category. The - workspace shell drops the `degraded-loaded-only` / "loaded only" status for - Stalker ITV once `itvFullListActive`; radio (no full-list cache) always keeps - the loaded-only hint (`workspace-shell-search.service.ts`). +- Search (#1543): the sidebar and fullscreen fields independently filter the + complete **selected category**, or the whole public catalog in All Items + (both the uncategorized grid and `*`). Neither merges foreign cached genres + into a category. ITV search never changes provider request parameters or + resets accumulated pages: this keeps the fullscreen field independent of + sidebar text. Cached categories are searched before the 100-row render + window; uncached/censored categories continue `get_ordered_list` pages when + a short/empty result cannot scroll, and continue on scroll otherwise. Search + results use 100-row windows. Clearing or changing the query resets the render + window, and category changes reset both list windows without restarting playback. + Empty or repeated provider pages terminate pagination; a cache-ready replay + of the same uncached page is deduplicated without hiding later pages. Aborted + requests cannot overwrite the current category, even after navigating away + and back. Radio stays on its separate station list and server-search flow. + The workspace shell retains its loaded-only hint for ITV portals without + a full cache and for radio. - Windowed selection: remote channel-up/down and numeric select operate over the full filtered category, so the render window (`renderLimit`) grows to include a selection beyond it (`ensureChannelWithinRenderWindow`) instead of diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index c9f01cc48..3a4d0a669 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -172,6 +172,42 @@ describe('withStalkerContent failure states', () => { store = TestBed.inject(TestContentStore); }); + it('keeps ITV pages unfiltered while independent local searches change', async () => { + const pending = + createDeferred>(); + dataService.sendIpcEvent.mockImplementation( + (_event, request: { params: { action: string } }) => + request.params.action === 'get_genres' + ? Promise.resolve({ js: [] }) + : pending.promise + ); + store.setSelectedContentType('itv'); + store.setCategories('itv', [ + { category_id: '5', category_name: 'Five' }, + ]); + store.setSelectedCategory('5'); + store.setCurrentPlaylist(PLAYLIST); + patchState(store, { searchPhrase: 'sidebar' }); + await flushResources(); + const orderedCalls = () => + dataService.sendIpcEvent.mock.calls.filter( + (call) => + (call[1] as { params: { action: string } }).params + .action === 'get_ordered_list' + ); + await waitForCondition(() => orderedCalls().length > 0); + expect( + (orderedCalls()[0][1] as { params: { search?: string } }).params + .search + ).toBeUndefined(); + patchState(store, { searchPhrase: 'changed' }); + await flushResources(); + pending.resolve(createContentResponse('Panel match')); + await waitForCondition(() => store.itvChannels().length > 0); + expect(orderedCalls()).toHaveLength(1); + expect(store.itvChannels()[0].name).toBe('Panel match'); + }); + it('normalizes category failures into empty arrays and explicit error state', async () => { dataService.sendIpcEvent.mockRejectedValue( new Error('get_genres failed') @@ -724,6 +760,15 @@ describe('withStalkerContent full ITV channel list cache', () => { void store.isPaginatedContentLoading(); } + it('does not request paged channels without a selected category', async () => { + setup(null); + enterItvCategory(''); + await flushResources(); + expect(store.itvChannels()).toEqual([]); + expect(store.hasMoreChannels()).toBe(false); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + it('serves the whole category from the cache without portal requests', async () => { setup(CACHED_CHANNELS); enterItvCategory('5'); @@ -897,6 +942,85 @@ describe('withStalkerContent full ITV channel list cache', () => { expect(store.hasMoreChannels()).toBe(false); }); + it('keeps censored pagination after a delayed cache replays the current page', async () => { + setup(null); + dataService.sendIpcEvent.mockImplementation( + (_event, payload: { params: { p: number } }) => + Promise.resolve({ + js: { + data: [ + { + id: String(payload.params.p), + name: `Hidden ${payload.params.p}`, + }, + ], + total_items: 3, + }, + }) + ); + enterItvCategory('1099'); + await waitForCondition(() => store.itvChannels().length === 1); + store.setPage(1); + await waitForCondition(() => store.itvChannels().length === 2); + itvCache.setChannels(CACHED_CHANNELS); + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length === 3 + ); + await flushResources(); + expect(store.itvChannels().map((row) => row.name)).toEqual([ + 'Hidden 1', + 'Hidden 2', + ]); + expect(store.hasMoreChannels()).toBe(true); + store.setPage(2); + await waitForCondition(() => store.itvChannels().length === 3); + expect(store.hasMoreChannels()).toBe(false); + }); + + it.each(['empty', 'repeated'])( + 'stops uncached search pagination on an %s page', + async (ending) => { + setup(CACHED_CHANNELS); + dataService.sendIpcEvent.mockResolvedValue({ + js: { data: [{ id: 'one', name: 'Hidden' }], total_items: 99 }, + }); + enterItvCategory('1099'); + await waitForCondition(() => store.itvChannels().length === 1); + if (ending === 'empty') + dataService.sendIpcEvent.mockResolvedValue({ + js: { data: [], total_items: 99 }, + }); + store.setPage(1); + await waitForCondition(() => !store.hasMoreChannels()); + expect(store.itvChannels()).toHaveLength(1); + } + ); + + it('rejects an abandoned category response even after navigating back to it', async () => { + setup(null); + const old = createDeferred(); + dataService.sendIpcEvent.mockReturnValueOnce(old.promise); + enterItvCategory('5'); + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length === 1 + ); + dataService.sendIpcEvent.mockResolvedValue( + createContentResponse('Current') + ); + store.setSelectedCategory('9'); + await waitForCondition( + () => store.itvChannels()[0]?.name === 'Current' + ); + store.setSelectedCategory('5'); + await waitForCondition( + () => dataService.sendIpcEvent.mock.calls.length === 3 + ); + await flushResources(); + old.resolve(createContentResponse('Abandoned')); + await flushResources(); + expect(store.itvChannels()[0].name).toBe('Current'); + }); + it('kicks off a background full-list load and swaps it in once ready', async () => { setup(null); dataService.sendIpcEvent.mockResolvedValue({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 8119854ae..f4e69d4f6 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -235,6 +235,7 @@ export function withStalkerContent() { portalRepair, }; + let lastLivePageKey = ''; return { categoryResource: resource({ params: () => ({ @@ -360,7 +361,12 @@ export function withStalkerContent() { params: () => ({ contentType: storeContext.selectedContentType(), category: storeContext.selectedCategoryId(), - search: storeContext.searchPhrase(), + // ITV fields filter locally; server search would narrow + // the shared pages behind the independent fullscreen field. + search: + storeContext.selectedContentType() === 'itv' + ? '' + : storeContext.searchPhrase(), pageIndex: storeContext.page() + 1, currentPlaylist: storeContext.currentPlaylist(), // Re-fires the loader once THIS portal's full ITV @@ -384,6 +390,7 @@ export function withStalkerContent() { }), loader: async ({ params, + abortSignal, }): Promise => { if (!params.category || params.category === '') { patchState( @@ -477,12 +484,14 @@ export function withStalkerContent() { null; return ( + !abortSignal.aborted && params.contentType === storeContext.selectedContentType() && params.category === storeContext.selectedCategoryId() && - params.search === - storeContext.searchPhrase() && + (params.contentType === 'itv' || + params.search === + storeContext.searchPhrase()) && params.pageIndex === storeContext.page() + 1 && paramsPlaylistKey === currentPlaylistKey && @@ -593,11 +602,22 @@ export function withStalkerContent() { const nextChannels = params.pageIndex === 1 ? channels - : [ + : dedupeContentById([ ...existingChannels, ...channels, - ]; + ]).map(toStalkerItvChannel); + const livePageKey = JSON.stringify([ + paramsPlaylistKey, + params.contentType, + params.category, + params.pageIndex, + ]); + // Cache readiness can replay the current censored page. + // A different page adding no ids is a stalled portal. + const replay = + livePageKey === lastLivePageKey; + lastLivePageKey = livePageKey; patchState(store, { totalCount: response.js.total_items ?? 0, @@ -607,8 +627,13 @@ export function withStalkerContent() { ? { itvChannels: nextChannels } : { radioChannels: nextChannels }), hasMoreChannels: + channels.length > 0 && + (params.pageIndex === 1 || + replay || + nextChannels.length > + existingChannels.length) && nextChannels.length < - (response.js.total_items ?? 0), + (response.js.total_items ?? 0), }); } else { // VOD/series pages accumulate into one diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts index 6e12451d9..9647fccdd 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.spec.ts @@ -57,6 +57,15 @@ describe('withStalkerSelection', () => { expect(store.page()).toBe(0); }); + it('preserves ITV pages when a local search changes or clears', () => { + store.setSelectedContentType('itv'); + store.setPage(2); + store.setSearchPhrase('shared'); + expect(store.page()).toBe(2); + store.setSearchPhrase(''); + expect(store.page()).toBe(2); + }); + it('synchronizes entity ids when the selected item changes', () => { store.setSelectedItem({ id: '55', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index ddcc1a3ee..e627ecac3 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -94,7 +94,11 @@ export function withStalkerSelection() { return; } - patchState(store, { searchPhrase: phrase, page: 0 }); + // ITV search is local to each surface; keep the shared catalog pages. + patchState(store, { + searchPhrase: phrase, + ...(store.selectedContentType() === 'itv' ? {} : { page: 0 }), + }); }, setSelectedItem(selectedItem: StalkerVodSource | null | undefined) { const selectedIdRaw = diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts index 6e34a65a9..7ea2fd1b3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.spec.ts @@ -158,6 +158,10 @@ describe('shouldAutoFillStampedList', () => { expect(shouldAutoFillStampedList(false, true)).toBe(false); }); + it('fills an ITV sidebar search to reach matches on later pages', () => { + expect(shouldAutoFillStampedList(false, true, true)).toBe(true); + }); + it('always lets the fullscreen panel fill itself', () => { // The sidebar's search term is not the panel's concern: the panel's // own term is what may need the next page. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts index a89e37510..9c83e333f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/panel-search-window.ts @@ -18,17 +18,16 @@ export type PanelSearchScrollAction = 'idle' | 'grow-window' | 'load-page'; */ /** * Whether a stamped list that does not overflow should fill itself without a - * scroll. The sidebar copy never does so while its own search is active - * (scrolling it still pages) — that has always been its behaviour, since a - * client-side search would otherwise walk the whole portal on its own. The - * fullscreen panel's copy always does: its term is what may need the next - * page, and the sidebar's term is not its concern. + * scroll. ITV local search must reach later category pages when there are + * no matches to scroll. Radio keeps its server-search pagination behavior. + * The fullscreen copy fills independently of the sidebar's term. */ export function shouldAutoFillStampedList( isPanelContainer: boolean, - sidebarSearchActive: boolean + sidebarSearchActive: boolean, + completeLocalSearch = false ): boolean { - return isPanelContainer || !sidebarSearchActive; + return isPanelContainer || !sidebarSearchActive || completeLocalSearch; } export function resolvePanelSearchScroll(state: { @@ -48,7 +47,7 @@ export function resolvePanelSearchScroll(state: { * Memoized, windowed matches for the fullscreen channel panel's own search * field. * - * In full-list mode the search source is the portal's entire channel list, + * The search source is the selected category (the portal in All Items), * and a broad term ("tv") matches most of it. The panel renders one * `app-channel-list-item` per row without virtual scrolling, so the matches * are rendered through the same bounded window the sidebar uses: `chunk` diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 4d29b45d6..3070f72ac 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -729,7 +729,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { } }); - it('searches the whole portal (all categories) in full-list mode, not just the current category', () => { + it('excludes other categories from full-list search', () => { // The current category (itvChannels) has only 'Alpha TV'/'Beta TV', but // the portal's full list contains a News channel in another genre. itvFullListActive.set(true); @@ -748,13 +748,13 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect( component.filteredChannels().map((channel) => channel.name) - ).toEqual(['CNN International']); + ).toEqual([]); }); it('includes paged censored-category channels in full-list search results', () => { // The adult category's channels come from the legacy paged flow and // are intentionally absent from the full-list cache — searching for a - // currently visible channel must still find it (merged source). + // currently visible channel must still find it within this category. itvFullListActive.set(true); itvSelectedCategoryFromCache.set(false); itvChannels.set([ @@ -774,15 +774,15 @@ describe('StalkerLiveStreamLayoutComponent', () => { component.filteredChannels().map((channel) => channel.name) ).toEqual(['Erox HD']); - // And the cached portal-wide channels remain searchable too. + // A cache hit in another category must not leak into this category. searchPhrase.set('alpha'); fixture.detectChanges(); expect( component.filteredChannels().map((channel) => channel.name) - ).toEqual(['Alpha TV']); + ).toEqual([]); }); - it('grows the render window to include a channel selected beyond it (remote/numeric nav)', async () => { + it.each([true, false])('grows the render window for remote/numeric selection (cached=%s)', async (cached) => { const full = Array.from({ length: 250 }, (_, index) => ({ id: `ch-${index}`, cmd: `ffrt4://itv/${index}`, @@ -791,9 +791,10 @@ describe('StalkerLiveStreamLayoutComponent', () => { logo: '', })); itvFullListActive.set(true); - itvSelectedCategoryFromCache.set(true); + itvSelectedCategoryFromCache.set(cached); itvChannels.set(full); - itvFullChannelList.set(full); + itvFullChannelList.set(cached ? full : []); + searchPhrase.set('channel'); fixture.detectChanges(); expect(component.visibleChannels()).toHaveLength(100); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e50410668..b6188c2cf 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -214,36 +214,12 @@ export class StalkerLiveStreamLayoutComponent !this.isRadioMode() && this.stalkerStore.itvSelectedCategoryFromCache() ); - /** - * The rows a search term is matched against: the current category, or in - * full-list mode the WHOLE portal's channel list (every category) merged - * with the currently loaded channels, because a censored (adult) category - * is paged from the portal and its channels are intentionally absent from - * the full-list cache. - */ - private readonly searchableChannels = computed(() => { - const source = this.channels(); - if (!this.isFullListMode()) { - return source; - } - - const merged = new Map(); - for (const channel of source) { - merged.set(normalizeStalkerEntityId(channel.id), channel); - } - for (const channel of this.stalkerStore.itvFullChannelList()) { - const id = normalizeStalkerEntityId(channel.id); - if (!merged.has(id)) { - merged.set(id, channel); - } - } - return [...merged.values()]; - }); - /** - * Channels matching the search phrase. Without a term, the current - * category; with one, `searchableChannels` filtered, so search behaves - * like "search all channels" whenever the full list is cached. + /** The store supplies the complete category when cached, accumulated pages otherwise. + * Only All Items has portal-wide scope. Both search fields share this source. */ + private readonly searchableChannels = computed(() => + this.showItvAllItems() ? this.itvFullChannelList() : this.channels() + ); readonly filteredChannels = computed(() => { const term = this.searchTerm(); if (!term) { @@ -291,7 +267,7 @@ export class StalkerLiveStreamLayoutComponent computation: () => FULL_LIST_RENDER_CHUNK, }); readonly visibleChannels = computed(() => - this.isCategoryFromCache() + this.isCategoryFromCache() || Boolean(this.searchTerm()) ? this.filteredChannels().slice(0, this.renderLimit()) : this.filteredChannels() ); @@ -323,10 +299,10 @@ export class StalkerLiveStreamLayoutComponent : this.stalkerStore.hasMoreChannels() ); readonly totalChannelCount = computed(() => this.filteredChannels().length); - readonly hasMoreItems = computed(() => - this.isCategoryFromCache() - ? this.visibleChannels().length < this.filteredChannels().length - : this.stalkerStore.hasMoreChannels() + readonly hasMoreItems = computed( + () => + this.visibleChannels().length < this.filteredChannels().length || + (!this.isCategoryFromCache() && this.stalkerStore.hasMoreChannels()) ); readonly isLoadingMore = signal(false); /** @@ -648,6 +624,8 @@ export class StalkerLiveStreamLayoutComponent effect(() => { const contentType = this.stalkerStore.selectedContentType(); this.stalkerStore.selectedCategoryId(); + this.panelSearch.clear(); + this.isLoadingMore.set(false); untracked(() => { if (contentType === 'radio') { this.stalkerStore.setRadioChannels([]); @@ -1042,13 +1020,13 @@ export class StalkerLiveStreamLayoutComponent } /** - * In full-list mode the rendered list is windowed to `renderLimit`. When a + * Cached categories and search results are windowed to `renderLimit`. When a * channel beyond that window is selected (remote channel-up/down, numeric * select), grow the window so the selection is actually in the DOM and can * be highlighted/scrolled to instead of drifting off-window. */ private ensureChannelWithinRenderWindow(channelId: string): void { - if (!this.isCategoryFromCache()) { + if (this.visibleChannels().length === this.filteredChannels().length) { return; } @@ -1105,17 +1083,20 @@ export class StalkerLiveStreamLayoutComponent } loadMore() { - if (this.isCategoryFromCache()) { - // Extends the render window over the in-memory list — no request. - if (this.hasMoreItems()) { - this.growRenderWindow(); - } + if (this.visibleChannels().length < this.filteredChannels().length) { + this.growRenderWindow(); return; } + if (this.isCategoryFromCache()) return; + this.loadNextChannelPage(); + } + private loadNextChannelPage(): void { // Legacy portal pagination — also used for censored (adult) genres // that are absent from the full-list cache. - if (this.isLoadingMore() || !this.hasMoreItems()) return; + if (this.isLoadingMore() || !this.stalkerStore.hasMoreChannels()) + return; + if (this.stalkerStore.isPaginatedContentLoading()) return; this.isLoadingMore.set(true); const nextPage = this.stalkerStore.page() + 1; this.stalkerStore.setPage(nextPage); @@ -1132,7 +1113,7 @@ export class StalkerLiveStreamLayoutComponent this.growRenderWindow(); return; } - this.loadMore(); + this.loadNextChannelPage(); } private growRenderWindow(): void { @@ -1551,7 +1532,8 @@ export class StalkerLiveStreamLayoutComponent if ( !shouldAutoFillStampedList( isPanelContainer, - sidebarSearchActive + sidebarSearchActive, + !this.isRadioMode() ) ) { continue; @@ -1566,8 +1548,8 @@ export class StalkerLiveStreamLayoutComponent * panel's copy, while searching with its own term, scrolls through its * own windowed matches, not the sidebar's rows: it grows that window * first and only pages the portal once the window covers every loaded - * match — and never in full-list mode, where its search already sees - * the whole catalog and a page would only widen the sidebar's window. + * match. Cached categories (and All Items) already expose their complete + * source and never request provider pages from the panel. */ private driveStampedList(container: HTMLElement, nearEnd: boolean) { // A closed panel stays mounted to preserve search and scroll, but @@ -1587,7 +1569,10 @@ export class StalkerLiveStreamLayoutComponent this.panelSearch.loadMore(); return; } - if (isPanelSearch && this.isCategoryFromCache()) return; + if (isPanelSearch) { + if (!this.panelUsesCachedRows()) this.loadNextChannelPage(); + return; + } if (isPanelContainer && !isPanelSearch) { // The blank panel shows the category, not the sidebar's filtered // rows, so its continuation is judged against the category too. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts index e9ff6c2c3..2faea785c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.panel-search.spec.ts @@ -57,7 +57,7 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => { selectedItvId: signal(undefined), selectedItem: signal(null), itvChannels, - radioChannels: signal([]), + radioChannels: signal([]), searchPhrase, hasMoreChannels, page, @@ -97,7 +97,10 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => { itvFullListActive.set(false); itvSelectedCategoryFromCache.set(false); store.selectedCategoryId.set('all'); + store.selectedContentType.set('itv'); + store.radioChannels.set([]); store.itvFullChannelList.set([]); + store.itvFullListLoading.set(false); store.setPage.mockClear(); await TestBed.configureTestingModule({ imports: [ @@ -156,6 +159,91 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => { afterEach(() => fixture.destroy()); + it('searches the complete selected category in both independent fields', () => { + const category = Array.from({ length: 250 }, (_, index) => ({ + ...channels[0], + id: `selected-${index}`, + name: `Shared ${index}`, + o_name: `Shared ${index}`, + })); + const foreign = { + ...channels[0], + id: 'foreign', + name: 'Shared foreign', + o_name: 'Shared foreign', + }; + itvFullListActive.set(true); + itvSelectedCategoryFromCache.set(true); + itvChannels.set(category); + store.itvFullChannelList.set([...category, foreign]); + searchPhrase.set('shared'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual(category); + expect(component.visibleChannels()).toHaveLength(100); + const panelTerm = signal('shared 249'); + expect(component.channelsForList(panelTerm)).toEqual([category[249]]); + expect(searchPhrase()).toBe('shared'); + searchPhrase.set('shared 248'); + expect(component.filteredChannels()).toEqual([category[248]]); + expect(component.channelsForList(panelTerm)).toEqual([category[249]]); + + store.selectedCategoryId.set('next'); + itvChannels.set([foreign]); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([]); + expect(component.channelsForList(panelTerm)).toEqual([]); + searchPhrase.set(''); + panelTerm.set(''); + expect(component.channelsForList()).toEqual([foreign]); + expect(component.channelsForList(panelTerm)).toEqual([foreign]); + expect(store.resolveItvPlayback).not.toHaveBeenCalled(); + + store.selectedCategoryId.set(null); + searchPhrase.set('shared'); + panelTerm.set('shared'); + fixture.detectChanges(); + expect(component.filteredChannels()).toHaveLength(251); + expect(component.channelsForList(panelTerm)).toHaveLength(100); + }); + + it('searches paged All Items while the full cache is still loading', () => { + store.selectedCategoryId.set('*'); + store.itvFullListLoading.set(true); + searchPhrase.set('one'); + fixture.detectChanges(); + expect(component.showItvAllItems()).toBe(false); + expect(component.filteredChannels()).toEqual([channels[0]]); + expect(component.channelsForList(signal('two'))).toEqual([channels[1]]); + }); + + it('does not relabel retained category rows as the initial All Items grid', () => { + // No selected category has no provider-page request. The unselected + // grid waits for the public cache, even if a prior category's rows + // are still present before the store's reset effect settles. + store.selectedCategoryId.set(null); + store.itvFullListLoading.set(true); + searchPhrase.set('one'); + fixture.detectChanges(); + expect(component.showItvAllItems()).toBe(true); + expect(component.filteredChannels()).toEqual([]); + expect(component.channelsForList(signal('two'))).toEqual([]); + store.itvFullChannelList.set(channels); + expect(component.filteredChannels()).toEqual([channels[0]]); + expect(component.channelsForList(signal('two'))).toEqual([channels[1]]); + }); + + it('does not search the ITV cache after switching to radio', () => { + itvFullListActive.set(true); + store.itvFullChannelList.set(channels); + store.selectedContentType.set('radio'); + store.radioChannels.set([channels[1]]); + searchPhrase.set('one'); + fixture.detectChanges(); + expect(component.filteredChannels()).toEqual([]); + searchPhrase.set('two'); + expect(component.filteredChannels()).toEqual([channels[1]]); + }); + it('keeps requesting pages while an empty panel search cannot scroll', async () => { // A term with no match on the loaded page renders nothing the user // could scroll, yet later pages may hold the channel — so the empty @@ -304,16 +392,14 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => { expect(container.classList.contains('app-scrollbar')).toBe(true); }); - it('leaves the sidebar alone while its own search is active', async () => { - // Only the panel copy fills itself during a sidebar search; the - // sidebar has never paged automatically then, and this fixture - // renders the sidebar copy only. + it('pages the sidebar when its search cannot fill the viewport', async () => { + // A short ITV result cannot scroll but later category pages may match. hasMoreChannels.set(true); searchPhrase.set('one'); fixture.detectChanges(); await settle(); - expect(store.setPage).not.toHaveBeenCalled(); + expect(store.setPage).toHaveBeenCalledWith(1); }); it('pauses automatic paging while the panel is closed and resumes on reopen', async () => {