Merge origin/master into perf/layout-shift-audit and move the gate list out of the component

Resolves the import conflict with the rotating dashboard hero (#1725), which
moved the hero TMDB service out of the rails component.

Addresses the Codex review on #1738: the 92-line rail-gate initializer grew
the grandfathered rails component from 703 to 795 lines. The rail order and
conditions now live in dashboard-rail-skeletons.ts behind a structural host
interface, the component keeps a single field (+2 lines over master), and a
spec covers the dashboard-specific rules (live favorites suppressed by the
recent-live rail below it, sources gated on playlists, Xtream on having an
Xtream playlist, disabled rails ignored). The seeded-profile timeline on the
merged branch, with the new hero, is still 0.0004 on each of three reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 22:26:18 +02:00
commit 6300a1f66a
68 files changed
+3692 -1162

No files matched your search

@@ -733,15 +733,15 @@ since shipped.)
does not latch and retries instead. Same gating as trending: TMDB
opt-in + Electron DB worker, deferred behind the dashboard's own data.
- **Hero extras**: `DashboardHeroTmdbService`
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
backdrop (when the activity row has none), a rating badge and up to two
genre chips — resolved through the enrichment facade, so items already
opened in a detail view come from the SQLite cache without network.
Results are memoized per lookup identity for the session. The hero renders
immediately from provider data; extras appear when resolved. Series
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
position (no TMDB involved); the watch-progress bar is limited to
movie/series heroes.
(`libs/workspace/dashboard/feature`) patches each movie/series hero slide
with a TMDB backdrop (when the activity row has none), a rating badge, up
to two genre chips, the overview and the release/first-air year — resolved
through the enrichment facade, so items already opened in a detail view
come from the SQLite cache without network. Results are memoized per
lookup identity for the session. Slides render immediately from provider
data; extras appear when resolved and disappear when TMDB is turned off.
Series slides additionally show the tracked "S{n}·E{n}" badge from the
playback position (no TMDB involved). Live slides never query TMDB.
The query is built to **match what the detail view searched with**, not
just what the card displays. A title alone is weaker identity than the
+53 -9
View File
@@ -37,7 +37,7 @@ Core implementation:
```
┌─────────────────────────────────────────────────────────────────────┐
│ Hero — Continue Watching (most recent item) │
│ Hero — rotating cinematic banner (resume · live · discovery) │
├─────────────────────────────────────────────────────────────────────┤
│ Continue Watching · See all → │
│ [poster][poster][poster][poster] →→ │
@@ -73,12 +73,14 @@ Render rules:
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. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel.
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.
4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden
— there is no "empty widget" placeholder.
5. 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.
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"`,
@@ -89,6 +91,47 @@ Render rules:
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:
@@ -109,7 +152,8 @@ Render rules:
1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`.
2. It derives the dashboard surface via `computed()`:
1. `hero` — first item of `globalRecentItems()`.
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/
@@ -161,12 +205,12 @@ Render rules:
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
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, so the
page component only forwards what a rail can see. The queue lives in
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.