feat(dashboard): portal EPG on the live rails, loaded lazily per visible card (#1638)

Xtream and Stalker live cards on the dashboard carried no XMLTV key, so the
rails never asked anything for them and showed only the LIVE chip. Their
programme now comes from the portal, one card at a time and only once the card
is on screen.

- `lib-dashboard-rail` reports the cards inside its viewport through an
  IntersectionObserver rooted at the track; `DashboardLiveEpgPresenter` unions
  them with the pinned hero row and hands the set to
  `DashboardPortalLiveEpgPresenter`.
- `DashboardPortalLiveEpgService` runs the bounded queue — two requests in
  flight, 200 ms apart, one card each — through
  `StreamResolverService.loadEpgForItems`, publishing every answer the moment
  it lands, so the page never waits and a slow portal delays no other card. A
  card scrolled past before its turn is never requested.
- A programme is trusted for 60 s, an empty answer for 30 s (the resolver
  reports a dead portal and a guide-less channel identically), and a
  completion captures both the display offset and the EPG source revision,
  requeueing itself when either moved.
- The presenter hands its wanted set back on destroy. Desktop only: the shared
  collection resolver is gated on the local XMLTV bridge.
- A shimmer placeholder shows only before a card's first portal answer; M3U
  cards keep the batched XMLTV lookup.
This commit is contained in:
4gray authored and GitHub committed 2026-09-20 22:53:55 +02:00
1 parent 4e575a810e
commit 6578e4073c
19 files changed
+1937 -46

No files matched your search

@@ -18,6 +18,7 @@ import {
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamEpgFixture,
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
@@ -47,6 +48,33 @@ test.describe('Dashboard Activation', () => {
liveFixture.items,
getXtreamTitle
);
// The mock's guide for the channel favourited below (its first live
// stream). The programme on air is read off the full guide at
// assertion time: the mock cuts its slots from the second the guide
// was generated, so the "current" listing of a fixture fetched in
// that same second is the slot that ended just then.
const epgFixture = await fetchXtreamEpgFixture(
request,
xtreamCredentials
);
expect(getXtreamTitle(epgFixture.stream)).toBe(liveTitle);
const liveNowTitle = () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const index = epgFixture.fullEpg.findIndex(
(listing) =>
listing.startTimestamp <= nowSeconds &&
nowSeconds < listing.stopTimestamp
);
expect(index).toBeGreaterThanOrEqual(0);
// Tolerate a slot boundary passing between the app's answer and
// this assertion.
const titles = epgFixture.fullEpg
.slice(index, index + 2)
.map((listing) =>
listing.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
return new RegExp(titles.join('|'));
};
const app = await launchElectronApp(dataDir);
try {
@@ -139,6 +167,15 @@ test.describe('Dashboard Activation', () => {
app.mainWindow,
'dashboard-live-favorites-rail'
);
// An Xtream card has no XMLTV key: its "now on air" line comes
// from the portal, asked for lazily once the card is on screen.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
@@ -167,6 +204,14 @@ test.describe('Dashboard Activation', () => {
app.mainWindow,
'dashboard-recent-live-rail'
);
// Same channel, same key: the recent card shares the answer.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',