Files
iptvnator/docs/architecture/workspace-dashboard.md
d8229b98fa feat(playback): record recently viewed only after the stream plays (#1732)
* feat(playback): record recently viewed only after the stream plays

A channel, movie or episode used to enter Recently Viewed (and the
dashboard's Continue Watching hero) the moment it was selected or its
link was resolved, so streams that failed straight away cluttered the
history.

Writers now defer the write to a root PlaybackHistoryGate, keyed by the
stream URL and/or the playback session key. The inline players confirm
those keys once the owned engine's position has advanced by two seconds
(seeks, stalls, pauses and a previous stream's progress do not count),
the radio player does the same, and a launched MPV/VLC session confirms
on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on
selection. Covers M3U (live, radio, movie detail), Stalker (live, radio,
VOD, series), Xtream VOD and series, and the global live collection.

The M3U host's embeddedPlayback is now compared by value: the history
write updates the playlist meta mid-playback, and a new but identical
playback object remounted the engine and restarted the stream.

E2E flows that relied on recording-on-click now play local fixtures
(HLS/TS/WebM routed in place of unreachable or public streams) and wait
for confirmed playback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): tighten recently viewed confirmation per review

- Correlate by session key first: when both the deferred write and the
  confirmation carry a playbackSessionKey, only that is compared, so the
  same stream URL played in another playlist no longer records a failed
  attempt. URLs remain the fallback (portal writes, MPV/VLC sessions).
  The M3U radio player now receives the host's session key.
- Count only playing progress: engines report `playing` (not paused, not
  seeking) with each time update, so short seeks of paused media no
  longer confirm a view.
- Xtream: a write confirmed after a playlist switch still saves to its own
  playlist but no longer replaces the current playlist's recent list.
- Global live tab: a row confirmed after another row was selected still
  moves to the top of an open Recently Viewed list (only a disposed tab
  skips the notification).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): confirm session-keyed history only by its own session

A write deferred with a playback session key (M3U) is now confirmed only
by that key. The app-wide MPV/VLC session confirmation carries just the
URL, so opening the same stream externally from another playlist could
still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is
instead confirmed by the WebPlayerViewComponent that requested it, under
its own session key, once the launch has opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(playback): keep the history gate off the initial bundle

The `@iptvnator/services` barrel ships in the initial bundle, so adding
PlaybackHistoryGate there (and subscribing to it from the app-wide
ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes.

- Move the gate to a new lazy-only `playback-data-access` project
  (`@iptvnator/playback/data-access`; scope:shared, domain:playback,
  type:data-access) and register it in the coverage policy.
- The gate subscribes to MPV/VLC session updates itself; it is created by
  the first deferred write, which precedes the launch it waits for.
  ExternalPlaybackService is back to master.
- The Xtream "playlist switched before confirmation" check moves to the
  lazy helper; the initial-path store only takes a `skipListRefresh` flag.

Net effect on this branch: +27 bytes over master (master itself is
108 bytes over the ratchet baseline already).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(playback): drop late Xtream confirmations off the initial path

The Xtream store ships in the initial bundle, so even the small
`skipListRefresh` flag cost 27 bytes there. A confirmation can only
arrive after a switch to another playlist from a slow MPV/VLC launch
(the inline player goes with the page), so the lazy helper now drops it
instead: recording it would misfile the item or replace the other
playlist's recent list. with-recent-items is back to master.

This branch is now 3 bytes below master on renderer.initialBytes; the
ratchet still reports master's pre-existing overage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(playback): pass the spec type-check gate from master

- playback-data-access: align tsconfig.spec.json with the epg-data-access
  config #1705 updated (bundler resolution, global.d.ts for window.electron).
- M3U recent-history spec: type the selectSignal override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep late Xtream confirmations in their own playlist history

A confirmation that arrives after a switch to another playlist (a slow
MPV/VLC launch) is no longer dropped: the lazy helper saves it to the
captured playlist through the data source, without reloading the store's
recent list, which belongs to the other playlist by then. The store and its
barrel ship in the initial bundle, so the save path stays in the feature
helper; renderer.initialBytes stays under the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): correlate global live-tab history by its session key

The unified Favorites/Recent live tab deferred its history write by stream
URL only, so the same URL played from another playlist could confirm a
failed selection, and a switch to catch-up before confirmation could never
match. It now defers with the tab's playlist-scoped playbackSessionKey (the
key its players confirm with), and the tab's radio player receives it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep MPV/VLC rows of the live tab confirmable by URL

The live tab's session key can only be confirmed by its own inline
players; MPV/VLC confirm the launched URL alone. A row that goes to an
external player (also later, after a double-click) now defers by URL, and
only rows played inline carry the session key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): per-channel M3U history attempts, capability-based Xtream fallback

- M3U: the recently-viewed dedupe key now includes the channel id, so a
  second row of the same URL defers its own write (its session key) and
  is recorded when it plays after the first row failed.
- Xtream late write: key uncached content by Xtream id per
  supportsXtreamSqliteDataSource (the data-source factory's contract), not
  by a generic Electron bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:23:06 +02:00

387 lines
24 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
the first history load runs. 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.
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.
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
advances. Hover, focus inside the hero 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 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`.
## 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`.
3. Chevron buttons fade in on hover (desktop only via `@media (hover: none)`).
4. Cards are keyboard-focusable router links; `scroll-snap-align: start`
means arrow-key nav lands on card boundaries.
5. Image handling: `loading="lazy"`, `decoding="async"`, fallback icon tile
when `imageUrl` is missing or `error` fires.
6. Dashboard hero, rail containers, rail cards, 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.
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 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'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.