mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 10:56:43 -08:00
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:
1 parent
2ac0de752f
commit
46c7713841
37 files changed
+1414
-53
No files matched your search
@@ -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[]>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+27
@@ -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');
|
||||
|
||||
+6
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<div class="skeleton-list">
|
||||
<app-channel-list-skeleton
|
||||
[count]="12"
|
||||
[showEpg]="true"
|
||||
[showEpg]="supportsEpg"
|
||||
[actionCount]="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+22
@@ -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();
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
<app-global-favorites-list
|
||||
[channels]="channelsForList()"
|
||||
[mode]="mode()"
|
||||
[showEpg]="supportsEpg"
|
||||
[favoriteUids]="favoriteUids()"
|
||||
[epgMap]="epgMap()"
|
||||
[progressTick]="progressTick()"
|
||||
|
||||
+7
@@ -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();
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+57
-1
@@ -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 () => {
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+41
-1
@@ -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', () => {
|
||||
|
||||
+1
@@ -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(() => {
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
|
||||
+4
-1
@@ -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">
|
||||
|
||||
+2
@@ -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', () => {
|
||||
|
||||
+1
-1
@@ -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');
|
||||
|
||||
+21
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
-17
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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', () => {
|
||||
|
||||
+22
@@ -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');
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+16
@@ -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();
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user