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

+63 -1
View File
@@ -141,7 +141,69 @@ Render rules:
`dashboard-recent-live-rail`); there is no fallback from one to the
other. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment
must use that key before falling back to the card title.
must use that key before falling back to the card title. Both rails are
enriched by `DashboardLiveEpgPresenter`, the one component-provided
facade for live EPG: it owns the XMLTV lookup described under "Scoped
lookups" in `m3u-playlist-module.md`, forwards everything portal-shaped
to `DashboardPortalLiveEpgPresenter`, and `enrich()` returns the cards
with their "now on air" row filled in.
Xtream and Stalker cards have no XMLTV key of their own; their "now on
air" line comes from the portal, **lazily and per card**:
- `buildDashboardPortalLiveEpgEntry` (dashboard data-access) turns a
live `PortalActivityItem` into the `UnifiedCollectionItem` the
collection pages hand `StreamResolverService.loadEpgForItems`, keyed
by the collection uid — favourites and recent rows of one channel
share the answer. Radio rows and rows without a usable provider id
get no entry. Cards carry that key as `liveEpgSourceKey`.
- `lib-dashboard-rail` reports the cards inside its track viewport
(plus ~one card of `rootMargin`) through `visibleCardsChanged`, from
an `IntersectionObserver` rooted at the track; without the API every
card counts as visible. Cards that leave the list are reported gone
at once.
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
visible keys of both rails with the pinned hero key and calls
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
on every change, on the 30 s tick, and on a display-offset change.
It is reached through `DashboardLiveEpgPresenter`, which derives the
portal rows itself from the enabled rails and pins the hero, so the
page component only forwards what a rail can see. The queue lives in
the root service, so leaving the dashboard hands the wanted set back
(`sync([])` on destroy); otherwise the queue would keep asking for
cards on a page that is gone.
- `DashboardPortalLiveEpgService` (root) owns the queue: at most two
requests in flight, 200 ms between starts (the numbers
`EpgQueueService` proved against real panels), one card per request,
each answer published the moment it lands in `programs`, so the page
never waits and a slow portal delays no other card. Only wanted keys
are dequeued, so a card scrolled past before its turn is never
requested. A programme lives 60 s; a programme that ended is asked
again, but not within 30 s of the last answer (a portal may keep
returning the stale row). An answer with **no** programme lives only
30 s, because the resolver reports a failed portal and a guide-less
channel identically (it files per-channel failures as `null`), so
there is no failure cooldown to keep and the short TTL is what lets
an outage recover on the next tick.
- Every answer is "at the provider clock" and against one XMLTV source
set. A request captures both the display offset and
`EpgSourceSettingsService.revision()` — the same fence
`EpgService.guard()` uses — and a completion whose either fact moved
is discarded and requeued instead of published. That requeue has to
happen in the completion: while the key is in flight the retire pass
cannot queue a replacement, and without it the pre-change answer
would be trusted for a full TTL (the repo's late-result
invalidation contract).
- Desktop only in practice: the shared collection resolver is gated on
the local XMLTV bridge (`supportsProgramLookup`) and answers nothing
without it, so `sync()` returns immediately in the PWA rather than
filing an empty answer for every card. Lifting that gate for portal
lookups would change the collection pages too and is deliberately
out of scope here.
- `DashboardLiveEpgPresenter.enrich()` prefers the portal answer, falls
back to the XMLTV title match when the portal said "nothing on air",
and marks a card
`nowPlayingState: 'pending'` only before its FIRST answer — the
channel layout then shows a shimmer placeholder in the programme
slot; a refresh keeps the previous answer on screen.
4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail
cards. Aggregates newly added VOD and series across *all* Xtream
playlists via `DashboardDataService.reloadXtreamRecentlyAddedItems()`,