fix(ui): preserve EPG in narrow channel rows (#1312)

Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views.
This commit is contained in:
4gray authored and GitHub committed 2026-07-31 08:27:36 +02:00
1 parent 2ac0de752f
commit 46c7713841
37 files changed
+1414 -53

No files matched your search

+7
View File
@@ -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.
@@ -203,7 +203,7 @@ export async function fetchXtreamEpgFixture(
export async function fetchStalkerCategoryFixture(
request: APIRequestContext,
type: 'itv' | 'series' | 'vod'
type: 'itv' | 'radio' | 'series' | 'vod'
): Promise<StalkerCategoryFixture> {
const categoriesResponse = await fetchJson<
StalkerProxyPayload<StalkerCategory[]>
+394 -4
View File
@@ -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<typeof channelItemByTitle>[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<typeof channelItemByTitle>[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<typeof channelItemByTitle>,
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<typeof channelItemByTitle>,
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<typeof channelItemByTitle>
) {
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<typeof channelItemByTitle>
) {
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<typeof channelItemByTitle>[0]
) {
@@ -296,6 +296,8 @@ function buildTimezoneFixture(): Pick<
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000));
epgListingsByStreamId.set(10_001, []);
epgListingsByStreamId.set(10_002, []);
return {
liveCategories,
+51 -3
View File
@@ -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({
@@ -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
@@ -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<typeof channelItemByTitle>[0],
width: number
): Promise<void> {
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<typeof channelItemByTitle>
): Promise<void> {
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.
@@ -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.
@@ -37,9 +37,15 @@
(sidebarToggleRequested)="toggleSidebar()"
/>
} @loading {
<app-channel-list-loading-state [view]="activeView()" />
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
} @placeholder {
<app-channel-list-loading-state [view]="activeView()" />
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
}
</div>
@@ -64,6 +64,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
})
class StubChannelListLoadingStateComponent {
readonly view = input<string | null>(null);
readonly showEpg = input(true);
}
@Component({
@@ -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"
@@ -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');
@@ -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<UnifiedFavoriteChannel[]>();
readonly mode = input<GlobalFavoritesListMode>('favorites');
readonly showEpg = input(this.runtime.supportsEpg);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input<number>(0);
@@ -21,7 +21,7 @@
<div class="skeleton-list">
<app-channel-list-skeleton
[count]="12"
[showEpg]="true"
[showEpg]="supportsEpg"
[actionCount]="1"
/>
</div>
@@ -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();
@@ -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<CollectionContentType>(
this.historyCollectionViewState()?.selectedContentType ?? 'live'
);
readonly supportsEpg = this.runtime.supportsEpg;
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
readonly selectedDetailSeriesResume = signal<SeriesResumeTarget | null>(
null
@@ -10,6 +10,7 @@
<app-global-favorites-list
[channels]="channelsForList()"
[mode]="mode()"
[showEpg]="supportsEpg"
[favoriteUids]="favoriteUids()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
@@ -65,6 +65,7 @@ class StubResizableDirective {
class StubGlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly showEpg = input(true);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(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();
@@ -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,
@@ -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;
@@ -69,7 +69,7 @@
@if (isInitialChannelsLoading()) {
<app-channel-list-skeleton
[count]="9"
[showEpg]="false"
[showEpg]="supportsEpg && !isRadioMode()"
[actionCount]="1"
/>
} @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
@@ -46,6 +46,8 @@ class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly showEpg = input(true);
readonly isRadio = input(false);
readonly epgProgram = input<unknown>(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 () => {
@@ -17,7 +17,7 @@
</div>
} @else if (filteredChannels().length > 0) {
<cdk-virtual-scroll-viewport
[itemSize]="68"
[itemSize]="channelItemSize"
class="scroll-viewport-portals"
>
<app-channel-list-item
@@ -41,6 +41,7 @@
? (currentProgramsProgress.get(item.xtream_id) ?? 0)
: 0
"
[showEpg]="supportsEpg"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="supportsEpgMapping"
@@ -1,5 +1,7 @@
import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
@@ -11,6 +13,7 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { PortalChannelsListComponent } from './portal-channels-list.component';
function buildEpgItem(params: {
@@ -257,7 +260,7 @@ describe('PortalChannelsListComponent', () => {
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', () => {
@@ -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(() => {
@@ -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<string>());
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<string>());
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<string>());
fixture.detectChanges();
@@ -1,5 +1,8 @@
@if (channelsLoading()) {
<app-channel-list-loading-state [view]="activeView()" />
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="shouldShowEpg()"
/>
} @else if (displayedChannels().length) {
@if (viewTitle() && activeView() !== 'groups' && activeView() !== 'all') {
<header class="context-header">
@@ -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', () => {
@@ -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<string>('all');
@@ -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;
}
}
@@ -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;
}
}
@@ -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', () => {
@@ -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');
@@ -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<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
@@ -42,7 +42,7 @@
<div class="groups-loading-content__list">
<app-channel-list-skeleton
[count]="9"
[showEpg]="true"
[showEpg]="showEpg()"
[actionCount]="2"
/>
</div>
@@ -59,7 +59,7 @@
<div class="channels-loading-state__list">
<app-channel-list-skeleton
[count]="9"
[showEpg]="true"
[showEpg]="showEpg()"
[actionCount]="2"
/>
</div>
@@ -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();
@@ -16,6 +16,7 @@ import { ChannelListSkeletonComponent } from '../channel-list-container/channel-
})
export class ChannelListLoadingStateComponent {
readonly view = input<string>('all');
readonly showEpg = input(true);
readonly isGroupsView = computed(() => this.view() === 'groups');
readonly groupRows = Array.from({ length: 10 }, (_, index) => index);