mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
feat(epg): rework live EPG panel into a horizontal timeline
Replace the vertical EPG list/panel with a single horizontal timeline ribbon (`app-epg-timeline`) shared across all live surfaces — the M3U player, the unified live tab, and the Xtream and Stalker layouts. It preloads a multi-day window and supports scroll, zoom, day navigation, short-programme tiers and per-state empty/error views; the programme-details dialog is restyled to match and now shows the channel logo. Timeline / UI - Auto-focus the currently-airing programme on channel select — instant (`behavior: 'auto'`), centred, and deduped by programme-set identity so the 30s tick / zoom / re-emits never cause flaky re-jumps. - Wide-tier cards show a dimmed description preview under the title; title and preview clip on whole lines (`flex-shrink: 0` + `-webkit-line-clamp`), never mid-line. - Dynamic bar subtitle: the now-playing / archive programme title instead of a static "Timeline" label. - State-aware toolbar: hide Now + zoom when there is no ribbon, and the date stepper when the channel has no EPG on any day (hidden, not disabled). - Empty-state fills and centres within the panel area instead of a fixed min-height that overflowed the compact inline panel and hid the buttons. EPG current-programme lookup (electron-backend) - Timezone-aware "airing now" comparison via SQLite `datetime()` (was a lexical string compare that broke for offset vs UTC `Z` timestamps). - Unscoped fallback for resolved candidates whose programmes are tagged with an out-of-scope source (multi-source imports sharing XMLTV ids), so the sidebar matches the timeline. - Match channel candidates by the raw (case-sensitive) key as well as `LOWER()`, since SQLite `LOWER()`/`COLLATE NOCASE` fold only ASCII — fixes Cyrillic / Greek channel names showing "No program information available". - Overlap-based day membership (`hasProgramsForDateKey`) so a film spanning midnight keeps the ribbon on "today" while it airs. Refactor - Extract the duplicated channel-list EPG enrichment (progress + side-car map) into `channel-list-container/epg-enrichment.util.ts`, shared by the all-channels / groups / favorites / recent views; also fixes recent's and favorites' unguarded, NaN-prone progress maths. Tests: ui-epg 75, components 77, electron-backend 432; lint + web build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1119189e2
commit
a048e56aca
60 files changed
+5523
-1191
No files matched your search
@@ -10,10 +10,7 @@ import {
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamEpgFixture,
|
||||
XtreamNormalizedEpgListing,
|
||||
} from './portal-mock-fixtures';
|
||||
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
|
||||
|
||||
const epgPortalName = 'Xtream EPG Fixture';
|
||||
const epgCredentials = {
|
||||
@@ -22,7 +19,7 @@ const epgCredentials = {
|
||||
};
|
||||
|
||||
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
test(`@epg @xtream @electron renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({
|
||||
test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
@@ -57,6 +54,7 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Sidebar channel list shows the per-channel "now" programme line.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(
|
||||
@@ -81,55 +79,42 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await channelRow.click();
|
||||
await expect(app.mainWindow.locator('app-epg-list')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
const currentDateKey = formatDateKeyInZone(Date.now(), timeZone);
|
||||
const currentDayTitles = titlesForDate(
|
||||
fixture.fullEpg,
|
||||
currentDateKey,
|
||||
timeZone
|
||||
);
|
||||
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(() => visibleProgramTitles(app.mainWindow))
|
||||
.toEqual(currentDayTitles);
|
||||
.poll(() => timelineBlockTitles(app.mainWindow))
|
||||
.toEqual(allTitles);
|
||||
|
||||
await expectCurrentProgram(
|
||||
app.mainWindow,
|
||||
currentProgram,
|
||||
timeZone
|
||||
);
|
||||
|
||||
const firstFutureDateKey = getFirstFutureDateKey(
|
||||
fixture.fullEpg,
|
||||
currentDateKey,
|
||||
timeZone
|
||||
);
|
||||
expect(firstFutureDateKey).not.toBeNull();
|
||||
|
||||
const futureDayTitles = titlesForDate(
|
||||
fixture.fullEpg,
|
||||
firstFutureDateKey!,
|
||||
timeZone
|
||||
);
|
||||
const nextDayClicks = dayDifference(
|
||||
currentDateKey,
|
||||
firstFutureDateKey!
|
||||
);
|
||||
|
||||
for (let index = 0; index < nextDayClicks; index += 1) {
|
||||
await app.mainWindow
|
||||
// The current programme is highlighted as the "now" block.
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator(
|
||||
'app-live-epg-panel .next-day, app-epg-list .next-day'
|
||||
'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title'
|
||||
)
|
||||
.click();
|
||||
}
|
||||
.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(() => visibleProgramTitles(app.mainWindow))
|
||||
.toEqual(futureDayTitles);
|
||||
.poll(async () => (await dayLabel.textContent())?.trim())
|
||||
.not.toBe(beforeLabel);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
@@ -145,105 +130,15 @@ function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
|
||||
}).format(new Date(timestampSeconds * 1000));
|
||||
}
|
||||
|
||||
function formatDateKeyInZone(
|
||||
timestampMilliseconds: number,
|
||||
timeZone: string
|
||||
): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(new Date(timestampMilliseconds));
|
||||
|
||||
const year = parts.find((part) => part.type === 'year')?.value;
|
||||
const month = parts.find((part) => part.type === 'month')?.value;
|
||||
const day = parts.find((part) => part.type === 'day')?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error(
|
||||
`Failed to format a date key for timezone ${timeZone}.`
|
||||
);
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function titlesForDate(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
dateKey: string,
|
||||
timeZone: string
|
||||
): string[] {
|
||||
return listings
|
||||
.filter(
|
||||
(listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
|
||||
dateKey
|
||||
)
|
||||
.map((listing) => listing.title);
|
||||
}
|
||||
|
||||
function getFirstFutureDateKey(
|
||||
listings: XtreamNormalizedEpgListing[],
|
||||
currentDateKey: string,
|
||||
timeZone: string
|
||||
): string | null {
|
||||
const uniqueDateKeys = Array.from(
|
||||
new Set(
|
||||
listings.map((listing) =>
|
||||
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone)
|
||||
)
|
||||
)
|
||||
).sort();
|
||||
|
||||
return uniqueDateKeys.find((dateKey) => dateKey > currentDateKey) ?? null;
|
||||
}
|
||||
|
||||
function dayDifference(fromDateKey: string, toDateKey: string): number {
|
||||
const from = Date.parse(`${fromDateKey}T00:00:00.000Z`);
|
||||
const to = Date.parse(`${toDateKey}T00:00:00.000Z`);
|
||||
return Math.round((to - from) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
async function visibleProgramTitles(
|
||||
async function timelineBlockTitles(
|
||||
page: Parameters<typeof channelItemByTitle>[0]
|
||||
) {
|
||||
return page
|
||||
.locator('app-epg-list .program-title')
|
||||
.locator('app-epg-timeline .epg-timeline__block-title')
|
||||
.allInnerTexts()
|
||||
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
async function expectCurrentProgram(
|
||||
page: Parameters<typeof channelItemByTitle>[0],
|
||||
currentProgram: XtreamNormalizedEpgListing,
|
||||
timeZone: string
|
||||
): Promise<void> {
|
||||
const startTime = formatTimeInZone(currentProgram.startTimestamp, timeZone);
|
||||
const stopTime = formatTimeInZone(currentProgram.stopTimestamp, timeZone);
|
||||
const currentProgramRow = page
|
||||
.locator('app-epg-list .program-item.current-program')
|
||||
.first();
|
||||
|
||||
if (await currentProgramRow.isVisible().catch(() => false)) {
|
||||
await expect(currentProgramRow.locator('.program-title')).toHaveText(
|
||||
currentProgram.title
|
||||
);
|
||||
await expect(currentProgramRow.locator('.time')).toHaveText(
|
||||
`${startTime}–${stopTime}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = page.locator('app-live-epg-panel').first();
|
||||
await expect(summary.locator('.live-epg-panel__title')).toHaveText(
|
||||
currentProgram.title
|
||||
);
|
||||
await expect(summary.locator('.live-epg-panel__time')).toHaveText(
|
||||
`${startTime} - ${stopTime}`
|
||||
);
|
||||
}
|
||||
|
||||
async function getProgressWidthPercent(
|
||||
row: ReturnType<typeof channelItemByTitle>
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user