mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
Xtream and Stalker live cards on the dashboard showed only the LIVE chip: they carry no XMLTV key and the rails never asked the portal. They now get their programme from the portal one card at a time, only once the card is on screen, with a bounded queue (2 in flight, 200 ms apart, 60 s TTL, 30 s failure cooldown) and every answer published as it lands, so the page never waits and a slow portal delays no other card. The rail reports visible cards through an IntersectionObserver; the presenter turns them plus the pinned hero into the wanted set; the service resolves each through StreamResolverService.loadEpgForItems, the same call the See all pages make. A shimmer placeholder shows only before a card's first answer. Follow-up to #1637. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 KiB
18 KiB
Workspace Dashboard
This document records the current dashboard implementation inside the workspace shell.
Related:
Summary
- The dashboard is the default
/workspacelanding page. - It is a rail-based content surface (Netflix / Apple TV pattern), not a customizable widget grid.
- Layout order is static and curated — there is no edit mode, drag-drop, or
size stepper. Each rail has a persisted show/hide toggle
(
Settings.dashboardRails,DashboardRailsSettingsinlibs/shared/interfaces/src/lib/settings.interface.ts, surfaced under Settings → Dashboard); every template rail is gated bydashboardRails().<key>. Rails additionally auto-hide when empty. - First-run users see the shared welcome empty-state with a single primary CTA to add their first playlist.
Core implementation:
libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts— the page-level facade.libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts— the reusable horizontal rail.libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts— data aggregation (recent items, favorites, playlist stats). Shared across rails.libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts— reused welcome state with the primary "Add your first playlist" CTA.
Page Structure
┌─────────────────────────────────────────────────────────────────────┐
│ Hero — Continue Watching (most recent item) │
├─────────────────────────────────────────────────────────────────────┤
│ Continue Watching · See all → │
│ [poster][poster][poster][poster] →→ │
├─────────────────────────────────────────────────────────────────────┤
│ Live now on your favorites · See all → │
│ [channel][channel][channel][channel] →→ │
├─────────────────────────────────────────────────────────────────────┤
│ Recently watched live TV · See all → │
│ [channel][channel][channel][channel] →→ │
├─────────────────────────────────────────────────────────────────────┤
│ Favorite movies & series · See all → │
│ [poster][poster][poster][poster] →→ │
├─────────────────────────────────────────────────────────────────────┤
│ Recently Used Sources · See all → │
│ [tile][tile][tile][tile] →→ │
├─────────────────────────────────────────────────────────────────────┤
│ Recently Added on Xtream (aggregated across providers) │
│ [poster][poster][poster] →→ │
├─────────────────────────────────────────────────────────────────────┤
│ Because you watched X (TMDB, opt-in, Electron-only) │
│ [poster][poster][poster] →→ │
├─────────────────────────────────────────────────────────────────────┤
│ Trending this week (TMDB, opt-in, Electron-only) │
│ [poster][poster][poster] →→ │
└─────────────────────────────────────────────────────────────────────┘
Render rules:
- Dashboard rails render independently as their data sources resolve. The
page no longer uses
dashboardReady()as a page-wide skeleton gate. Initial hero/recent/favorites loading states render scoped skeletons so one slow rail does not hide already available content. hasPlaylists() === false→ render<app-empty-state [type]="'welcome-dashboard'">full-bleed. All rails and the hero are skipped.hero()=globalRecentItems()[0]. If present, render the hero panel.- Each rail is emitted via
@if (cards.length > 0). Empty rails are hidden — there is no "empty widget" placeholder. - The continue-watching hero prefers a stored Xtream
backdrop_url; when it is missing the UI falls back to a blurred poster treatment instead of showing a flat panel. - Live favorites are promoted into their own live rail; movie/series
favorites render in a separate
Favorite movies & seriesrail (favoriteMoviesAndSeriesCards,data-test-id="dashboard-favorite-vod-rail", mapped fromglobalFavoriteItems()filtered to movie/series). Full mixed favorites management stays on/workspace/global-favorites. - The live favorites rail keeps its scoped skeleton until the initial global favorites load has completed for both Xtream-backed and playlist-backed favorites. This avoids first-paint partial counts such as a single Stalker favorite appearing before M3U favorites finish resolving.
Rail Contract
DashboardRailComponent is purely presentational:
- Inputs:
label,items: DashboardRailCard[], optionalseeAllLink, optionalaspectRatio(default'2 / 3'), optionaltestId. - Behavior: horizontal flex track with
scroll-snap-type: x mandatory. - Chevron buttons fade in on hover (desktop only via
@media (hover: none)). - Cards are keyboard-focusable router links;
scroll-snap-align: startmeans arrow-key nav lands on card boundaries. - Image handling:
loading="lazy",decoding="async", fallback icon tile whenimageUrlis missing orerrorfires. - Dashboard hero, rail containers, rail cards, and "Manage all" links expose
stable
data-test-idhooks. Treat these as the supported Electron E2E selector surface; do not target internal CSS class names.
Data Flow
WorkspaceDashboardRailsComponentinjectsDashboardDataService.- It derives the dashboard surface via
computed():hero— first item ofglobalRecentItems().continueWatchingCards— mapsglobalRecentVodItems()to movie/series cover cards. Portal playback positions are bulk-loaded per playlist so hero and cards can show progress, remaining time, and series season/ episode badges. This includes Stalker VOD activity normalized to series throughis_series. Series lookup uses keyed maps for both direct episode ids and parent series ids; card renders must not scan the full playback-position map. The badge uses savedseasonNumber/episodeNumbermetadata and does not infer it from provider payloads; legacy rows without that metadata remain badge-less until replay. Dashboard-originated Xtream series clicks also carry that exact episode target through the global-recent inline-detail handoff. Once the series metadata and playback positions load, the detail player consumes the target once and resumes the saved episode. Opening the same item normally from the global recent grid remains a detail-only action.liveFavoriteCardsEnrichedandrecentLiveCardsEnriched— two independent rails (dashboard-live-favorites-railanddashboard-recent-live-rail); there is no fallback from one to the other. M3U cards carry anepg_lookup_keyusing 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. That XMLTV lookup is one batched local query for every live card and re-runs on the 30 s tick. 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 livePortalActivityIteminto theUnifiedCollectionItemthe collection pages handStreamResolverService.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 asliveEpgSourceKey.lib-dashboard-railreports the cards inside its track viewport (plus ~one card ofrootMargin) throughvisibleCardsChanged, from anIntersectionObserverrooted 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 callsDashboardPortalLiveEpgService.sync()with exactly those entries — on every change, on the 30 s tick, and on a display-offset change.DashboardPortalLiveEpgService(root) owns the queue: at most two requests in flight, 200 ms between starts (the numbersEpgQueueServiceproved against real panels), one card per request, each answer published the moment it lands inprograms, 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. Answers live 60 s; a failed portal is left alone for 30 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). Every answer is "at the provider clock", so a changed display offset or a changed XMLTV source set drops them all.enrichLiveCardsprefers the portal answer, falls back to the XMLTV title match when the portal said "nothing on air", and marks a cardnowPlayingState: '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.
xtreamRecentlyAddedCards— mapsxtreamRecentlyAddedItems()to rail cards. Aggregates newly added VOD and series across all Xtream playlists viaDashboardDataService.reloadXtreamRecentlyAddedItems(), which callsgetGlobalRecentlyAdded('all', limit, 'xtream')with the DB-levelplaylists.type = 'xtream'filter. The rail is Electron-only (PWA returns[]) and auto-hides when empty, so users without Xtream playlists never see it. Cards carry aplaylist_name · typesubtitle so users can tell which provider each item came from. Driven by an effect that re-runs whenever the Xtream playlist count changes, but the first run waits forglobalFavoritesLoaded()so the slower recently-added DB query does not block the live favorites rail on startup.recommendationCards/trendingCards— the two TMDB rails. Both need the TMDB opt-in AND the Electron DB worker that answersDB_MATCH_TITLES(each is hidden in the PWA), and both load afterglobalFavoritesLoaded()so the batched title match never competes for the worker at startup.recommendationCardsis seeded from recently watched movies/series and only shows titles present in an imported library, hiding itself below five matched cards; its rail label names the seed ("Because you watched X") when exactly one seed contributed.trendingCardsshows TMDB's weekly trending and falls back to a prefilled global search for unmatched titles. Contracts:docs/architecture/tmdb-metadata-enrichment.md("Dashboard Integration").sourceCards— mapsrecentPlaylists()to rail cards.recentPlaylists()ranks M3U, Xtream, and Stalker sources by their latest recent activity fromglobalRecentItems(), then falls back to playlistupdateDate/importDatefor sources that have never been used.
DashboardDataServiceis passive on construction. The dashboard feature owns the initial reloads for recent items, favorites, and Xtream recently added rows on page entry.- No dashboard-local
Layoutstate, no localStorage keys, no migrations. Per-rail visibility is the one persisted preference, and it lives in the global settings store (Settings.dashboardRails), not in a dashboard-owned layout blob. - Navigation state + deep-link targets come from the existing
getRecentItemLink()/getGlobalFavoriteLink()/getPlaylistLink()helpers onDashboardDataServiceand reuse the workspace navigation helpers in@iptvnator/portal/shared/util. - Xtream VOD and series detail pages opportunistically backfill
content.backdrop_urlwhen metadata exposes a backdrop, but that write must not refresh recently viewed ordering by itself. - The dashboard feature triggers a fresh reload of DB-backed recent/favorite rows on dashboard entry so newly backfilled backdrop data is visible as soon as the user returns from a detail page.
- Playback-position reloads are keyed by the VOD/series recent set and should
call
reloadPlaybackPositions()throughuntracked()so live-only recent changes do not trigger unnecessary IPC round-trips. - Electron M3U dashboard favorites should use
PlaylistsService.getM3uFavoriteChannels()first. That method checks the SQLite playlist migration flag and then callsdbGetAppPlaylistFavoriteChannels(playlistId), letting the DB worker return only matched favorite channels instead of sending the full playlist payload back to the renderer. If the bridge method is missing or migration is incomplete, the dashboard falls back to the full playlist read. - Electron playlist summary loads should use
dbGetAppPlaylistMetas()throughPlaylistsService.getAllPlaylists(). This keeps dashboard/source/sidebar startup on a metadata-only SQLite path and avoids parsing full M3Upayloadblobs for surfaces that only need playlist title, type, counts, favorites, recent activity, and source connection fields. Workflows that need channel payloads still callgetPlaylistById().
Empty State
The welcome state is rendered via the existing
EmptyStateComponent (type="welcome-dashboard") from
libs/playlist/shared/ui:
- Illustration + headline + description from the existing M3U welcome
strings (
HOME.PLAYLISTS.WELCOME_*). - Primary button emits
addPlaylistClicked. The dashboard page wires this toWORKSPACE_SHELL_ACTIONS.openAddPlaylistDialog(). - Feature chips (M3U / Xtream / Stalker) are provided by the component.
UX Rules
- Rails represent content the user is likely to resume, not provider internals. Never surface raw API objects.
- Each rail must auto-hide when its data source is empty.
- Image assets must degrade to a typed icon fallback — never show broken images or empty tiles.
- The page must never show "No widgets" style text. If there is no content and no playlists, render the welcome state; otherwise render whatever rails have data.
- Navigation from a rail card must deep-link into the appropriate workspace route without switching the active playlist in the header switcher.
- Xtream series hero/Continue Watching clicks with a saved episode position must resume that exact episode while preserving the collection-owned detail and Back behavior. Do not apply autoplay to ordinary collection-grid clicks.
Recently Used Sourcesreflects recent source usage across all provider types, not just recent imports.- The live rail title key must match the rendered source: favorites use
WORKSPACE.DASHBOARD.LIVE_FAVORITES; the recently-watched-live rail usesWORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV(liveRailTitleKeyForSourceinrails/dashboard-rail.utils.ts).
Adding Or Changing Rails
Current workflow:
- Add a new
computed()signal for the card list inWorkspaceDashboardRailsComponent, mapping your source data toDashboardRailCard. - Drop a
<lib-dashboard-rail>in the template, gated by@if (cards.length > 0). - If the data source is new, extend
DashboardDataServicerather than reaching into DB services directly from the component. - Provide a
seeAllLinkonly if there is a dedicated "manage all" route for that content type.
Deferred Work
Intentionally out of scope:
- Customizable layout (drag/drop, resize, freeform reordering). The rail
order stays curated and opinionated. (Per-rail show/hide toggles have
since shipped via
Settings.dashboardRails— see Summary.) - Freeform widget grid with collision management.
- External data rails such as RSS, sports, or news adapters.
- Per-user A/B variants of rail ordering.