diff --git a/.changes/ui-narrow-channel-epg.md b/.changes/ui-narrow-channel-epg.md new file mode 100644 index 000000000..52e05c173 --- /dev/null +++ b/.changes/ui-narrow-channel-epg.md @@ -0,0 +1,7 @@ +--- +type: fix +area: ui +--- + +Narrow EPG channel rows now keep the current programme, progress, and enabled +channel actions visible instead of dropping useful context. diff --git a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts index 043976e1b..55e220c3b 100644 --- a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts +++ b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts @@ -203,7 +203,7 @@ export async function fetchXtreamEpgFixture( export async function fetchStalkerCategoryFixture( request: APIRequestContext, - type: 'itv' | 'series' | 'vod' + type: 'itv' | 'radio' | 'series' | 'vod' ): Promise { const categoriesResponse = await fetchJson< StalkerProxyPayload diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 979323fbb..f65926039 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -1,4 +1,6 @@ +import type { Locator } from '@playwright/test'; import { + addStalkerPortal, addXtreamPortal, channelItemByTitle, clickCategoryByNameExact, @@ -11,9 +13,13 @@ import { resetMockServers, saveSettings, test, + waitForStalkerCatalog, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; -import { fetchXtreamEpgFixture } from './portal-mock-fixtures'; +import { + fetchStalkerCategoryFixture, + fetchXtreamEpgFixture, +} from './portal-mock-fixtures'; const epgPortalName = 'Xtream EPG Fixture'; const epgCredentials = { @@ -124,6 +130,160 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { }); } +test('@epg @xtream @electron keeps EPG context and actions at narrow channel-row widths', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const currentProgram = fixture.shortEpg[0]; + if (!currentProgram) { + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); + } + const app = await launchElectronApp(dataDir, { + env: { TZ: 'UTC' }, + }); + + try { + await addXtreamPortal(app.mainWindow, { + name: `${epgPortalName} Narrow`, + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + + const currentProgramRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + const noProgramRow = channelItemByTitle( + app.mainWindow, + 'Night Sports' + ).first(); + await expect(currentProgramRow).toBeVisible({ timeout: 20000 }); + await expect(noProgramRow).toBeVisible({ timeout: 20000 }); + + const currentTitle = currentProgramRow.locator('.epg-title'); + const progressTrack = currentProgramRow.locator('.epg-progress-track'); + const favoriteAction = currentProgramRow.locator('.favorite-button'); + const placeholder = noProgramRow.locator('.epg-placeholder'); + const logo = currentProgramRow.locator('.channel-logo-shell'); + const timeLabels = currentProgramRow.locator('.epg-time'); + + await setPortalChannelItemWidth(app.mainWindow, 300); + + await expect(currentTitle).toHaveText(currentProgram.title); + await expect(currentTitle).toBeVisible(); + await expect(progressTrack).toBeVisible(); + await expect(favoriteAction).toBeVisible(); + await expect(placeholder).toBeVisible(); + await expect(logo).toBeVisible(); + await expect(timeLabels).toHaveCount(2); + await expect(timeLabels.first()).toBeVisible(); + await expect(timeLabels.last()).toBeHidden(); + await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports'); + await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema'); + await expectTimelineStartAndProgressAligned(currentProgramRow); + await expectNarrowRowContentFits(currentProgramRow); + await expectNarrowRowContentFits(noProgramRow); + + await setPortalChannelItemWidth(app.mainWindow, 232); + + await expect(currentTitle).toHaveText(currentProgram.title); + await expect(currentTitle).toBeVisible(); + await expect(progressTrack).toBeVisible(); + await expect(favoriteAction).toBeVisible(); + await expect(placeholder).toBeVisible(); + await expect(logo).toBeHidden(); + await expect(timeLabels).toHaveCount(2); + await expect(timeLabels.first()).toBeVisible(); + await expect(timeLabels.last()).toBeHidden(); + await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports'); + await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema'); + await expectTimelineStartAndProgressAligned(currentProgramRow); + await expectNarrowRowContentFits(currentProgramRow); + await expectNarrowRowContentFits(noProgramRow); + + await setPortalChannelItemWidth(app.mainWindow, 200); + + await expect(currentTitle).toHaveText(currentProgram.title); + await expect(currentTitle).toBeVisible(); + await expect(progressTrack).toBeVisible(); + await expect(favoriteAction).toBeVisible(); + await expect(placeholder).toBeVisible(); + await expect(logo).toBeHidden(); + await expect(timeLabels.first()).toBeHidden(); + await expect(timeLabels.last()).toBeHidden(); + await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports'); + await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema'); + await expectNarrowRowContentFits(currentProgramRow); + await expectNarrowRowContentFits(noProgramRow); + } finally { + await closeElectronApp(app); + } +}); + +test('@radio @stalker @electron keeps radio rows compact at narrow widths', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['stalker']); + const fixture = await fetchStalkerCategoryFixture(request, 'radio'); + const [firstItem, secondItem] = fixture.items; + const firstTitle = firstItem?.o_name || firstItem?.name; + const secondTitle = secondItem?.o_name || secondItem?.name; + if (!firstTitle || !secondTitle) { + throw new Error( + 'Expected the Stalker radio fixture to include two named stations.' + ); + } + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow, { + name: 'Stalker Radio Row Fixture', + }); + await waitForStalkerCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Radio'); + const categoryButton = app.mainWindow.getByRole('button', { + name: fixture.categoryName, + exact: true, + }); + await expect(categoryButton).toBeVisible(); + await categoryButton.click(); + + const radioRow = channelItemByTitle( + app.mainWindow, + firstTitle + ).first(); + await expect(radioRow).toBeVisible({ timeout: 20000 }); + await expect(radioRow).toHaveClass(/compact/); + await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0); + await expect(radioRow.locator('.epg-title')).toHaveCount(0); + await expect(radioRow.locator('.epg-timeline')).toHaveCount(0); + await expectCompactRadioRowHeightAndStride(radioRow, secondTitle); + + await setStalkerChannelItemWidth(app.mainWindow, 232); + + await expect(radioRow.locator('.channel-logo-shell')).toBeVisible(); + await expect(radioRow.locator('.favorite-button')).toBeVisible(); + await expectCompactRadioRowHeightAndStride(radioRow, secondTitle); + + await setStalkerChannelItemWidth(app.mainWindow, 200); + + await expect(radioRow.locator('.channel-name')).toBeVisible(); + await expect(radioRow.locator('.channel-logo-shell')).toBeHidden(); + await expect(radioRow.locator('.favorite-button')).toBeHidden(); + await expectCompactRadioRowHeightAndStride(radioRow, secondTitle); + } finally { + await closeElectronApp(app); + } +}); + test('@epg @xtream @electron renders the vertical list view when the setting is "list"', async ({ dataDir, request, @@ -167,9 +327,7 @@ test('@epg @xtream @electron renders the vertical list view when the setting is await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({ timeout: 20000, }); - await expect( - app.mainWindow.locator('app-epg-timeline') - ).toHaveCount(0); + await expect(app.mainWindow.locator('app-epg-timeline')).toHaveCount(0); // The on-air programme is the highlighted "now" row. await expect( @@ -191,6 +349,238 @@ function formatTimeInZone(timestampSeconds: number, timeZone: string): string { }).format(new Date(timestampSeconds * 1000)); } +async function setPortalChannelItemWidth( + page: Parameters[0], + width: number +) { + const channelItems = page.locator( + 'app-portal-channels-list app-channel-list-item' + ); + await channelItems.evaluateAll((elements, itemWidth) => { + for (const element of elements) { + (element as HTMLElement).style.width = `${itemWidth}px`; + } + }, width); + await expect + .poll(() => + channelItems + .first() + .evaluate((element) => + Math.round(element.getBoundingClientRect().width) + ) + ) + .toBe(width); +} + +async function setStalkerChannelItemWidth( + page: Parameters[0], + width: number +) { + const channelItems = page.locator( + 'app-stalker-live-stream-layout app-channel-list-item' + ); + await channelItems.evaluateAll((elements, itemWidth) => { + for (const element of elements) { + (element as HTMLElement).style.width = `${itemWidth}px`; + } + }, width); + await expect + .poll(() => + channelItems + .first() + .evaluate((element) => + Math.round(element.getBoundingClientRect().width) + ) + ) + .toBe(width); +} + +const portalChannelItemSize = 68; +const compactRadioItemSize = 52; +const stalkerChannelItemGap = 2; +const rowGeometryTolerance = 0.25; +const contentGeometryTolerance = 1; + +type ElementBox = { + x: number; + y: number; + width: number; + height: number; +}; + +async function expectPortalRowHeightAndStride( + row: ReturnType, + expectedFollowingTitle: string +) { + const followingRow = row + .locator('xpath=ancestor::app-channel-list-item') + .locator('xpath=following-sibling::app-channel-list-item[1]') + .getByTestId('channel-item'); + await expect(followingRow.locator('.channel-name')).toHaveText( + expectedFollowingTitle + ); + + const [rowBox, followingRowBox] = await Promise.all([ + row.boundingBox(), + followingRow.boundingBox(), + ]); + if (!rowBox || !followingRowBox) { + throw new Error( + `Expected visible channel-row geometry before "${expectedFollowingTitle}" for the virtual-stride assertion.` + ); + } + + expect(Math.abs(rowBox.height - portalChannelItemSize)).toBeLessThanOrEqual( + rowGeometryTolerance + ); + expect( + Math.abs(followingRowBox.y - rowBox.y - portalChannelItemSize) + ).toBeLessThanOrEqual(rowGeometryTolerance); +} + +async function expectCompactRadioRowHeightAndStride( + row: ReturnType, + expectedFollowingTitle: string +) { + const followingRow = row + .locator('xpath=ancestor::app-channel-list-item') + .locator('xpath=following-sibling::app-channel-list-item[1]') + .getByTestId('channel-item'); + await expect(followingRow.locator('.channel-name')).toHaveText( + expectedFollowingTitle + ); + + const [rowBox, followingRowBox] = await Promise.all([ + row.boundingBox(), + followingRow.boundingBox(), + ]); + if (!rowBox || !followingRowBox) { + throw new Error( + `Expected compact radio-row geometry before "${expectedFollowingTitle}".` + ); + } + + expect(Math.abs(rowBox.height - compactRadioItemSize)).toBeLessThanOrEqual( + rowGeometryTolerance + ); + expect( + Math.abs( + followingRowBox.y - + rowBox.y - + compactRadioItemSize - + stalkerChannelItemGap + ) + ).toBeLessThanOrEqual(rowGeometryTolerance); +} + +async function expectTimelineStartAndProgressAligned( + row: ReturnType +) { + const [startTimeBox, progressBox] = await Promise.all([ + row.locator('.epg-time').first().boundingBox(), + row.locator('.epg-progress-track').first().boundingBox(), + ]); + if (!startTimeBox || !progressBox) { + throw new Error( + 'Expected visible start-time and progress geometry in the narrow channel row.' + ); + } + + expect( + Math.abs(boxCenterY(startTimeBox) - boxCenterY(progressBox)) + ).toBeLessThanOrEqual(contentGeometryTolerance); +} + +async function expectNarrowRowContentFits( + row: ReturnType +) { + const [rowBox, detailsBox, actionsBox] = await Promise.all([ + row.boundingBox(), + row.locator('.channel-details').boundingBox(), + row.locator('.action-buttons').boundingBox(), + ]); + + if (!rowBox || !detailsBox || !actionsBox) { + throw new Error( + 'Expected visible narrow channel-row, details, and action geometry.' + ); + } + + expectBoxContainedWithin(detailsBox, rowBox); + expectBoxContainedWithin(actionsBox, rowBox); + expect(boxRight(detailsBox)).toBeLessThanOrEqual( + actionsBox.x + contentGeometryTolerance + ); + await expectVisibleElementsContainedWithin( + row.locator( + '.channel-details > .channel-name:visible, .channel-details > .epg-title:visible, .channel-details > .epg-placeholder:visible, .channel-details > .epg-timeline:visible' + ), + detailsBox, + 'channel-details child' + ); + await expectVisibleElementsContainedWithin( + row.locator('.action-buttons > button:visible'), + actionsBox, + 'channel action button' + ); + + const progressTrack = row.locator('.epg-progress-track'); + if ((await progressTrack.count()) > 0) { + const progressBox = await progressTrack.first().boundingBox(); + if (!progressBox) { + throw new Error( + 'Expected visible progress-track geometry in the narrow channel row.' + ); + } + expectBoxContainedWithin(progressBox, detailsBox); + expect(progressBox.width).toBeGreaterThanOrEqual(24); + } +} + +async function expectVisibleElementsContainedWithin( + elements: Locator, + outer: ElementBox, + description: string +) { + const count = await elements.count(); + if (count === 0) { + throw new Error(`Expected at least one visible ${description}.`); + } + + for (let index = 0; index < count; index += 1) { + const elementBox = await elements.nth(index).boundingBox(); + if (!elementBox) { + throw new Error( + `Expected visible ${description} geometry at index ${index}.` + ); + } + expectBoxContainedWithin(elementBox, outer); + } +} + +function expectBoxContainedWithin(inner: ElementBox, outer: ElementBox) { + expect(inner.x).toBeGreaterThanOrEqual(outer.x - contentGeometryTolerance); + expect(inner.y).toBeGreaterThanOrEqual(outer.y - contentGeometryTolerance); + expect(boxRight(inner)).toBeLessThanOrEqual( + boxRight(outer) + contentGeometryTolerance + ); + expect(boxBottom(inner)).toBeLessThanOrEqual( + boxBottom(outer) + contentGeometryTolerance + ); +} + +function boxRight(box: ElementBox) { + return box.x + box.width; +} + +function boxBottom(box: ElementBox) { + return box.y + box.height; +} + +function boxCenterY(box: ElementBox) { + return box.y + box.height / 2; +} + async function timelineBlockTitles( page: Parameters[0] ) { diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index 409cafa94..82b908554 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -296,6 +296,8 @@ function buildTimezoneFixture(): Pick< const epgListingsByStreamId = new Map(); epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000)); + epgListingsByStreamId.set(10_001, []); + epgListingsByStreamId.set(10_002, []); return { liveCategories, diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index a4e899137..9e26995b1 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -6,9 +6,37 @@ import { } from './server.js'; import { startLoopbackServer } from './testing/http-server.fixture.js'; -jest.mock('@faker-js/faker', () => ({ - faker: { seed: jest.fn() }, -})); +jest.mock('@faker-js/faker', () => { + const fixedDate = new Date('2020-01-01T00:00:00.000Z'); + const fixedText = 'Fixture value'; + return { + faker: { + seed: jest.fn(), + company: { + catchPhrase: () => fixedText, + name: () => fixedText, + }, + date: { + past: () => fixedDate, + recent: () => fixedDate, + }, + location: { country: () => fixedText }, + lorem: { + paragraph: () => fixedText, + sentence: () => fixedText, + words: () => fixedText, + }, + music: { + genre: () => fixedText, + songName: () => fixedText, + }, + number: { + int: ({ min = 0 }: { min?: number }) => min, + }, + person: { fullName: () => fixedText }, + }, + }; +}); jest.setTimeout(60_000); @@ -89,6 +117,26 @@ describe('Xtream mock server factory', () => { } }); + it('keeps a non-EPG timezone stream empty across repeated short-EPG requests', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + const requestUrl = `${running.origin}/player_api.php?username=epg&password=epg&action=get_short_epg&stream_id=10001`; + try { + const firstResponse = await fetch(requestUrl).then((response) => + response.json() + ); + const secondResponse = await fetch(requestUrl).then((response) => + response.json() + ); + + expect(firstResponse.epg_listings).toHaveLength(0); + expect(secondResponse.epg_listings).toHaveLength(0); + } finally { + await running.close(); + } + }); + it('rejects non-loopback performance binds before opening a listener', () => { expect(() => createXtreamMockApp({ diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 5b9be58a7..e0082a268 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -156,6 +156,27 @@ minimum dimensions and flexible columns over fixed row widths. start time, progress bar, end time - Action buttons sit on the trailing edge and inherit row color +### Responsive Information Priority + +- EPG-enabled, noncompact rows keep a fixed `68px` height that matches the + virtual-scroll stride. EPG-disabled, compact rows use a matching fixed `52px` + row and virtual-scroll size. +- At `310px` and below, hide the end time while keeping the start time and + progress bar. +- At `270px` and below, hide the decorative logo while retaining program + context and actions, and tighten horizontal padding to preserve the remaining + content. +- At `220px` and below, hide the start time while keeping the progress bar. +- In EPG-preview rows, narrow width alone must not remove the channel name, + program title or no-program placeholder, progress bar, drag affordance when + applicable, or enabled actions. +- Radio consumers without EPG render the row as compact instead of showing a + false no-program placeholder. Compact rows keep the logo at `270px`, then + hide the logo and actions at `220px`. +- `isRadio` alone must not change row height inside a fixed-size mixed virtual + list; the consumer's `showEpg` state and virtual-scroll item size own density. +- Loading skeletons mirror the same responsive hierarchy and row geometry. + ### Logo Rules - Show fallback icon only when no image is available or image loading fails diff --git a/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md b/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md new file mode 100644 index 000000000..273600f1d --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md @@ -0,0 +1,496 @@ +# Preserve EPG Preview in Narrow Channel Lists Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep current-program context and enabled channel actions visible when a shared channel row is narrowed to the supported Live TV sidebar widths. + +**Architecture:** Preserve the shared `app-channel-list-item` API and virtual-scroll height. Change only its container-query degradation order and the matching skeleton geometry, with one deterministic Electron regression that forces item hosts to representative widths independently of scrollbar behavior. + +**Tech Stack:** Angular standalone components, SCSS container queries, Playwright Electron E2E, Nx, Prettier. + +--- + +### Task 1: Add the failing narrow-row Electron regression + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/xtream-epg.e2e.ts` + +- [ ] **Step 1: Add a dedicated narrow-width EPG test** + +Add one test outside the timezone loop so the responsive contract is exercised +once rather than duplicated for each timezone. Reuse the existing fictional +`epg/epg` portal, select `EPG Focus`, and use `Timezone News` for a current +programme plus `Night Sports` for the no-program placeholder. + +```ts +test('@epg @xtream @electron keeps EPG context and actions at narrow channel-row widths', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const currentProgram = fixture.shortEpg[0]; + if (!currentProgram) { + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); + } + const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } }); + + try { + await addXtreamPortal(app.mainWindow, { + name: `${epgPortalName} Narrow`, + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + + const currentRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + const placeholderRow = channelItemByTitle( + app.mainWindow, + 'Night Sports' + ).first(); + await expect(currentRow).toBeVisible({ timeout: 20000 }); + await expect(placeholderRow).toBeVisible(); + + await setPortalChannelItemWidth(app.mainWindow, 232); + + await expect(currentRow.locator('.epg-title')).toHaveText( + currentProgram.title + ); + await expect(currentRow.locator('.epg-progress-track')).toBeVisible(); + await expect(currentRow.locator('.favorite-button')).toBeVisible(); + await expect(placeholderRow.locator('.epg-placeholder')).toBeVisible(); + await expect(currentRow.locator('.channel-logo-shell')).toBeHidden(); + await expect(currentRow.locator('.epg-time').first()).toBeVisible(); + await expect(currentRow.locator('.epg-time').last()).toBeHidden(); + await expect(currentRow).toHaveCSS('min-height', '68px'); + await expectNarrowRowContentFits(currentRow); + await expectNarrowRowContentFits(placeholderRow); + + await setPortalChannelItemWidth(app.mainWindow, 200); + + await expect(currentRow.locator('.epg-title')).toBeVisible(); + await expect(currentRow.locator('.epg-progress-track')).toBeVisible(); + await expect(currentRow.locator('.favorite-button')).toBeVisible(); + await expect(placeholderRow.locator('.epg-placeholder')).toBeVisible(); + await expect(currentRow.locator('.epg-time').first()).toBeHidden(); + await expect(currentRow.locator('.epg-time').last()).toBeHidden(); + await expect(currentRow).toHaveCSS('min-height', '68px'); + await expectNarrowRowContentFits(currentRow); + await expectNarrowRowContentFits(placeholderRow); + } finally { + await closeElectronApp(app); + } +}); +``` + +- [ ] **Step 2: Add the deterministic host-width helper** + +Place the helper with the existing E2E-local utilities: + +```ts +async function setPortalChannelItemWidth( + page: Parameters[0], + width: number +): Promise { + const itemHosts = page.locator( + 'app-portal-channels-list app-channel-list-item' + ); + await itemHosts.evaluateAll((elements, itemWidth) => { + for (const element of elements) { + (element as HTMLElement).style.width = `${itemWidth}px`; + } + }, width); + await expect + .poll(() => + itemHosts + .first() + .evaluate((element) => + Math.round(element.getBoundingClientRect().width) + ) + ) + .toBe(width); +} + +async function expectNarrowRowContentFits( + row: ReturnType +): Promise { + const rowBox = await row.boundingBox(); + const detailsBox = await row.locator('.channel-details').boundingBox(); + const actionsBox = await row.locator('.action-buttons').boundingBox(); + if (!rowBox || !detailsBox || !actionsBox) { + throw new Error('Expected visible narrow-row geometry.'); + } + + expect(detailsBox.x).toBeGreaterThanOrEqual(rowBox.x); + expect(detailsBox.x + detailsBox.width).toBeLessThanOrEqual( + actionsBox.x + 0.5 + ); + expect(actionsBox.x + actionsBox.width).toBeLessThanOrEqual( + rowBox.x + rowBox.width + 0.5 + ); + + const progressTrack = row.locator('.epg-progress-track'); + if ((await progressTrack.count()) > 0) { + const progressBox = await progressTrack.boundingBox(); + expect(progressBox?.width ?? 0).toBeGreaterThanOrEqual(24); + } +} +``` + +- [ ] **Step 3: Run the focused E2E and prove the old behavior fails** + +Run: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-epg.e2e.ts --skip-nx-cache +``` + +Expected: the new test fails at `232px` because the current `max-width: 270px` +rule hides the programme title, timeline, and placeholder, switches the EPG row +to the compact height, and leaves the logo visible. At `200px`, the current +rule also hides the complete action group. Existing EPG tests should remain +green before the new assertions are reached. + +### Task 2: Implement the shared responsive contract + +**Files:** + +- Modify: `libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss:241` +- Modify: `libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss:89` +- Verify: `apps/electron-backend-e2e/src/xtream-epg.e2e.ts` + +- [ ] **Step 1: Keep timing horizontal below 310px** + +Replace the one-column timeline at `max-width: 310px` with a start-time and +flexible-progress row while continuing to hide the end time: + +```scss +@container (max-width: 310px) { + .epg-timeline { + grid-template-columns: auto minmax(24px, 1fr); + gap: 5px; + } + + .epg-time:last-child { + display: none; + } +} +``` + +- [ ] **Step 2: Preserve EPG and actions below 270px** + +Replace the current `max-width: 270px` degradation with: + +```scss +@container (max-width: 270px) { + .channel-list-item { + gap: 8px; + padding-inline: 8px 6px; + } + + .channel-list-item:not(.compact) .channel-logo-shell { + display: none; + } + + .channel-list-item:not(.compact) .channel-content { + gap: 0; + } + + .channel-list-item.compact .channel-logo-shell { + width: 34px; + height: 34px; + } +} +``` + +Do not set the row to `52px`; EPG rows inherit the base `68px` minimum. Do not +hide `.epg-title`, `.epg-timeline`, `.epg-placeholder`, +`.program-info-button`, or `.action-buttons`. + +- [ ] **Step 3: Remove only the start time below 220px** + +Replace the current `max-width: 220px` action-hiding rule with: + +```scss +@container (max-width: 220px) { + .channel-list-item:not(.compact) .epg-timeline { + grid-template-columns: minmax(24px, 1fr); + } + + .channel-list-item:not(.compact) .epg-time:first-child { + display: none; + } + + .channel-list-item.compact { + .channel-logo-shell, + .action-buttons { + display: none; + } + + .channel-content { + gap: 0; + } + } +} +``` + +The end time is already hidden by the wider breakpoint. Channel name, current +programme or placeholder, progress, drag affordance, and every enabled action +remain available in EPG rows. The `.compact` branch deliberately preserves the +existing non-EPG degradation; radio-only consumers without EPG opt into that +branch through `showEpg=false`, rather than changing mixed-list height through +`isRadio`. + +- [ ] **Step 4: Match skeleton degradation to the live row** + +Keep the existing `360px` tightening. Replace the narrower skeleton rules so +they hide only the logo and its gap: + +```scss +@container (max-width: 270px) { + .channel-list-item-skeleton { + gap: 8px; + padding-inline: 8px 6px; + } + + .channel-list-item-skeleton:not(.compact) .channel-logo-skeleton { + display: none; + } + + .channel-list-item-skeleton:not(.compact) .channel-content-skeleton { + gap: 0; + } + + .channel-list-item-skeleton.compact .channel-logo-skeleton { + width: 34px; + height: 34px; + } +} +``` + +Scope the skeleton's existing `max-width: 220px` behavior to compact rows: + +```scss +@container (max-width: 220px) { + .channel-list-item-skeleton.compact { + .channel-logo-skeleton, + .action-buttons-skeleton { + display: none; + } + + .channel-content-skeleton { + gap: 0; + } + } +} +``` + +The EPG title, progress, action slots, and `68px` minimum height must remain +stable at every supported narrow width, while compact skeletons preserve their +current behavior. + +- [ ] **Step 5: Run focused and component validation** + +Run: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-epg.e2e.ts --skip-nx-cache +pnpm nx test components --skip-nx-cache +pnpm nx lint components --skip-nx-cache +pnpm nx lint electron-backend-e2e --skip-nx-cache +``` + +Expected: all commands pass. The Electron test proves the old narrow-width +failure is fixed; component tests protect unchanged rendering and interactions. + +- [ ] **Step 6: Commit the regression and implementation** + +```bash +git add \ + apps/electron-backend-e2e/src/xtream-epg.e2e.ts \ + libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss \ + libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss +git commit -m "fix(ui): preserve EPG in narrow channel rows" +``` + +### Task 3: Document the contract and add the release note + +**Files:** + +- Modify: `docs/architecture/iptvnator-ui-guidelines.md:111` +- Create: `.changes/ui-narrow-channel-epg.md` + +- [ ] **Step 1: Add the canonical responsive priority** + +Under `## Channel List Item`, add a `### Responsive Information Priority` +section after `### Content Layout`: + +```md +### Responsive Information Priority + +- Keep EPG rows at `68px`; only rows without EPG use the `52px` compact height. +- At `310px` and below, hide the programme end time and retain the start time + plus progress on one row. +- At `270px` and below, hide the decorative logo before hiding programme + context or actions. +- At `220px` and below, hide the programme start time and retain progress. +- Never remove the channel name, current-programme title or no-program + placeholder, progress, drag affordance, or an enabled row action merely + because the shared item is narrow. +- Keep the skeleton geometry aligned with the loaded row. +``` + +- [ ] **Step 2: Add a user-facing fix note** + +Create `.changes/ui-narrow-channel-epg.md`: + +```md +--- +type: fix +area: ui +--- + +Narrow channel lists now keep the current programme, progress, and channel +actions visible instead of dropping useful EPG context. +``` + +Do not list issue `#1118` as closed because this PR intentionally implements +only its first increment. + +- [ ] **Step 3: Validate formatting and release-note metadata** + +Run: + +```bash +pnpm exec prettier --check \ + docs/architecture/iptvnator-ui-guidelines.md \ + .changes/ui-narrow-channel-epg.md +pnpm run release:notes:validate +git diff --check +``` + +Expected: all commands pass. + +- [ ] **Step 4: Commit documentation and release metadata** + +```bash +git add \ + docs/architecture/iptvnator-ui-guidelines.md \ + .changes/ui-narrow-channel-epg.md +git commit -m "docs(ui): document narrow channel row priority" +``` + +### Task 4: Run final automated and Electron UI verification + +**Files:** + +- Verify all files changed in Tasks 1–3. +- Do not add generated screenshots or a real playlist to the repository. + +- [ ] **Step 1: Run the complete affected validation ladder** + +Run: + +```bash +pnpm nx test components --skip-nx-cache +pnpm nx lint components --skip-nx-cache +pnpm nx lint electron-backend-e2e --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-epg.e2e.ts --skip-nx-cache +pnpm nx build web --skip-nx-cache +pnpm run release:notes:validate +pnpm exec prettier --check \ + apps/electron-backend-e2e/src/xtream-epg.e2e.ts \ + libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss \ + libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss \ + docs/architecture/iptvnator-ui-guidelines.md \ + .changes/ui-narrow-channel-epg.md +git diff --check +``` + +Expected: every command exits successfully. + +- [ ] **Step 2: Build and launch an isolated Electron runtime** + +Build once: + +```bash +pnpm nx run electron-backend:build-e2e +``` + +Start the mock servers in a dedicated terminal: + +```bash +pnpm nx run-many \ + --target=serve \ + --projects=xtream-mock-server,stalker-mock-server \ + --parallel=2 \ + --output-style=stream +``` + +Create a disposable profile and launch the built app with CDP: + +```bash +IPTVNATOR_VISUAL_DATA_DIR="$(mktemp -d /tmp/iptvnator-sidebar.XXXXXX)" +IPTVNATOR_E2E_DATA_DIR="$IPTVNATOR_VISUAL_DATA_DIR" \ +IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1 \ +ELECTRON_IS_DEV=0 \ +NODE_ENV=test \ +TZ=UTC \ +./node_modules/.bin/electron \ + --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port=9222 \ + dist/apps/electron-backend/main.js +``` + +- [ ] **Step 3: Verify real layout behavior with `agent-browser`** + +Connect to the IPTVnator renderer, not a DevTools target: + +```bash +agent-browser --cdp 9222 tab list +agent-browser --cdp 9222 snapshot -i -c -d 4 +``` + +Using only mock credentials, inspect Xtream `EPG Focus → Timezone News` at the +persisted `live-channels-sidebar-width=250`. Confirm in computed layout and a +temporary screenshot that: + +- the programme title and progress are visible; +- the end time and logo are hidden at the resulting `~228–234px` item width; +- the favorite action is visible and not clipped; +- the row remains `68px` high; +- `Night Sports` shows the no-program placeholder without overlap. + +Then add the local Stalker mock portal at +`http://localhost:3210/portal.php` with MAC `00:1A:79:00:00:01` and inspect its +Live surface at the `250px` sidebar minimum. This proves a second provider +using the shared row has the same geometry. Save screenshots only under +`/tmp`, for example: + +```bash +agent-browser --cdp 9222 screenshot /tmp/iptvnator-narrow-epg-xtream.png +agent-browser --cdp 9222 screenshot /tmp/iptvnator-narrow-epg-stalker.png +``` + +- [ ] **Step 4: Check repository scope and hand off** + +Run: + +```bash +git status --short +git diff origin/master...HEAD --stat +git log --oneline origin/master..HEAD +``` + +Expected: the branch contains only the approved design, regression, shared-row +SCSS, canonical guideline, release note, and this implementation plan. No +`.superpowers/` visual artifacts, screenshots, real playlist data, credentials, +or unrelated changes are staged or committed. diff --git a/docs/superpowers/specs/2026-07-29-responsive-channel-epg-preview-design.md b/docs/superpowers/specs/2026-07-29-responsive-channel-epg-preview-design.md new file mode 100644 index 000000000..3549d402c --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-responsive-channel-epg-preview-design.md @@ -0,0 +1,122 @@ +# Preserve EPG Preview in Narrow Channel Lists + +## Context + +Xtream and Stalker Live TV allow the Channels sidebar to be resized down to +`250px`. The list viewport then leaves roughly `228–234px` for each shared +`app-channel-list-item` after padding and the scrollbar. + +The shared row currently hides the programme title, progress bar, no-program +placeholder, and programme-info action at a container width of `270px` or less. +The supported minimum sidebar width therefore removes the most useful browsing +context by design. + +This is the first increment of issue #1118. It fixes the information hierarchy +inside the shared row without changing sidebar widths, persistence, panel +ownership, or playback layout. + +## Product Decision + +Keep the existing `250px` minimum sidebar width. A wider minimum would protect +the row by taking space away from the player or guide, which is the wrong +tradeoff for a three-region Live TV layout. + +Instead, degrade the row in this order: + +1. Reduce spacing and logo size. +2. Hide the programme end time. +3. Hide the decorative channel logo. +4. Hide the programme start time at the narrowest supported widths. + +The channel name, current programme title, no-program placeholder, and progress +bar remain available at every supported width. Enabled favorite, +programme-info, remove, and drag actions also remain available; narrowing a +layout must not remove the only touch or keyboard path to an action. + +## Responsive Contract + +| Item container width | Required behavior | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Above `360px` | Show the full row: logo, programme title, start/end times, progress, and enabled actions. | +| `360px` and below | Tighten spacing and the logo while retaining all information. | +| `310px` and below | Hide the programme end time. Keep the start time and progress on one horizontal line. | +| `270px` and below | Hide the channel logo and remove its inner gap. Keep channel name, programme title or placeholder, start time, progress, drag affordance, and all enabled trailing actions. | +| `220px` and below | Hide the programme start time, leaving progress-only timing. Keep channel name, programme title or placeholder, drag affordance, and all enabled trailing actions. | + +EPG rows remain `68px` high at every breakpoint so their content and the virtual +scroll item size agree. The existing `52px` compact height remains limited to +rows where EPG is disabled. The skeleton row must follow the same geometry so +loading content does not jump between incompatible layouts. + +## Shared-Consumer Scope + +`app-channel-list-item` is shared by Xtream, Stalker, M3U, favorites, recent, +and global channel lists. The contract therefore applies consistently wherever +the host becomes narrow. This PR does not add provider-specific overrides. + +Compact rows without EPG keep their existing behavior. Radio-only consumers +that do not expose EPG mark their rows compact; `isRadio` alone never changes +height inside a fixed-size mixed virtual list. The change does not add EPG data +where a consumer currently disables it. + +## Alternatives Considered + +### Increase the Live TV sidebar minimum + +Rejected for this increment. It masks the row bug but reduces the remaining +player/guide area and does not help other narrow consumers of the shared row. + +### Preserve every time label by increasing row height + +Rejected. Start and end times are secondary to the programme identity and +progress signal. Stacking all metadata makes browsing materially less dense. + +### Change the complete three-pane layout now + +Deferred. Independent Groups/Channels collapse and content-aware external +player space require different state ownership and broader playback testing. +Bundling them with a shared-row CSS fix would make review and rollback harder. + +## Accessibility and Interaction + +No interaction or focus semantics change in this increment. Existing favorite, +programme-info, auxiliary, remove, and drag actions remain available at +supported widths. A context menu or full guide is not treated as an equivalent +replacement for an explicit row action. + +Keyboard semantics for the clickable row and keyboard-accessible resizing are +separate follow-up work and must not be implied as fixed here. + +## Testing + +- Add a focused Electron E2E regression using the fictional Xtream EPG fixture. + Set the shared row host to a deterministic `232px` (the typical item width + inside the `250px` sidebar), then prove the programme title, no-program + placeholder, start time, progress, and enabled favorite action remain visible + while the end time and logo are hidden. Repeat at `200px` to prove the start + time becomes hidden without removing the programme identity, progress, or + action. This isolates the container-query contract from platform-specific + scrollbar width and storage timing. +- Keep the existing component tests for content rendering and run the + `components` test and lint targets. +- Run the focused Electron EPG E2E target. +- Use `agent-browser` over Electron CDP with the mock server to inspect the + real persisted `250px` minimum-width sidebar in the running app, capture a + screenshot, and check for clipping or overlap. Check at least one additional + shared/provider surface when deterministic fixture data is available. +- Run the web build and release-note validation. + +## Documentation and Release Notes + +Update `docs/architecture/iptvnator-ui-guidelines.md` with the responsive +information-priority contract. Add a user-facing fix note under `.changes/`. + +## Follow-up PRs + +1. Separate the persisted and effective collapse state for Groups and Channels, + with accessible restore controls and focus handling. +2. Make the right region content-aware: keep Guide when EPG exists, but remove + the empty external-player region when it has no useful content and let + Channels use a bounded wider layout. +3. Treat a first-class Guide/search entry as separate product discovery rather + than silently expanding issue #1118. diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index bd30b8c03..f4e33afdb 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -37,9 +37,15 @@ (sidebarToggleRequested)="toggleSidebar()" /> } @loading { - + } @placeholder { - + } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 5279ae96a..f0c7bba3b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -64,6 +64,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); }) class StubChannelListLoadingStateComponent { readonly view = input(null); + readonly showEpg = input(true); } @Component({ diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index b686dc97d..a79a63c55 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -36,7 +36,8 @@ [logo]="ch.logo" [name]="ch.name" [selected]="activeUid() === ch.uid" - [showEpg]="true" + [showEpg]="showEpg() && ch.radio !== 'true'" + [isRadio]="ch.radio === 'true'" [epgProgram]="ch.currentEpgProgram" [progressPercentage]="ch.progressPercentage" [showFavoriteButton]="true" diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index 3da53d630..a01b7075a 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -75,6 +75,33 @@ describe('GlobalFavoritesListComponent', () => { expect(icons).toEqual(['star_outline', 'star']); }); + it('renders radio rows as compact without a false EPG placeholder', () => { + fixture.componentRef.setInput('channels', [ + buildChannel('radio', 'Radio One', { radio: 'true' }), + ]); + fixture.detectChanges(); + + const row = fixture.nativeElement.querySelector('.channel-list-item'); + expect(row.classList.contains('compact')).toBe(true); + expect( + fixture.nativeElement.querySelector('.epg-placeholder') + ).toBeNull(); + }); + + it('renders non-radio rows as compact when the host disables EPG', () => { + fixture.componentRef.setInput('showEpg', false); + fixture.componentRef.setInput('channels', [ + buildChannel('pwa', 'PWA Channel'), + ]); + fixture.detectChanges(); + + const row = fixture.nativeElement.querySelector('.channel-list-item'); + expect(row.classList.contains('compact')).toBe(true); + expect( + fixture.nativeElement.querySelector('.epg-placeholder') + ).toBeNull(); + }); + it('preserves incoming recent order when a favorites sort mode is set', () => { fixture.componentRef.setInput('mode', 'recent'); fixture.componentRef.setInput('sortMode', 'name-asc'); diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index e3e0c25d9..9fe42c7c0 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -20,7 +20,10 @@ import { ChannelDetailsDialogComponent, ChannelListItemComponent, } from '@iptvnator/ui/components'; -import { SettingsStore } from '@iptvnator/services'; +import { + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { buildStalkerEpgMappingKey, buildXtreamEpgMappingKey, @@ -61,6 +64,7 @@ export class GlobalFavoritesListComponent { private readonly dialog = inject(MatDialog); private readonly epgBridge = inject(EpgRuntimeBridgeService); readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); readonly contextMenuTrigger = @@ -71,6 +75,7 @@ export class GlobalFavoritesListComponent { readonly channels = input.required(); readonly mode = input('favorites'); + readonly showEpg = input(this.runtime.supportsEpg); readonly favoriteUids = input>(new Set()); readonly epgMap = input>(new Map()); readonly progressTick = input(0); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 8808dc868..8b2024bca 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -21,7 +21,7 @@
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 13b303ec1..0bfeb27dd 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -26,6 +26,7 @@ import { UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; @@ -287,6 +288,12 @@ describe('UnifiedCollectionPageComponent', () => { provide: WorkspaceViewCommandService, useValue: workspaceViewCommands, }, + { + provide: RuntimeCapabilitiesService, + useValue: { + supportsEpg: false, + }, + }, ], }) .overrideComponent(UnifiedCollectionPageComponent, { @@ -308,6 +315,21 @@ describe('UnifiedCollectionPageComponent', () => { fixture.componentRef.setInput('defaultScope', 'all'); }); + it('uses compact loading rows when the runtime has no EPG support', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton.compact' + ) + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton:not(.compact)' + ) + ).toBeNull(); + }); + it('reloads favorites after playlist hydration completes', async () => { fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 81df4588c..a03b82f7b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -55,6 +55,7 @@ import { UnifiedFavoritesDataService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; @@ -99,6 +100,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { private readonly favoritesData = inject(UnifiedFavoritesDataService); private readonly recentData = inject(UnifiedRecentDataService); private readonly dialogService = inject(DialogService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly translate = inject(TranslateService); private readonly workspaceViewCommands = inject(WorkspaceViewCommandService); private readonly liveSidebarStateService = inject( @@ -144,6 +146,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { readonly selectedContentType = signal( this.historyCollectionViewState()?.selectedContentType ?? 'live' ); + readonly supportsEpg = this.runtime.supportsEpg; readonly selectedDetailItem = signal(null); readonly selectedDetailSeriesResume = signal( null diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 08714535e..df9ce8167 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -10,6 +10,7 @@ (); readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly showEpg = input(true); readonly favoriteUids = input>(new Set()); readonly epgMap = input>(new Map()); readonly progressTick = input(0); @@ -347,6 +348,10 @@ describe('UnifiedLiveTabComponent', () => { expect(streamResolver.loadEpgForItems).not.toHaveBeenCalled(); expect(streamResolver.loadM3uProgramsForItem).not.toHaveBeenCalled(); + const list = fixture.debugElement.query( + By.directive(StubGlobalFavoritesListComponent) + ).componentInstance as StubGlobalFavoritesListComponent; + expect(list.showEpg()).toBe(false); expect( fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); @@ -721,6 +726,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(component.channelsForList()[0].radio).toBe('true'); + await component.onChannelSelected(component.channelsForList()[0]); fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index ba0d5702f..4318c157a 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -277,6 +277,7 @@ export class UnifiedLiveTabComponent { playlistName: item.playlistName, streamUrl: item.streamUrl, m3uChannel: item.m3uChannel, + radio: item.radio, xtreamId: item.xtreamId, tvgId: item.tvgId, stalkerCmd: item.stalkerCmd, diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts index 438854125..12db6982e 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts @@ -34,6 +34,9 @@ export interface UnifiedFavoriteChannel { /** Full M3U channel metadata used by row context menu actions */ m3uChannel?: Channel; + /** Radio flag used to keep non-EPG audio rows compact */ + radio?: string; + /** Xtream numeric stream ID (Xtream channels only) */ xtreamId?: number; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index f9780a282..7e53dea5b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -69,7 +69,7 @@ @if (isInitialChannelsLoading()) { } @else if (searchTerm() && visibleChannels().length === 0) { @@ -106,9 +106,13 @@ ) ?? 0) : 0 " + [showEpg]="supportsEpg && !isRadioMode()" + [isRadio]="isRadioMode()" [showFavoriteButton]="true" [showProgramInfoButton]="false" - [showDetailsContextMenu]="supportsEpgMapping" + [showDetailsContextMenu]=" + supportsEpgMapping && !isRadioMode() + " [isFavorite]=" favorites.get(normalizeStalkerEntityId(item.id)) ?? false 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 79a12e7a9..4515593ca 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 @@ -46,6 +46,8 @@ class StubChannelListItemComponent { readonly name = input(''); readonly logo = input(null); readonly selected = input(false); + readonly showEpg = input(true); + readonly isRadio = input(false); readonly epgProgram = input(null); readonly progressPercentage = input(0); readonly showFavoriteButton = input(false); @@ -367,7 +369,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { return Boolean(window.electron); }, get supportsEpgMapping() { - return false; + return Boolean(window.electron); }, }, }, @@ -501,6 +503,60 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + const channelRows = fixture.debugElement.queryAll( + By.directive(StubChannelListItemComponent) + ); + expect(channelRows).not.toHaveLength(0); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showEpg() + ) + ).toBe(true); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showDetailsContextMenu() + ) + ).toBe(true); + }); + + it('renders radio channel rows with compact no-EPG density', () => { + stalkerStore.selectedContentType.set('radio'); + + fixture.detectChanges(); + + const channelRows = fixture.debugElement.queryAll( + By.directive(StubChannelListItemComponent) + ); + expect(channelRows).not.toHaveLength(0); + expect( + channelRows.every((row) => + ( + row.componentInstance as StubChannelListItemComponent + ).isRadio() + ) + ).toBe(true); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showEpg() + ) + ).toBe(true); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showDetailsContextMenu() + ) + ).toBe(true); }); it('restores the collapsed live EPG panel state after embedded playback starts', async () => { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 6271ec3e0..3f72bab30 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -17,7 +17,7 @@ } @else if (filteredChannels().length > 0) { { expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1); }); - it('does not derive or subscribe to row EPG previews in browser/PWA mode', () => { + it('does not derive or subscribe to row EPG previews in browser/PWA mode', async () => { Object.defineProperty(window, 'electron', { configurable: true, writable: true, @@ -285,6 +288,8 @@ describe('PortalChannelsListComponent', () => { fixture.destroy(); fixture = TestBed.createComponent(PortalChannelsListComponent); fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); epgResults$.next({ streamId: 50, @@ -302,8 +307,43 @@ describe('PortalChannelsListComponent', () => { const pwaComponent = fixture.componentInstance; expect(pwaComponent.supportsEpg).toBe(false); + expect(pwaComponent.channelItemSize).toBe(52); expect(pwaComponent.epgPrograms.size).toBe(0); expect(pwaComponent.currentProgramsProgress.size).toBe(0); + + const virtualScrollElement = fixture.debugElement.query( + By.css('cdk-virtual-scroll-viewport') + ); + if (!virtualScrollElement) { + throw new Error( + `Expected PWA channel viewport, received: ${fixture.nativeElement.innerHTML.slice(0, 1000)}` + ); + } + const virtualScroll = virtualScrollElement.injector.get( + CdkFixedSizeVirtualScroll + ); + Object.defineProperty( + virtualScrollElement.nativeElement, + 'clientHeight', + { + configurable: true, + value: 520, + } + ); + pwaComponent.viewport()?.checkViewportSize(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const channelRow = fixture.debugElement + .query(By.directive(ChannelListItemComponent)) + .injector.get(ChannelListItemComponent); + expect(virtualScroll.itemSize).toBe(52); + expect(channelRow.showEpg()).toBe(false); + expect( + fixture.nativeElement + .querySelector('.channel-list-item') + .classList.contains('compact') + ).toBe(true); }); it('does not mark a live item as favorite when only a colliding movie ID is favorited', () => { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 8a2076a32..a85ab35af 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -99,6 +99,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { readonly contextMenuPosition = signal({ x: '0px', y: '0px' }); readonly supportsEpg = this.runtime.supportsEpg; readonly supportsEpgMapping = this.runtime.supportsEpgMapping; + readonly channelItemSize = this.supportsEpg ? 68 : 52; readonly isSelectedTypeContentLoading = this.xtreamStore.selectedTypeContentLoading; readonly channels = computed(() => { diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts index f7ecacaf4..4ce8801ee 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts @@ -81,7 +81,7 @@ describe('AllChannelsViewComponent', () => { ); fixture.componentRef.setInput('progressTick', 0); fixture.componentRef.setInput('shouldShowEpg', false); - fixture.componentRef.setInput('itemSize', 48); + fixture.componentRef.setInput('itemSize', 52); fixture.componentRef.setInput('favoriteIds', new Set()); fixture.detectChanges(); }); @@ -111,7 +111,7 @@ describe('AllChannelsViewComponent', () => { ); fixture.componentRef.setInput('progressTick', 0); fixture.componentRef.setInput('shouldShowEpg', false); - fixture.componentRef.setInput('itemSize', 48); + fixture.componentRef.setInput('itemSize', 52); fixture.componentRef.setInput('favoriteIds', new Set()); fixture.detectChanges(); @@ -132,7 +132,7 @@ describe('AllChannelsViewComponent', () => { ); fixture.componentRef.setInput('progressTick', 0); fixture.componentRef.setInput('shouldShowEpg', false); - fixture.componentRef.setInput('itemSize', 48); + fixture.componentRef.setInput('itemSize', 52); fixture.componentRef.setInput('favoriteIds', new Set()); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 437dd388d..d6be05b29 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -1,5 +1,8 @@ @if (channelsLoading()) { - + } @else if (displayedChannels().length) { @if (viewTitle() && activeView() !== 'groups' && activeView() !== 'all') {
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index 8c423a128..913695bcb 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -209,6 +209,7 @@ describe('ChannelListContainerComponent', () => { fixture.detectChanges(); expect(fixture.componentInstance.shouldShowEpg()).toBe(false); + expect(fixture.componentInstance.itemSize()).toBe(52); expect(storageGet).not.toHaveBeenCalled(); }); @@ -222,6 +223,7 @@ describe('ChannelListContainerComponent', () => { expect(storageGet).toHaveBeenCalled(); expect(fixture.componentInstance.shouldShowEpg()).toBe(true); + expect(fixture.componentInstance.itemSize()).toBe(68); }); it('enables EPG rows and scopes lookups when the active M3U playlist has detected EPG URLs', () => { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index d36f2aa81..dbaec930d 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -178,7 +178,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ); /** Item size for virtual scroll - compact when no EPG */ - readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); + readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 52)); /** Active view (all, groups, favorites, recent) */ readonly activeView = input('all'); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss index 8deb39f91..5b9dc714c 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss @@ -20,11 +20,16 @@ overflow: hidden; &.compact { + height: 52px; min-height: 52px; - padding-block: 7px; + padding-block: 6px; } } +.channel-list-item-skeleton:not(.compact) { + height: 68px; +} + .channel-content-skeleton { min-width: 0; flex: 1; @@ -104,29 +109,35 @@ @container (max-width: 270px) { .channel-list-item-skeleton { - min-height: 52px; gap: 8px; padding-inline: 8px 6px; } - .channel-logo-skeleton { - width: 34px; - height: 34px; + .channel-list-item-skeleton:not(.compact) { + padding-block: 7px; } - .epg-title-skeleton, - .epg-progress-skeleton { + .channel-list-item-skeleton:not(.compact) .channel-logo-skeleton { display: none; } + + .channel-list-item-skeleton:not(.compact) .channel-content-skeleton { + gap: 0; + } + + .channel-list-item-skeleton.compact .channel-logo-skeleton { + width: 34px; + height: 34px; + } } @container (max-width: 220px) { - .channel-logo-skeleton, - .action-buttons-skeleton { + .channel-list-item-skeleton.compact .channel-logo-skeleton, + .channel-list-item-skeleton.compact .action-buttons-skeleton { display: none; } - .channel-content-skeleton { + .channel-list-item-skeleton.compact .channel-content-skeleton { gap: 0; } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index 4e7430ad3..75c1faed8 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -44,11 +44,16 @@ } &.compact { + height: 52px; min-height: 52px; - padding-block: 7px; + padding-block: 6px; } } +.channel-list-item:not(.compact) { + height: 68px; +} + .drag-icon { flex-shrink: 0; cursor: move; @@ -73,7 +78,11 @@ place-items: center; overflow: hidden; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.12), + rgba(255, 255, 255, 0) + ), rgba(127, 127, 127, 0.12); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), @@ -180,7 +189,8 @@ rgba(255, 255, 255, 0) ); box-shadow: - 0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent), + 0 0 0 1px + color-mix(in srgb, var(--app-selection-border) 82%, transparent), 0 0 10px -2px var(--app-selection-glow); transition: width 0.35s ease; } @@ -260,7 +270,7 @@ @container (max-width: 310px) { .epg-timeline { - grid-template-columns: 1fr; + grid-template-columns: auto minmax(24px, 1fr); gap: 5px; } @@ -271,35 +281,48 @@ @container (max-width: 270px) { .channel-list-item { - min-height: 52px; gap: 8px; padding-inline: 8px 6px; } - .channel-logo-shell { - width: 34px; - height: 34px; + .channel-list-item:not(.compact) { + padding-block: 7px; } - .epg-title, - .epg-timeline, - .epg-placeholder, - .program-info-button { + .channel-list-item:not(.compact) .channel-logo-shell { display: none; } + + .channel-list-item:not(.compact) .channel-content { + gap: 0; + } + + .channel-list-item.compact .channel-logo-shell { + width: 34px; + height: 34px; + } } @container (max-width: 220px) { - .channel-logo-shell, - .action-buttons { + .channel-list-item:not(.compact) .epg-timeline { + grid-template-columns: minmax(24px, 1fr); + } + + .channel-list-item:not(.compact) .epg-time:first-child { display: none; } - .channel-content { + .channel-list-item.compact .channel-logo-shell, + .channel-list-item.compact .action-buttons { + display: none; + } + + .channel-list-item.compact .channel-content { gap: 0; } } -.favorite-button, .program-info-button { +.favorite-button, +.program-info-button { padding: 0; -} \ No newline at end of file +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index 733e0e2ab..9f1c3cfbc 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -78,6 +78,24 @@ describe('ChannelListItemComponent', () => { expect( fixture.nativeElement.querySelector('.epg-placeholder') ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.channel-list-item') + .classList.contains('compact') + ).toBe(false); + }); + + it('uses compact density when a radio consumer disables EPG', () => { + fixture.componentRef.setInput('name', 'Radio One'); + fixture.componentRef.setInput('showEpg', false); + fixture.componentRef.setInput('isRadio', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.channel-list-item') + .classList.contains('compact') + ).toBe(true); }); it('shows the generic fallback icon when no logo is available', () => { diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index da7f066c9..69365f082 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -1,4 +1,6 @@ +import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; @@ -213,6 +215,26 @@ describe('GroupsViewComponent', () => { expect(component.groupChannelSortLabel()).toBe('Playlist Order'); }); + it('keeps the virtual-scroll item size aligned with row EPG density', () => { + setInputs({ shouldShowEpg: false }); + + expect(component.itemSize()).toBe(52); + expect( + fixture.debugElement + .query(By.css('cdk-virtual-scroll-viewport')) + .injector.get(CdkFixedSizeVirtualScroll).itemSize + ).toBe(52); + + setInputs({ shouldShowEpg: true }); + + expect(component.itemSize()).toBe(68); + expect( + fixture.debugElement + .query(By.css('cdk-virtual-scroll-viewport')) + .injector.get(CdkFixedSizeVirtualScroll).itemSize + ).toBe(68); + }); + it('restores a saved valid sort mode and ignores invalid stored values', () => { fixture.destroy(); localStorage.setItem(GROUP_CHANNEL_SORT_STORAGE_KEY, 'name-asc'); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index eea1018d8..596a0f361 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -142,7 +142,7 @@ export class GroupsViewComponent { this.searchTerm().trim().length > 0 || this.localGroupSearchTerm().trim().length > 0 ); - readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); + readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 52)); readonly contextMenuChannel = signal(null); readonly contextMenuPosition = signal({ x: '0px', diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html index 1ce0fe8c3..676655433 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html @@ -42,7 +42,7 @@
@@ -59,7 +59,7 @@
diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts index b223d283b..bb41aa6fd 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts @@ -31,6 +31,22 @@ describe('ChannelListLoadingStateComponent', () => { ).toBeNull(); }); + it('renders compact skeleton rows when the host disables EPG', () => { + fixture.componentRef.setInput('showEpg', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton.compact' + ) + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton:not(.compact)' + ) + ).toBeNull(); + }); + it('renders a two-column group loading layout for the groups view', () => { fixture.componentRef.setInput('view', 'groups'); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts index a65429ae4..9d3f83600 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts @@ -16,6 +16,7 @@ import { ChannelListSkeletonComponent } from '../channel-list-container/channel- }) export class ChannelListLoadingStateComponent { readonly view = input('all'); + readonly showEpg = input(true); readonly isGroupsView = computed(() => this.view() === 'groups'); readonly groupRows = Array.from({ length: 10 }, (_, index) => index);