diff --git a/.changes/xtream-category-selection-scroll.md b/.changes/xtream-category-selection-scroll.md new file mode 100644 index 000000000..19c2bed83 --- /dev/null +++ b/.changes/xtream-category-selection-scroll.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Selecting an Xtream category no longer scrolls the category panel to an unrelated category, including when categories are hidden or sorted alphabetically. diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 181bc1079..af7ae8137 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -1,4 +1,6 @@ import { Locator, Page } from '@playwright/test'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import { ok as assert } from 'node:assert'; import { addXtreamPortal, closeElectronApp, @@ -14,8 +16,96 @@ import { waitForSourceRowIdle, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { applyTheme } from './theme-contrast'; test.describe('Electron Xtream Category Management', () => { + test('keeps the selected category in view with 800 categories, 600 hidden and A-Z sorting', async ({ + dataDir, + request, + }) => { + test.slow(); + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + await addXtreamPortal(app.mainWindow, { + username: 'category-scroll', + password: 'category-scroll', + }); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await waitForXtreamWorkspaceReady(app.mainWindow); + const panel = app.mainWindow.locator('app-workspace-context-panel'); + const rows = panel.locator('.category-item'); + await expect(rows).toHaveCount(800); + const dialog = await openManageCategoriesDialog(app.mainWindow); + await dialog + .getByRole('button', { name: 'Deselect All', exact: true }) + .click(); + await dialog.locator('input[type="search"]').fill('Visible'); + await dialog + .getByRole('button', { name: 'Select Filtered', exact: true }) + .click(); + await expect(dialog.locator('.selection-info')).toHaveText( + 'Total selected: 200 / 800' + ); + await dialog + .getByRole('button', { name: 'Save', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + await expect(rows).toHaveCount(200); + await panel + .getByRole('button', { name: 'Sort categories', exact: true }) + .click(); + await app.mainWindow + .getByRole('menuitem', { name: 'Name A-Z' }) + .click(); + + const categories = await app.mainWindow.evaluate(async () => { + const playlistId = location.pathname.match( + /\/workspace\/xtreams\/([^/]+)/ + )?.[1]; + if (!playlistId) + throw new Error('Xtream playlist route is missing'); + const api = ( + window as unknown as { electron: ElectronBridgeApi } + ).electron; + return api.dbGetCategories(playlistId, 'live'); + }); + categories.sort((left, right) => + left.name.localeCompare(right.name) + ); + await expect(rows.locator('.nav-item-label')).toHaveText( + categories.map((category) => category.name) + ); + + // Exercise collisions above and below the clicked row. Reading the + // imported IDs avoids relying on SQLite allocation/import order. + for (const [theme, direction] of [ + ['dark', -1], + ['light', 1], + ] as const) { + await applyTheme(app.mainWindow, theme); + const category = categories.find((candidate, index) => { + const wrongIndex = categories.findIndex( + (other) => other.xtream_id === candidate.id + ); + return ( + wrongIndex >= 0 && (wrongIndex - index) * direction > 15 + ); + }); + assert( + category, + `Missing fixture collision in direction ${direction}` + ); + const row = rows.filter({ hasText: category.name }); + await row.click(); + await expect(row).toHaveAttribute('aria-current', 'true'); + await expectCategoryCentered(row); + } + } finally { + await closeElectronApp(app); + } + }); + for (const section of ['Live TV', 'Movies', 'Series']) { test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({ dataDir, @@ -407,6 +497,44 @@ async function openManageCategoriesDialog(page: Page) { return dialog; } +/** Wait for the real smooth scroll to finish with the selected row centered. */ +async function expectCategoryCentered(row: Locator): Promise { + let previousTop = -1; + let stableSamples = 0; + await expect + .poll( + async () => { + const position = await row.evaluate((element) => { + const container = element.closest( + 'app-workspace-context-category-view' + ) as HTMLElement; + const bounds = container.getBoundingClientRect(); + const rowBounds = element.getBoundingClientRect(); + const target = + container.scrollTop + + rowBounds.top - + bounds.top - + container.clientHeight / 2 + + rowBounds.height / 2; + const clamped = Math.min( + container.scrollHeight - container.clientHeight, + Math.max(0, target) + ); + return { + top: container.scrollTop, + centered: Math.abs(container.scrollTop - clamped) < 2, + }; + }); + stableSamples = + position.top === previousTop ? stableSamples + 1 : 0; + previousTop = position.top; + return position.centered && stableSamples >= 3; + }, + { intervals: [100] } + ) + .toBe(true); +} + async function refreshFromWorkspaceHeader(page: Page): Promise { await page .getByRole('button', { name: 'Refresh playlist', exact: true }) diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index 82b908554..37877570c 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -84,6 +84,15 @@ function generatePortalData(username: string, password: string): PortalData { }; } let liveCategories = generateCategories('live', categoryCount.live); + if (scenario.categoryFixture === 'scroll') { + liveCategories = liveCategories.map((category, index) => ({ + ...category, + // Coprime step permutes names independently of provider/SQLite IDs. + category_name: `${index % 4 === 0 ? 'Visible' : 'Hidden'} ${String( + (index * 137) % categoryCount.live + ).padStart(3, '0')}`, + })); + } const vodCategories = generateCategories('vod', categoryCount.vod); const seriesCategories = generateCategories('series', categoryCount.series); const epgListingsByStreamId = new Map(); diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 4470cda72..1ab1949a0 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -13,6 +13,8 @@ export interface ScenarioConfig { expiryDate: string; /** Optional deterministic EPG fixture profile for scenario-specific tests. */ epgFixture?: 'timezone-focus'; + /** Large, deliberately reordered categories for sidebar scroll coverage. */ + categoryFixture?: 'scroll'; /** * Optional `server_info` clock override. `timezone` is reported * verbatim (real panels sometimes send spellings such as `UTC+3` that @@ -54,6 +56,18 @@ export interface ScenarioConfig { * Unknown credential pairs use a hash of "username:password" as seed. */ export const SCENARIOS: Record = { + 'category-scroll:category-scroll': { + name: 'category-scroll', + description: '800 live categories, including 200 marked Visible', + seed: 800, + categoryCount: { live: 800, vod: 0, series: 0 }, + itemsPerCategory: 1, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + categoryFixture: 'scroll', + }, 'live-fallback:live-fallback': { name: 'live-format-fallback', description: 'Local HLS failures and playable TS', diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index 8a863b44d..d22e0d4a7 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -87,6 +87,11 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0 - **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database - **Display order**: The sidebar defaults to server order. Users can switch the category panel to `A-Z` or `Z-A` from the sort menu next to category search. +- **Selection scrolling**: The panel centers the rendered selected row after + selection changes. Electron selects by local SQLite category ID; the row's + `data-category-id` can contain its provider ID. Those IDs are not + interchangeable when locating the scroll target, including after filtering + hidden categories or sorting. - **All-hidden recovery**: Once the selected Xtream type is loaded, the manage categories button remains available even if every visible category has been hidden. The sidebar category list is filtered, but the dialog reads all diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts index 72e1b2ccd..bd7b117cd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts @@ -61,7 +61,7 @@ export class WorkspaceContextCategoryViewComponent { } } - private readonly hostEl = inject(ElementRef); + private readonly hostEl = inject>(ElementRef); readonly categoryClicked = output(); @@ -74,12 +74,11 @@ export class WorkspaceContextCategoryViewComponent { queueMicrotask(() => { const container = this.hostEl.nativeElement; - const candidates = Array.from( - container.querySelectorAll('[data-category-id]') - ) as HTMLElement[]; - const selected = candidates.find( - (el) => - el.dataset['categoryId'] === String(selectedCategory) + // Follow the rendered selection: Electron selects by SQLite + // ID, while data-category-id can contain a provider ID that + // coincides with a different row's SQLite ID. + const selected = container.querySelector( + '.category-item[aria-current="true"]' ); if (!selected) { return; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 936fb565c..4f7741dfe 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -62,7 +62,9 @@ function getCategoryLabels( describe('WorkspaceContextPanelComponent', () => { let fixture: ComponentFixture; - const xtreamCategories = signal([ + const xtreamCategories = signal< + Array<{ id: number; name: string; xtream_id?: number }> + >([ { id: 1, name: 'News' }, { id: 2, name: 'Sports' }, ]); @@ -323,6 +325,59 @@ describe('WorkspaceContextPanelComponent', () => { ); }); + it.each([ + { id: 7, name: 'Alpha', top: 520 }, + { id: 8, name: 'Zulu', top: 2420 }, + ])( + 'scrolls to the selected local category $name when provider IDs collide after sorting', + async ({ id, name, top }) => { + fixture.componentRef.setInput('section', 'live'); + xtreamSelectedTypeContentState.set('ready'); + // SQLite IDs drive selection; provider IDs can belong to another row. + xtreamCategories.set([ + { id: 8, xtream_id: 7, name: 'Zulu' }, + { id: 50, xtream_id: 500, name: 'Middle' }, + { id: 7, xtream_id: 8, name: 'Alpha' }, + ]); + fixture.componentInstance.setCategorySortMode('name-asc'); + fixture.detectChanges(); + + const container = fixture.nativeElement.querySelector( + 'app-workspace-context-category-view' + ) as HTMLElement; + Object.defineProperties(container, { + clientHeight: { value: 400 }, + scrollHeight: { value: 3000 }, + }); + container.scrollTop = 500; + container.getBoundingClientRect = () => + new DOMRect(0, 100, 200, 400); + container.scrollTo = jest.fn(); + const rows = Array.from( + container.querySelectorAll('.category-item') + ); + rows.forEach((row, index) => { + row.getBoundingClientRect = () => + new DOMRect(0, [300, 800, 2200][index], 200, 40); + }); + const selected = rows.find((row) => + row.textContent?.includes(name) + ); + if (!selected) throw new Error(`Missing category ${name}`); + selected.click(); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(id); + xtreamSelectedCategoryId.set(id); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(selected.getAttribute('aria-current')).toBe('true'); + expect(container.scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top, + }); + } + ); + it('uses translated category sort labels and distinct mode icons', () => { fixture.componentRef.setInput('section', 'vod'); xtreamSelectedTypeContentState.set('ready');