mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
* fix(dashboard): keep the hero height stable across slides The hero laid out only the shown slide, so every automatic rotation between slides of different heights resized the banner and moved every rail below it by 7px, every 8 s on an idle dashboard (a layout-shift score of 0.005 per rotation cycle). Every slide's content now sits in the same grid cell at the bottom of the banner. The hero is therefore as tall as its tallest slide whichever one is shown, and it still grows for an unusually full slide. Inactive slides are inert and visibility: hidden, and carry no test hooks. Enter on the hero follows the shown slide's primary action. The rotation dots keep one fixed width. The active pill is the same 18px bar with its clip-path opened, so a slide change no longer moves the neighbouring dots or the pill itself. The legibility E2E now records which nodes shift. It allows under 0.001 in all, with nothing inside the hero moving. It measures 0 on this branch and fails on master (0.0054). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(dashboard): guard the hero rotation at the narrow width too The rotation guard ran only at the wide width, before any slide was enriched. It now runs a second unattended rotation after the contrast pass, at the narrow width, where slides wrap the most and every slide carries a rating and a two-line overview. On master's hero that second rotation also fails, because its dots move. measureBackdropTextContrast clipped its screenshot to the range of the element's line boxes. A title cut by the 2-line clamp has line boxes below the visible box, so the probe measured the pill row there and scored about 1.0. The Xtream mock gives the recently-added slide a different title per run, so this failed whenever a long one came up. The clip is now the line boxes intersected with the element's own box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
483 lines
30 KiB
Markdown
483 lines
30 KiB
Markdown
# Workspace Dashboard
|
||
|
||
This document records the current dashboard implementation inside the workspace
|
||
shell.
|
||
|
||
Related:
|
||
|
||
- [Workspace Shell](./workspace-shell.md)
|
||
|
||
## Summary
|
||
|
||
- The dashboard is the default `/workspace` landing 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`, `DashboardRailsSettings` in
|
||
`libs/shared/interfaces/src/lib/settings.interface.ts`, surfaced under
|
||
Settings → Dashboard); every template rail is gated by
|
||
`dashboardRails().<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:
|
||
|
||
1. `libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts`
|
||
— the page-level facade.
|
||
2. `libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts`
|
||
— the reusable horizontal rail.
|
||
3. `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`
|
||
— data aggregation (recent items, favorites, playlist stats). Shared across
|
||
rails.
|
||
4. `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 — rotating cinematic banner (resume · live · discovery) │
|
||
├─────────────────────────────────────────────────────────────────────┤
|
||
│ 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:
|
||
|
||
1. 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.
|
||
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
|
||
full-bleed. All rails and the hero are skipped.
|
||
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
|
||
see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while
|
||
it has no slide and any of its sources (history, favorites, Xtream
|
||
recently added) is still on its first load, or a live candidate still
|
||
waits for its first programme answer (portal or XMLTV, for at most
|
||
`DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS`, 2 s, from the hero's creation).
|
||
Dropping it earlier removed the hero and inserted it again when a later
|
||
source featured a title, moving every rail below twice. Once the
|
||
skeleton has gone it does not come back. An item enters recent history only after
|
||
its stream has really played (see "Recently Viewed Confirmation" in
|
||
`embedded-inline-playback.md`), so a channel that failed at once never
|
||
becomes a hero slide.
|
||
4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden
|
||
— there is no "empty widget" placeholder.
|
||
5. Hero slides prefer a stored `backdrop_url`, then the TMDB backdrop; when
|
||
both are missing the poster becomes a blurred wash plus key art on the
|
||
right instead of a flat panel.
|
||
6. Live favorites are promoted into their own live rail; movie/series
|
||
favorites render in a separate `Favorite movies & series` rail
|
||
(`favoriteMoviesAndSeriesCards`, `data-test-id="dashboard-favorite-vod-rail"`,
|
||
mapped from `globalFavoriteItems()` filtered to movie/series). Full mixed
|
||
favorites management stays on `/workspace/global-favorites`.
|
||
7. 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.
|
||
|
||
## Cinematic Hero
|
||
|
||
`DashboardHeroComponent` renders a full-bleed banner: it cancels the page's
|
||
`--dashboard-gutter`/top padding and the centred `--dashboard-max-width`
|
||
(the page host is the `dashboard` inline-size container), keeps
|
||
`clamp(320px, 42vh, 520px)` so the first rail starts above the fold, and uses
|
||
`--app-content-bg` as its scrim so it dissolves into the page in both themes.
|
||
|
||
Height: that clamp is a floor, not a fixed height. Every slide's content is
|
||
laid out in the same grid cell at the bottom of the banner, so it is as tall
|
||
as its tallest slide whichever one is shown, and an unusually full slide or a
|
||
long translation grows it instead of being cut. Only the active slide is
|
||
shown; the others are `inert` and `visibility: hidden`. When only the shown
|
||
slide was in flow, each automatic rotation between slides of different
|
||
heights resized the banner and moved every rail below it, every 8 s on an idle
|
||
dashboard. Late data (TMDB extras, the live slide's first EPG answer, the next
|
||
programme) can still grow the tallest slide, once, when it arrives.
|
||
|
||
Slides (`pickDashboardHeroSources`, at most four, stable order, each title
|
||
once):
|
||
|
||
1. the newest unfinished movie/series (`isPortalPlaybackWatched` rows skip);
|
||
2. a live channel with a programme on air — the first of
|
||
`selectDashboardHeroLiveCandidates` (up to three favourites, then two
|
||
recently watched channels) whose EPG answer has a title;
|
||
3. one favourite movie/series and one Xtream recently-added title;
|
||
4. remaining places round-robin over the next items of those lists;
|
||
5. only when nothing qualifies, the newest history row of any kind (a
|
||
detail action: it can be a finished title).
|
||
|
||
While live candidates exist but none has answered yet, one place stays
|
||
reserved for the live slide, so its late arrival never evicts a slide the
|
||
user may be viewing.
|
||
|
||
The live candidates are derived and pinned by `DashboardLiveEpgPresenter`
|
||
itself (XMLTV lookup and portal queue), independent of the live rails, so the
|
||
slide works with those rails hidden. Actions: a resume slide keeps the resume
|
||
handoff and adds a detail-only "Details" when a series episode can resume;
|
||
discovery slides open the detail page; live slides open the channel. TMDB
|
||
extras (backdrop, rating, genres, overview, year) come from
|
||
`DashboardHeroTmdbService` per featured title and vanish when TMDB is off.
|
||
|
||
Artwork: a title's backdrop, else its poster blurred and scaled past the
|
||
edges (no second, sharp copy); a live channel's logo sits on the right as key
|
||
art over its own wash. Series titles drop their season marker
|
||
(`splitSeasonSuffix`) — the `S1·E1` chip names the season. Chips are
|
||
`app-meta-chip`; the primary is the details pages' light primary
|
||
(`light-primary-button` from `libs/ui/styles`). With no artwork at all the
|
||
stage is a gradient in the title's hue (`--hero-hue`), light in the light
|
||
theme and near-black in the dark one; a dark gradient under the light
|
||
theme's page-coloured scrim read as a grey slab behind dark text.
|
||
|
||
Legibility: slide text stays at 4.5:1 or more over any artwork. The side
|
||
scrim holds 88% of the page colour up to the slide's right edge
|
||
(`--hero-text-edge`: the inset plus `min(560px, 55%)`, the slide's own
|
||
`max-width`) before it opens onto the art. In the narrow layout (`dashboard`
|
||
container ≤ 720px) the slide spans the width, so a full-bleed scrim sits
|
||
behind the text block (90%, fading in just above the eyebrow), the copy gets
|
||
a scrim-coloured text shadow, and the slide enters without a fade so that
|
||
scrim never flashes the art on a rotation. Body text is 85% of the heading
|
||
colour; the rating chip uses `--app-rating-color`, set per theme in
|
||
`m3-theme.scss`. Buttons end long labels in an ellipsis.
|
||
`dashboard-hero-legibility.e2e.ts` replaces every image with a black-and-white
|
||
checkerboard and measures each piece of slide text from the screen in both
|
||
themes, at a wide and a narrow width, for a backdrop, a blurred-poster, a
|
||
no-artwork and a live slide.
|
||
|
||
Semantics: the page has one stable, visually hidden `h1` ("Dashboard",
|
||
`dashboard-page-heading`); each slide title is an `h2`, like the rail titles.
|
||
Slide changes are announced by one polite live region
|
||
(`dashboard-hero-announcement`, position and title) that lives outside the
|
||
slides and is silent while the slides rotate on their own. A
|
||
slide's progress bar is named after its title (a live slide: the programme)
|
||
and a title's reads "N% watched". The dots are 26 × 24px targets (WCAG 2.5.8)
|
||
of one fixed width; the active pill is the same 18px bar with its
|
||
`clip-path` opened, so a slide change moves no dot.
|
||
|
||
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
|
||
advances. The fill animates `transform` only (a bar sliding in under the
|
||
pill's rounded clip), so it runs on the compositor; animating `width` there
|
||
cost a style, layout and paint pass on every frame of an idle dashboard.
|
||
Hover, focus inside the hero, a hidden document and the pause button pause
|
||
it; an explicit Play clears the hover/focus pause until they re-arm; under
|
||
`prefers-reduced-motion` nothing auto-advances. The hero is a focusable
|
||
region: ←/→ switch slides and Enter follows the primary action. The active slide is tracked
|
||
by id, so a late live slide never moves the user off the current one. Test
|
||
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||
`dashboard-hero-dot`, `dashboard-hero-pause`,
|
||
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`. The
|
||
slide hooks mark the shown slide only; the inert slides carry none.
|
||
`dashboard-hero-rotation.e2e.ts` drives the real fill animation (with a
|
||
shortened `--hero-rotation-ms`) to prove its `animationend` still advances
|
||
and that pause holds the slide. `dashboard-hero-legibility.e2e.ts` sums the
|
||
layout shifts of an unattended rotation, at the wide width and again at the
|
||
narrow one once every slide carries a rating and an overview: under 0.001 in
|
||
all, and none inside the hero.
|
||
|
||
## Rail Contract
|
||
|
||
`DashboardRailComponent` is purely presentational:
|
||
|
||
1. Inputs: `label`, `items: DashboardRailCard[]`, optional `seeAllLink`,
|
||
optional `aspectRatio` (default `'2 / 3'`), optional `testId`.
|
||
2. Behavior: horizontal flex track with `scroll-snap-type: x mandatory`.
|
||
The track scrolls back to the start and re-observes its cards only when
|
||
the ids or order of `items` change. Hosts rebuild card objects on every
|
||
clock tick (live progress, expiry badges), and such a rebuild must not
|
||
move a rail the user scrolled.
|
||
3. Chevron buttons fade in on hover (desktop only via `@media (hover: none)`).
|
||
Edge fades follow the chevrons' visibility. The track bleeds
|
||
`--rail-bleed` past the viewport on every side so card focus rings and
|
||
hover lift are not clipped; the fades are offset by the same variable so
|
||
they reach the track's clipping edge and no card strip shows beyond them.
|
||
4. Cards are keyboard-focusable router links; `scroll-snap-align: start`
|
||
means arrow-key nav lands on card boundaries. A card that receives
|
||
keyboard or script focus scrolls fully into the viewport: Chromium skips
|
||
its own focus scroll once 32px of an element shows, so the track's
|
||
`focusin` handler moves to the first card-start snap position revealing
|
||
the whole card (a card wider than the viewport aligns at its own start).
|
||
Focus caused by a press inside the track (within 100ms of `pointerdown`,
|
||
650ms for touch) leaves the rail still, so the card does not slide from
|
||
under the pointer before the click.
|
||
5. Image handling: `loading="lazy"`, `decoding="async"`, fallback icon tile
|
||
when `imageUrl` is missing or `error` fires.
|
||
6. Dashboard hero, rail containers, rail viewports and tracks, rail cards and
|
||
their links, and "Manage all" links expose stable `data-test-id` hooks. Treat these as the supported Electron E2E
|
||
selector surface; do not target internal CSS class names.
|
||
|
||
## Data Flow
|
||
|
||
1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`.
|
||
2. It derives the dashboard surface via `computed()`:
|
||
1. The hero slides — built by `DashboardHeroSlidesPresenter`, see
|
||
[Cinematic Hero](#cinematic-hero).
|
||
2. `continueWatchingCards` — maps `globalRecentVodItems()` 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. Whether an item is looked up as a movie (one `vod`
|
||
row) or a series (episode rows under the parent id) is its WATCH
|
||
kind, `resolvePortalActivityWatchKind`, not its routing `type`. The
|
||
shape that needs the distinction is a Stalker embedded-VOD row: its
|
||
stored entry carries a `series[]` episode array but no `is_series`
|
||
flag, so `extractStalkerItemType` reports `movie` (deliberately — the
|
||
item belongs in the VOD catalog) while its progress lives in episode
|
||
rows. The mappers give both it and a lazy Ministra `is_series` row
|
||
(already typed `series`) `watch_kind: '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 saved `seasonNumber` /
|
||
`episodeNumber` metadata and does not infer it from provider payloads;
|
||
legacy rows without that metadata remain badge-less until replay.
|
||
Dashboard-originated Xtream and Stalker 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.
|
||
Continue Watching cards carry no provider/content-kind subtitle: their
|
||
meta row is the S·E chip plus a "N min left" label (`remainingLabel`,
|
||
from `formatRemainingLabel`), and the row is not rendered when both are
|
||
absent. The hero subtitle is the source name alone, through
|
||
`playlistDisplayLabel`.
|
||
3. `liveFavoriteCardsEnriched` and `recentLiveCardsEnriched` — two
|
||
independent rails (`dashboard-live-favorites-rail` and
|
||
`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. 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.
|
||
One component-provided `DashboardLiveEpgClock` drives every live
|
||
refresh and progress bar on the page. It ticks every 30 s only while
|
||
the XMLTV lookup has cards or the portal presenter wants one, and
|
||
only while the document is visible; it reads the clock at once when
|
||
it starts again. A tick re-reads progress for every live card. It
|
||
re-asks an XMLTV scope only after one of its programmes has ended,
|
||
while a key has no programme, or once the answer is five minutes old
|
||
(`LIVE_EPG_MAX_ANSWER_AGE_MS`), because a guide refreshed elsewhere
|
||
can correct a programme still on air. A guide import or source change
|
||
(`EpgService.epgAvailable$`) re-asks at once. An unchanged answer is
|
||
not re-emitted.
|
||
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 keys and calls
|
||
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
|
||
on every change, on each tick of the shared live-EPG clock, 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's live
|
||
candidates, 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()`,
|
||
which calls `getGlobalRecentlyAdded('all', limit, 'xtream')` with the
|
||
DB-level `playlists.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 the source name as their subtitle
|
||
(`playlistDisplayLabel`) so users can tell where each item was added;
|
||
the content kind is not repeated on every card. Driven by an
|
||
effect that re-runs whenever the Xtream playlist count changes, but the
|
||
first run waits for `globalFavoritesLoaded()` so the slower
|
||
recently-added DB query does not block the live favorites rail on
|
||
startup.
|
||
5. `recommendationCards` / `trendingCards` — the two TMDB rails. Both
|
||
need the TMDB opt-in AND the Electron DB worker that answers
|
||
`DB_MATCH_TITLES` (each is hidden in the PWA), and both load after
|
||
`globalFavoritesLoaded()` so the batched title match never competes
|
||
for the worker at startup. `recommendationCards` is 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. `trendingCards` shows TMDB's weekly trending and
|
||
falls back to a prefilled global search for unmatched titles.
|
||
Contracts: `docs/architecture/tmdb-metadata-enrichment.md`
|
||
("Dashboard Integration").
|
||
6. `sourceCards` — maps `recentPlaylists()` to rail cards. `recentPlaylists()`
|
||
ranks M3U, Xtream, and Stalker sources by their latest recent activity
|
||
from `globalRecentItems()`, then falls back to playlist
|
||
`updateDate` / `importDate` for sources that have never been used.
|
||
3. `DashboardDataService` is passive on construction. The dashboard feature
|
||
owns the initial reloads for recent items, favorites, and Xtream recently
|
||
added rows on page entry.
|
||
4. No dashboard-local `Layout` state, 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.
|
||
5. Navigation state + deep-link targets come from the existing
|
||
`getRecentItemLink()` / `getGlobalFavoriteLink()` / `getPlaylistLink()`
|
||
helpers on `DashboardDataService` and reuse the workspace navigation
|
||
helpers in `@iptvnator/portal/shared/util`.
|
||
6. Xtream VOD and series detail pages opportunistically backfill
|
||
`content.backdrop_url` when metadata exposes a backdrop, but that write
|
||
must not refresh recently viewed ordering by itself.
|
||
7. 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.
|
||
8. Playback-position reloads are keyed by the VOD/series recent set and should
|
||
call `reloadPlaybackPositions()` through `untracked()` so live-only recent
|
||
changes do not trigger unnecessary IPC round-trips.
|
||
9. Electron M3U dashboard favorites should use
|
||
`PlaylistsService.getM3uFavoriteChannels()` first. That method checks the
|
||
SQLite playlist migration flag and then calls
|
||
`dbGetAppPlaylistFavoriteChannels(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.
|
||
10. Electron playlist summary loads should use
|
||
`dbGetAppPlaylistMetas()` through `PlaylistsService.getAllPlaylists()`.
|
||
This keeps dashboard/source/sidebar startup on a metadata-only SQLite path
|
||
and avoids parsing full M3U `payload` blobs for surfaces that only need
|
||
playlist title, type, counts, favorites, recent activity, and source
|
||
connection fields. Workflows that need channel payloads still call
|
||
`getPlaylistById()`.
|
||
|
||
## Empty State
|
||
|
||
The welcome state is rendered via the existing
|
||
`EmptyStateComponent` (`type="welcome-dashboard"`) from
|
||
`libs/playlist/shared/ui`:
|
||
|
||
1. Illustration + headline + description from the existing M3U welcome
|
||
strings (`HOME.PLAYLISTS.WELCOME_*`).
|
||
2. Primary button emits `addPlaylistClicked`. The dashboard page wires this
|
||
to `WORKSPACE_SHELL_ACTIONS.openAddPlaylistDialog()`.
|
||
3. Feature chips (M3U / Xtream / Stalker) are provided by the component.
|
||
|
||
## UX Rules
|
||
|
||
1. Rails represent content the user is likely to resume, not provider
|
||
internals. Never surface raw API objects.
|
||
2. Each rail must auto-hide when its data source is empty.
|
||
3. Image assets must degrade to a typed icon fallback — never show broken
|
||
images or empty tiles.
|
||
4. 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.
|
||
5. Navigation from a rail card must deep-link into the appropriate workspace
|
||
route without switching the active playlist in the header switcher.
|
||
6. Xtream and Stalker 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.
|
||
7. `Recently Used Sources` reflects recent source usage across all provider
|
||
types, not just recent imports.
|
||
8. The live rail title key must match the rendered source: favorites use
|
||
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; the recently-watched-live rail uses
|
||
`WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV`
|
||
(`liveRailTitleKeyForSource` in `rails/dashboard-rail.utils.ts`).
|
||
|
||
## Adding Or Changing Rails
|
||
|
||
Current workflow:
|
||
|
||
1. Add a new `computed()` signal for the card list in
|
||
`WorkspaceDashboardRailsComponent`, mapping your source data to
|
||
`DashboardRailCard`.
|
||
2. Drop a `<lib-dashboard-rail>` in the template, gated by
|
||
`@if (cards.length > 0)`.
|
||
3. If the data source is new, extend `DashboardDataService` rather than
|
||
reaching into DB services directly from the component.
|
||
4. Provide a `seeAllLink` only if there is a dedicated "manage all" route
|
||
for that content type.
|
||
|
||
## Deferred Work
|
||
|
||
Intentionally out of scope:
|
||
|
||
1. 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.)
|
||
2. Freeform widget grid with collision management.
|
||
3. External data rails such as RSS, sports, or news adapters.
|
||
4. Per-user A/B variants of rail ordering.
|
||
|
||
## Source subscription expiry
|
||
|
||
Source cards show a passive subscription-expiry chip: amber within seven days,
|
||
error-toned once expired. Account details stay behind the Account info menu.
|
||
`DashboardSourceExpiryService` in `libs/workspace/dashboard/data-access` reads
|
||
Xtream expiry from cached `PortalStatusService.checkPortalStatusDetails()`
|
||
(`exp_date`). Stalker uses the persisted `stalkerAccountInfo` snapshot from the
|
||
playlist payload, not the metadata row; each source therefore needs one memoized
|
||
full-playlist read. The chip is not a separate account-refresh request.
|
||
The badge only changes at day boundaries, so the rails do not poll the clock:
|
||
`createSourceExpiryClock` arms one timer for the earliest boundary among the
|
||
known facts (`nextSourceExpiryChangeMs`), capped at an hour because timers do
|
||
not follow system sleep. It arms no timer while the page is hidden or the
|
||
sources rail is disabled, and re-reads the clock when the page becomes visible. It schedules from the real
|
||
time, so facts that arrive long after the last tick are not scheduled late.
|
||
Facts whose badge can no longer change arm no timer.
|