mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
* feat(epg): add vertical list view for the live EPG panel
Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).
- New EpgListViewComponent (app-epg-list-view) mirrors
EpgTimelineComponent's input/output contract 1:1, so all four live
hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
hasProgramsForDateKey, epg-archive.util catch-up gating,
epg-summary.util collapsed-summary maths, epg-date helpers,
EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
duplicated logic.
- Rows show time range, title, optional description, live progress on
the on-air row, catch-up "Watch" on past rows when archive playback
is available, and a details dialog; keyboard activation guards
nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
collapse/expand remounts, and shows a sticky in-flow "On now" strip
(never overlaying rows) when the current programme is scrolled away;
all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
modifier (--epg-inline-height clamp); timeline and collapsed heights
are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
SettingsStore/StorageMap → segmented control in the EPG section);
Electron-only UI, PWA stays on the timeline default. i18n keys added
to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
persistence spec, swap tests in all four host specs, and Electron
E2E for the settings round-trip and the rendered list view. Docs
updated (m3u-playlist-module.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): address list-view review findings from Codex and Greptile
- Reset the list view to today when a new channel's programme set
arrives while the user is parked on another day (timeline parity):
the scroll controller now keys by the full programme-set identity
(programsFocusKey) and commits today before focusing, instead of
silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
on SettingsStore; the four live hosts consume the derived signal
instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
registerEpgListViewEffects(), bringing the component back under the
300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
coverage: return-to-today on channel switch, day navigation left
alone, no-takeover when today has no data, empty-set no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): drop malformed programmes from the list-view day filter
Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
210 lines
7.3 KiB
TypeScript
210 lines
7.3 KiB
TypeScript
import {
|
|
addXtreamPortal,
|
|
channelItemByTitle,
|
|
clickCategoryByNameExact,
|
|
closeElectronApp,
|
|
expect,
|
|
goToDashboard,
|
|
launchElectronApp,
|
|
openSettings,
|
|
openWorkspaceSection,
|
|
resetMockServers,
|
|
saveSettings,
|
|
test,
|
|
waitForXtreamWorkspaceReady,
|
|
} from './electron-test-fixtures';
|
|
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
|
|
|
|
const epgPortalName = 'Xtream EPG Fixture';
|
|
const epgCredentials = {
|
|
username: 'epg',
|
|
password: 'epg',
|
|
};
|
|
|
|
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
|
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, 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: timeZone },
|
|
});
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: `${epgPortalName} ${timeZone}`,
|
|
username: epgCredentials.username,
|
|
password: epgCredentials.password,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await clickCategoryByNameExact(
|
|
app.mainWindow,
|
|
fixture.categoryName
|
|
);
|
|
|
|
const channelRow = channelItemByTitle(
|
|
app.mainWindow,
|
|
fixture.stream.name ?? ''
|
|
).first();
|
|
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
|
|
|
// Sidebar channel list shows the per-channel "now" programme line.
|
|
await expect
|
|
.poll(async () =>
|
|
(
|
|
(await channelRow
|
|
.locator('.epg-title')
|
|
.textContent()) ?? ''
|
|
).trim()
|
|
)
|
|
.toBe(currentProgram.title);
|
|
await expect
|
|
.poll(async () => {
|
|
return (
|
|
await channelRow.locator('.epg-time').allInnerTexts()
|
|
).map((value) => value.trim());
|
|
})
|
|
.toEqual([
|
|
formatTimeInZone(currentProgram.startTimestamp, timeZone),
|
|
formatTimeInZone(currentProgram.stopTimestamp, timeZone),
|
|
]);
|
|
await expect
|
|
.poll(async () => getProgressWidthPercent(channelRow))
|
|
.toBeGreaterThan(0);
|
|
|
|
await channelRow.click();
|
|
await expect(
|
|
app.mainWindow.locator('app-epg-timeline')
|
|
).toBeVisible({ timeout: 20000 });
|
|
|
|
// The timeline renders the full multi-day window as blocks,
|
|
// sorted by start time (no per-day filtering — it scrolls).
|
|
const allTitles = [...fixture.fullEpg]
|
|
.sort((a, b) => a.startTimestamp - b.startTimestamp)
|
|
.map((listing) => listing.title);
|
|
await expect
|
|
.poll(() => timelineBlockTitles(app.mainWindow))
|
|
.toEqual(allTitles);
|
|
|
|
// The current programme is highlighted as the "now" block.
|
|
await expect(
|
|
app.mainWindow
|
|
.locator(
|
|
'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title'
|
|
)
|
|
.first()
|
|
).toHaveText(currentProgram.title);
|
|
|
|
// The date stepper advances the day label.
|
|
const dayLabel = app.mainWindow
|
|
.locator('app-epg-timeline .epg-timeline__day small')
|
|
.first();
|
|
const beforeLabel = (await dayLabel.textContent())?.trim();
|
|
await app.mainWindow
|
|
.locator(
|
|
'app-epg-timeline .epg-timeline__stepper .epg-timeline__nav'
|
|
)
|
|
.last()
|
|
.click();
|
|
await expect
|
|
.poll(async () => (await dayLabel.textContent())?.trim())
|
|
.not.toBe(beforeLabel);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('@epg @xtream @electron renders the vertical list view when the setting is "list"', 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);
|
|
|
|
try {
|
|
// Opt into the list view first (from the fresh workspace) so the portal
|
|
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
|
|
await openSettings(app.mainWindow);
|
|
await app.mainWindow
|
|
.locator('[data-test-id="epg-view-mode-list"]')
|
|
.click();
|
|
await saveSettings(app.mainWindow);
|
|
await goToDashboard(app.mainWindow);
|
|
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: `${epgPortalName} List`,
|
|
username: epgCredentials.username,
|
|
password: epgCredentials.password,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
|
const channelRow = channelItemByTitle(
|
|
app.mainWindow,
|
|
fixture.stream.name ?? ''
|
|
).first();
|
|
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
|
await channelRow.click();
|
|
|
|
// The list view renders instead of the timeline.
|
|
await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({
|
|
timeout: 20000,
|
|
});
|
|
await expect(
|
|
app.mainWindow.locator('app-epg-timeline')
|
|
).toHaveCount(0);
|
|
|
|
// The on-air programme is the highlighted "now" row.
|
|
await expect(
|
|
app.mainWindow
|
|
.locator('app-epg-list-view .g-row[data-when="now"] .title')
|
|
.first()
|
|
).toHaveText(currentProgram.title);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
timeZone,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
}).format(new Date(timestampSeconds * 1000));
|
|
}
|
|
|
|
async function timelineBlockTitles(
|
|
page: Parameters<typeof channelItemByTitle>[0]
|
|
) {
|
|
return page
|
|
.locator('app-epg-timeline .epg-timeline__block-title')
|
|
.allInnerTexts()
|
|
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
|
|
}
|
|
|
|
async function getProgressWidthPercent(
|
|
row: ReturnType<typeof channelItemByTitle>
|
|
) {
|
|
const style = await row.locator('.epg-progress-fill').getAttribute('style');
|
|
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
|
|
return Number.parseFloat(width);
|
|
}
|