feat(workspace): cinematic rotating dashboard hero

Replace the dashboard's card hero with a full-bleed cinematic banner that
rotates between the newest unfinished title, a favourite (or recent)
channel with a programme on air, and a favourite plus a recently added
title.

- New DashboardHeroComponent + DashboardHeroSlidesPresenter; slide
  selection is pure (pickDashboardHeroSources) with stable order, so a
  late live slide never moves the active one.
- Bleeds to the content-area edges, clamp(320px, 42vh, 520px) height, and
  a page-background scrim so it dissolves into the rails in both themes.
- No 16:9 backdrop: blurred poster wash plus sharp key art on the right.
- TMDB extras gain overview and year; live slides show EPG synopsis and
  category. The live EPG presenter derives and pins hero live candidates
  itself, independent of the live rails.
- 8 s rotation driven by the active dot's fill animation; pauses on hover,
  focus and via a pause button; no auto-advance with reduced motion;
  roving arrow keys on the dots.
- Moves the hero out of workspace-dashboard-rails (841 -> 700 lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 18:04:59 +02:00
1 parent 9d02f90dfe
commit 2b28b5ac84
42 files changed
+2910 -1059

No files matched your search

+4 -4
View File
@@ -10,10 +10,10 @@ contracts on its own.
The dashboard renders a surface only when the matching setting is enabled. Data
rails also require the underlying data slice to have at least one item.
- `hero` shows the large top banner for the most recent global item. When that
item is a live TV channel, the hero looks up the current XMLTV programme and
displays the programme title, time range, and EPG progress bar when data is
available.
- `hero` shows the full-width cinematic banner that rotates between the
newest unfinished title, a favourite (or recent) channel with a programme on
air, and favourite / recently added titles. Contract:
`docs/architecture/workspace-dashboard.md#cinematic-hero`.
- `continueWatching` shows recent movies and series from
`DashboardDataService.globalRecentVodItems()` using cover cards with playback
progress when a saved resume position is available.
@@ -0,0 +1,330 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TranslateService } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import type {
PlaybackPositionData,
PortalActivityItem,
PortalAddedItem,
PortalFavoriteItem,
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
import {
DashboardHeroTmdbService,
type DashboardHeroTmdbExtras,
} from './dashboard-hero-tmdb.service';
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
import type { DashboardHeroLiveCandidate } from './dashboard-hero-slides.utils';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
const series: PortalRecentItem = {
id: 1,
title: 'Big Pharma',
type: 'movie',
watch_kind: 'series',
source: 'stalker',
playlist_id: 'p1',
playlist_name: 'rucolor',
category_id: '1',
xtream_id: 11,
poster_url: 'https://img/pharma-poster.jpg',
viewed_at: '2026-09-02',
};
const watchedMovie: PortalRecentItem = {
...series,
id: 2,
title: 'Finished Film',
watch_kind: 'movie',
xtream_id: 12,
viewed_at: '2026-09-01',
};
const channel: PortalFavoriteItem = {
id: 3,
title: 'Match TV',
type: 'live',
source: 'xtream',
playlist_id: 'p2',
category_id: '5',
xtream_id: 33,
poster_url: 'https://img/match-logo.png',
added_at: '2026-08-01',
};
const favoriteFilm: PortalFavoriteItem = {
id: 4,
title: 'Burnley',
type: 'series',
source: 'xtream',
playlist_id: 'p2',
playlist_name: 'http://user:secret@4kgood.org:8080',
category_id: '9',
xtream_id: 44,
release_year: 2023,
added_at: '2026-08-02',
};
const import1: PortalAddedItem = {
id: 5,
title: 'Parallel Stories',
type: 'movie',
source: 'xtream',
playlist_id: 'p3',
category_id: '2',
xtream_id: 55,
backdrop_url: 'https://img/parallel-wide.jpg',
added_at: '2026-09-03',
};
const onAir: DashboardLiveEpgDetails = {
nowPlayingTitle: 'Football: farewell match',
nowPlayingTimeRange: '18:50 – 20:55',
nowPlayingProgress: 35,
nowPlayingDescription: 'Live from Moscow.',
nowPlayingCategory: 'Sport',
};
describe('DashboardHeroSlidesPresenter', () => {
let recentItems: ReturnType<typeof signal<PortalRecentItem[]>>;
let favorites: ReturnType<typeof signal<PortalFavoriteItem[]>>;
let addedItems: ReturnType<typeof signal<PortalAddedItem[]>>;
let candidates: ReturnType<typeof signal<DashboardHeroLiveCandidate[]>>;
let liveDetails: jest.Mock;
let tmdbEnabled: ReturnType<typeof signal<boolean>>;
let getExtras: jest.Mock;
const positions = new Map<string | number, PlaybackPositionData>([
[
1,
{
contentXtreamId: 111,
contentType: 'episode',
seriesXtreamId: 11,
seasonNumber: 1,
episodeNumber: 3,
positionSeconds: 600,
durationSeconds: 1800,
},
],
[
2,
{
contentXtreamId: 12,
contentType: 'vod',
positionSeconds: 5900,
durationSeconds: 6000,
},
],
]);
function create(): DashboardHeroSlidesPresenter {
TestBed.configureTestingModule({
providers: [
DashboardHeroSlidesPresenter,
{
provide: DashboardDataService,
useValue: {
globalRecentLoading: signal(false),
globalRecentItems: recentItems,
globalRecentVodItems: () =>
recentItems().filter((i) => i.type !== 'live'),
globalFavoriteItems: favorites,
xtreamRecentlyAddedItems: addedItems,
getPlaybackPositionForItem: (
item: PortalActivityItem
) => positions.get(item.id) ?? null,
getRecentItemLink: (item: PortalActivityItem) => [
'/recent',
String(item.id),
],
getRecentItemNavigationState: () => ({ resume: true }),
getRecentItemDetailNavigationState: () => ({
resume: false,
}),
getRecentItemResumeNavigation: (
item: PortalActivityItem
) => (item.id === 1 ? { link: [], state: {} } : null),
getGlobalFavoriteLink: (item: PortalActivityItem) => [
'/favorite',
String(item.id),
],
getGlobalFavoriteNavigationState: () => ({ fav: true }),
getRecentlyAddedLink: (item: PortalActivityItem) => [
'/added',
String(item.id),
],
getRecentlyAddedNavigationState: () => ({
added: true,
}),
},
},
{
provide: DashboardLiveEpgPresenter,
useValue: {
heroLiveCandidates: candidates,
heroDetailsFor: liveDetails,
},
},
{
provide: DashboardHeroTmdbService,
useValue: {
isEnabled: () => tmdbEnabled(),
keyFor: (item: PortalActivityItem) => item.title,
getExtras,
},
},
{
provide: TranslateService,
useValue: {
onLangChange: new Subject(),
instant: (key: string, params?: object) =>
params ? `${key} ${JSON.stringify(params)}` : key,
},
},
],
});
return TestBed.inject(DashboardHeroSlidesPresenter);
}
beforeEach(() => {
recentItems = signal([series, watchedMovie]);
favorites = signal([channel, favoriteFilm]);
addedItems = signal([import1]);
candidates = signal([{ origin: 'favorite', item: channel }]);
liveDetails = jest.fn(() => onAir);
tmdbEnabled = signal(false);
getExtras = jest.fn().mockResolvedValue(null);
});
it('builds the rotation from resume, live, favourite and import slides', () => {
const slides = create().slides();
expect(slides.map((slide) => [slide.kind, slide.title])).toEqual([
['continue', 'Big Pharma'],
['live', 'Match TV'],
['favorite', 'Burnley'],
['added', 'Parallel Stories'],
]);
// A (nearly) finished film is not something to continue.
expect(slides.some((slide) => slide.title === 'Finished Film')).toBe(
false
);
});
it('resumes an unfinished series and offers a detail-only way in', () => {
const [resume] = create().slides();
expect(resume).toMatchObject({
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
reasonLabelKey: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
episodeBadge:
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE {"season":1,"episode":3}',
source: 'rucolor',
progress: 33,
// No 16:9 backdrop: the poster becomes the blurred stage.
backdropSource: 'poster',
primaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
link: ['/recent', '1'],
state: { resume: true },
remainingLabel: {
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: 20 },
},
},
secondaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
state: { resume: false },
},
});
});
it('shows a favourite channel with its programme on air now', () => {
const live = create().slides()[1];
expect(live).toMatchObject({
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_LIVE',
reasonLabelKey: 'WORKSPACE.DASHBOARD.HERO_FAVORITE_CHANNEL',
programmeTitle: 'Football: farewell match',
description: 'Live from Moscow.',
category: 'Sport',
timeRange: '18:50 – 20:55',
progress: 35,
primaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_WATCH_LIVE',
link: ['/favorite', '3'],
},
secondaryAction: null,
});
});
it('leaves the live slide out until a candidate has a programme on air', () => {
liveDetails.mockReturnValue(null);
const presenter = create();
expect(presenter.slides().map((slide) => slide.kind)).not.toContain(
'live'
);
});
it('opens discovery slides on their detail page, with a safe source label', () => {
const slides = create().slides();
// The stored name is a URL with credentials: only its host shows.
expect(slides[2].source).toBe('4kgood.org:8080');
expect(slides[2]).toMatchObject({
year: 2023,
primaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
link: ['/favorite', '4'],
},
secondaryAction: null,
});
expect(slides[3]).toMatchObject({
backdropSource: 'backdrop',
primaryAction: { link: ['/added', '5'], state: { added: true } },
});
});
it('patches TMDB extras in, and drops them when TMDB is switched off', async () => {
tmdbEnabled.set(true);
const extras: DashboardHeroTmdbExtras = {
backdropUrl: 'https://tmdb/pharma-wide.jpg',
rating: '7.5',
genres: ['Drama'],
overview: 'A pharmacist enters big business.',
year: 2024,
};
getExtras.mockImplementation((item: PortalActivityItem) =>
Promise.resolve(item.title === 'Big Pharma' ? extras : null)
);
const presenter = create();
TestBed.tick();
await Promise.resolve();
expect(getExtras).toHaveBeenCalledTimes(3);
expect(presenter.slides()[0]).toMatchObject({
backdropUrl: 'https://tmdb/pharma-wide.jpg',
backdropSource: 'backdrop',
rating: '7.5',
genres: ['Drama'],
description: 'A pharmacist enters big business.',
year: 2024,
});
tmdbEnabled.set(false);
expect(presenter.slides()[0]).toMatchObject({
backdropSource: 'poster',
rating: null,
description: null,
});
});
it('falls back to the generated stage when an image fails to load', () => {
const presenter = create();
presenter.markImageFailed('https://img/pharma-poster.jpg');
expect(presenter.slides()[0]).toMatchObject({
backdropUrl: undefined,
backdropSource: 'fallback',
});
});
});
@@ -0,0 +1,354 @@
import {
computed,
effect,
inject,
Injectable,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
import {
playlistDisplayLabel,
resolvePortalActivityWatchKind,
type PlaybackPositionData,
type PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
import {
DashboardHeroTmdbService,
type DashboardHeroTmdbExtras,
} from './dashboard-hero-tmdb.service';
import {
dashboardHeroHue,
resolveDashboardHeroArtwork,
type DashboardHeroAction,
type DashboardHeroSlide,
} from './dashboard-hero.utils';
import {
dashboardHeroItemKey,
pickDashboardHeroSources,
type DashboardHeroSource,
} from './dashboard-hero-slides.utils';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
import {
buildDashboardEpisodeBadge,
formatRemainingLabel,
playbackProgressPercent,
} from './dashboard-playback.utils';
const TYPE_LABEL_KEYS = {
live: 'WORKSPACE.DASHBOARD.TYPE_LIVE',
movie: 'WORKSPACE.DASHBOARD.TYPE_MOVIE',
series: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
} as const;
const PROVIDER_LABEL_KEYS = {
xtream: 'WORKSPACE.DASHBOARD.XTREAM',
stalker: 'WORKSPACE.DASHBOARD.STALKER',
m3u: 'WORKSPACE.DASHBOARD.M3U',
} as const;
/**
* Builds the cinematic hero's rotation slides from the dashboard data:
* which titles are featured (`pickDashboardHeroSources`), their artwork,
* TMDB extras, playback progress, live programme and actions.
*
* Component-provided next to the hero; the live programme answers come from
* the page's `DashboardLiveEpgPresenter`, which already pins the hero's live
* candidates. TMDB extras are fetched per featured title after first paint,
* memoized by `DashboardHeroTmdbService`, and vanish immediately when the
* user opts out of TMDB mid-session.
*/
@Injectable()
export class DashboardHeroSlidesPresenter {
private readonly data = inject(DashboardDataService);
private readonly liveEpg = inject(DashboardLiveEpgPresenter);
private readonly heroTmdb = inject(DashboardHeroTmdbService);
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly failedImages = signal<Record<string, true>>({});
private readonly tmdbExtras = signal<
ReadonlyMap<string, DashboardHeroTmdbExtras | null>
>(new Map());
private readonly requestedTmdbKeys = new Set<string>();
/** First history load still running and nothing to feature yet. */
readonly loading = computed(
() => this.data.globalRecentLoading() && this.slides().length === 0
);
/** The first candidate channel with a programme on air right now. */
private readonly liveSlide = computed(() => {
for (const candidate of this.liveEpg.heroLiveCandidates()) {
const details = this.liveEpg.heroDetailsFor(candidate.item);
if (details?.nowPlayingTitle) {
return { candidate, details };
}
}
return null;
});
private readonly sources = computed(() =>
pickDashboardHeroSources({
continueItems: this.data
.globalRecentVodItems()
.filter(
(item) =>
!isPortalPlaybackWatched(
this.data.getPlaybackPositionForItem(item)
)
),
live: this.liveSlide()?.candidate ?? null,
favorites: this.data
.globalFavoriteItems()
.filter(
(item) => item.type === 'movie' || item.type === 'series'
),
recentlyAdded: this.data.xtreamRecentlyAddedItems(),
mostRecent: this.data.globalRecentItems()[0] ?? null,
})
);
readonly slides = computed<DashboardHeroSlide[]>(() => {
this.languageTick();
const liveDetails = this.liveSlide()?.details ?? null;
return this.sources().map((source) =>
this.toSlide(source, liveDetails)
);
});
constructor() {
effect(() => {
if (!this.heroTmdb.isEnabled()) {
return;
}
const items = this.sources()
.map((source) => source.item)
.filter((item) => item.type !== 'live');
untracked(() => items.forEach((item) => this.loadTmdbExtras(item)));
});
}
markImageFailed(url: string): void {
this.failedImages.update((state) =>
state[url] ? state : { ...state, [url]: true }
);
}
private loadTmdbExtras(item: PortalActivityItem): void {
const key = this.heroTmdb.keyFor(item);
if (this.requestedTmdbKeys.has(key)) {
return;
}
this.requestedTmdbKeys.add(key);
void this.heroTmdb.getExtras(item).then((extras) => {
this.tmdbExtras.update((state) => new Map(state).set(key, extras));
});
}
private toSlide(
source: DashboardHeroSource,
liveDetails: DashboardLiveEpgDetails | null
): DashboardHeroSlide {
const item = source.item;
const isLive = item.type === 'live';
// Single reactive read, gated on the TMDB opt-in
const extras =
!isLive && this.heroTmdb.isEnabled()
? (this.tmdbExtras().get(this.heroTmdb.keyFor(item)) ?? null)
: null;
const details =
source.kind === 'live'
? liveDetails
: isLive
? this.liveEpg.heroDetailsFor(item)
: null;
const position = isLive
? null
: this.data.getPlaybackPositionForItem(item);
const artwork = resolveDashboardHeroArtwork(
{
backdropUrl: item.backdrop_url || extras?.backdropUrl || null,
posterUrl: item.poster_url,
title: item.title,
},
this.failedImages()
);
return {
...artwork,
id: `${source.kind}:${dashboardHeroItemKey(item)}`,
kind: source.kind,
contentType: item.type,
title: item.title,
// Watch kind: a Stalker embedded-VOD show routes as a movie
typeLabelKey:
TYPE_LABEL_KEYS[
isLive
? 'live'
: (resolvePortalActivityWatchKind(item) ?? item.type)
],
reasonLabelKey: this.reasonLabelKey(source),
episodeBadge: buildDashboardEpisodeBadge(
item,
position,
(key, params) => this.translate.instant(key, params)
),
rating: extras?.rating ?? null,
genres: extras?.genres ?? [],
year: extras?.year ?? item.release_year ?? null,
source: playlistDisplayLabel(
item.playlist_name,
this.translate.instant(
item.source
? PROVIDER_LABEL_KEYS[item.source]
: 'WORKSPACE.DASHBOARD.PROVIDER'
)
),
programmeTitle: details?.nowPlayingTitle ?? null,
category: details?.nowPlayingCategory ?? null,
timeRange: details?.nowPlayingTimeRange ?? null,
description: isLive
? (details?.nowPlayingDescription ?? null)
: (extras?.overview ?? null),
progress: isLive
? (details?.nowPlayingProgress ?? null)
: playbackProgressPercent(position),
accentHue: dashboardHeroHue(item.title),
...this.actionsFor(source, position),
};
}
private reasonLabelKey(source: DashboardHeroSource): string {
switch (source.kind) {
case 'continue':
return 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING';
case 'live':
return source.origin === 'favorite'
? 'WORKSPACE.DASHBOARD.HERO_FAVORITE_CHANNEL'
: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED';
case 'favorite':
return 'WORKSPACE.DASHBOARD.HERO_FAVORITE';
case 'added':
return 'WORKSPACE.DASHBOARD.HERO_RECENTLY_ADDED';
case 'recent':
return 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED';
}
}
/**
* Resume slides keep the hero's resume handoff (a saved series episode
* auto-plays) and offer "Details" as the detail-only way in; discovery
* slides open the detail page; live slides open the channel.
*/
private actionsFor(
source: DashboardHeroSource,
position: PlaybackPositionData | null
): Pick<DashboardHeroSlide, 'primaryAction' | 'secondaryAction'> {
switch (source.kind) {
case 'live':
return {
primaryAction:
source.origin === 'favorite'
? watchLiveAction(
this.data.getGlobalFavoriteLink(source.item),
this.data.getGlobalFavoriteNavigationState(
source.item
)
)
: watchLiveAction(
this.data.getRecentItemLink(source.item),
this.data.getRecentItemNavigationState(
source.item
)
),
secondaryAction: null,
};
case 'favorite':
return {
primaryAction: detailsAction(
this.data.getGlobalFavoriteLink(source.item),
this.data.getGlobalFavoriteNavigationState(source.item)
),
secondaryAction: null,
};
case 'added':
return {
primaryAction: detailsAction(
this.data.getRecentlyAddedLink(source.item),
this.data.getRecentlyAddedNavigationState(source.item)
),
secondaryAction: null,
};
case 'continue':
case 'recent': {
const item = source.item;
const link = this.data.getRecentItemLink(item);
const state = this.data.getRecentItemNavigationState(item);
if (item.type === 'live') {
return {
primaryAction: watchLiveAction(link, state),
secondaryAction: null,
};
}
const canResume =
this.data.getRecentItemResumeNavigation(item) !== null;
return {
primaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
icon: 'play_arrow',
link,
state,
remainingLabel: formatRemainingLabel(position),
testId: 'dashboard-hero-primary-action',
},
secondaryAction: canResume
? {
...detailsAction(
link,
this.data.getRecentItemDetailNavigationState(
item
)
),
testId: 'dashboard-hero-secondary-action',
}
: null,
};
}
}
}
}
function watchLiveAction(
link: string[],
state: Record<string, unknown> | undefined
): DashboardHeroAction {
return {
labelKey: 'WORKSPACE.DASHBOARD.HERO_WATCH_LIVE',
icon: 'play_arrow',
link,
state,
testId: 'dashboard-hero-primary-action',
};
}
function detailsAction(
link: string[],
state: Record<string, unknown> | undefined
): DashboardHeroAction {
return {
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
icon: 'info',
link,
state,
testId: 'dashboard-hero-primary-action',
};
}
@@ -0,0 +1,189 @@
import type {
PortalAddedItem,
PortalFavoriteItem,
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import {
dashboardHeroItemKey,
HERO_SLIDE_LIMIT,
pickDashboardHeroSources,
selectDashboardHeroLiveCandidates,
type DashboardHeroSourceInput,
} from './dashboard-hero-slides.utils';
const base = (
id: number,
type: 'live' | 'movie' | 'series',
title: string
) => ({
id,
title,
type,
playlist_id: 'p',
category_id: '1',
xtream_id: id,
source: 'xtream' as const,
});
const recent = (
id: number,
type: 'live' | 'movie' | 'series' = 'movie'
): PortalRecentItem => ({
...base(id, type, `recent-${id}`),
viewed_at: '2026-09-01',
});
const favorite = (
id: number,
type: 'live' | 'movie' | 'series' = 'movie'
): PortalFavoriteItem => ({
...base(id, type, `favorite-${id}`),
added_at: '2026-09-01',
});
const added = (id: number): PortalAddedItem => ({
...base(id, 'movie', `added-${id}`),
added_at: '2026-09-01',
});
const input = (
overrides: Partial<DashboardHeroSourceInput>
): DashboardHeroSourceInput => ({
continueItems: [],
live: null,
favorites: [],
recentlyAdded: [],
mostRecent: null,
...overrides,
});
const summary = (sources: ReturnType<typeof pickDashboardHeroSources>) =>
sources.map((source) => `${source.kind}:${source.item.title}`);
describe('pickDashboardHeroSources', () => {
it('leads with the title to resume, then live, a favourite and an import', () => {
const live = favorite(50, 'live');
expect(
summary(
pickDashboardHeroSources(
input({
continueItems: [recent(1), recent(2)],
live: { origin: 'favorite', item: live },
favorites: [favorite(10), favorite(11)],
recentlyAdded: [added(20), added(21)],
})
)
)
).toEqual([
'continue:recent-1',
'live:favorite-50',
'favorite:favorite-10',
'added:added-20',
]);
});
it('fills the remaining places round-robin and never exceeds the limit', () => {
const sources = pickDashboardHeroSources(
input({
continueItems: [recent(1), recent(2), recent(3)],
favorites: [favorite(10)],
})
);
expect(summary(sources)).toEqual([
'continue:recent-1',
'favorite:favorite-10',
'continue:recent-2',
'continue:recent-3',
]);
expect(sources.length).toBeLessThanOrEqual(HERO_SLIDE_LIMIT);
});
it('features a title once even when it is both unfinished and a favourite', () => {
const shared = recent(1);
expect(
summary(
pickDashboardHeroSources(
input({
continueItems: [shared],
favorites: [{ ...shared, added_at: 'x' }, favorite(10)],
})
)
)
).toEqual(['continue:recent-1', 'favorite:favorite-10']);
});
it('keeps the order stable when the live slide arrives late', () => {
const without = summary(
pickDashboardHeroSources(
input({
continueItems: [recent(1)],
favorites: [favorite(10)],
})
)
);
const withLive = summary(
pickDashboardHeroSources(
input({
continueItems: [recent(1)],
live: { origin: 'recent', item: recent(60, 'live') },
favorites: [favorite(10)],
})
)
);
expect(without).toEqual(['continue:recent-1', 'favorite:favorite-10']);
expect(withLive).toEqual([
'continue:recent-1',
'live:recent-60',
'favorite:favorite-10',
]);
});
it('falls back to the newest history row only when nothing else qualifies', () => {
const channel = recent(70, 'live');
expect(
summary(pickDashboardHeroSources(input({ mostRecent: channel })))
).toEqual(['recent:recent-70']);
expect(
summary(
pickDashboardHeroSources(
input({ mostRecent: channel, favorites: [favorite(10)] })
)
)
).toEqual(['favorite:favorite-10']);
expect(pickDashboardHeroSources(input({}))).toEqual([]);
});
});
describe('selectDashboardHeroLiveCandidates', () => {
it('prefers favourites, adds recent channels and skips duplicates', () => {
const candidates = selectDashboardHeroLiveCandidates(
[
favorite(1, 'live'),
favorite(2, 'live'),
favorite(3, 'live'),
favorite(4, 'live'),
],
[recent(2, 'live'), recent(5, 'live'), recent(6, 'live')]
);
expect(
candidates.map(({ origin, item }) => `${origin}:${item.id}`)
).toEqual(['favorite:1', 'favorite:2', 'favorite:3', 'recent:5']);
});
it('ignores non-live rows', () => {
expect(
selectDashboardHeroLiveCandidates([favorite(1, 'movie')], [])
).toEqual([]);
});
});
describe('dashboardHeroItemKey', () => {
it('identifies a title across the lists it can come from', () => {
expect(dashboardHeroItemKey(recent(1))).toBe(
dashboardHeroItemKey(favorite(1))
);
expect(dashboardHeroItemKey(recent(1))).not.toBe(
dashboardHeroItemKey(recent(1, 'series'))
);
});
});
@@ -0,0 +1,140 @@
import type {
PortalActivityItem,
PortalAddedItem,
PortalFavoriteItem,
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
/** Slides the cinematic hero rotates through, at most. */
export const HERO_SLIDE_LIMIT = 4;
/** Auto-advance interval; the active dot fills over the same duration. */
export const HERO_ROTATION_MS = 8_000;
const HERO_LIVE_FAVORITE_CANDIDATES = 3;
const HERO_LIVE_RECENT_CANDIDATES = 2;
/**
* Why a title is on the hero, which decides its eyebrow and actions:
* - `continue` — unfinished movie/series from the recent history
* - `live` — a favourite (or recently watched) channel with a programme on air
* - `favorite` / `added` — discovery slides from favourites and Xtream imports
* - `recent` — last-resort fallback: the most recent item of any kind, so a
* history with only guide-less live channels still gets a hero
*/
export type DashboardHeroSlideKind =
'continue' | 'live' | 'favorite' | 'added' | 'recent';
export type DashboardHeroLiveCandidate =
| { readonly origin: 'favorite'; readonly item: PortalFavoriteItem }
| { readonly origin: 'recent'; readonly item: PortalRecentItem };
export type DashboardHeroSource =
| { readonly kind: 'continue' | 'recent'; readonly item: PortalRecentItem }
| ({ readonly kind: 'live' } & DashboardHeroLiveCandidate)
| { readonly kind: 'favorite'; readonly item: PortalFavoriteItem }
| { readonly kind: 'added'; readonly item: PortalAddedItem };
export interface DashboardHeroSourceInput {
/** Unfinished movies/series, most recent first. */
readonly continueItems: readonly PortalRecentItem[];
/** The channel picked for the live slide, when one has a programme on air. */
readonly live: DashboardHeroLiveCandidate | null;
/** Favourite movies/series. */
readonly favorites: readonly PortalFavoriteItem[];
readonly recentlyAdded: readonly PortalAddedItem[];
/** The newest history row, used only when nothing else qualifies. */
readonly mostRecent: PortalRecentItem | null;
}
/** One title, whichever list it came from. */
export function dashboardHeroItemKey(
item: Pick<PortalActivityItem, 'playlist_id' | 'type' | 'xtream_id' | 'id'>
): string {
return `${item.playlist_id}::${item.type}::${item.xtream_id ?? item.id}`;
}
/**
* Channels that may fill the live slide, in preference order: favourites
* first (the slide is "your channel is on air"), then the channels watched
* most recently. The live EPG presenter pins exactly these so their
* programmes are looked up even when their rails are hidden or scrolled.
*/
export function selectDashboardHeroLiveCandidates(
favoriteLive: readonly PortalFavoriteItem[],
recentLive: readonly PortalRecentItem[]
): DashboardHeroLiveCandidate[] {
const seen = new Set<string>();
const candidates: DashboardHeroLiveCandidate[] = [];
const add = (candidate: DashboardHeroLiveCandidate) => {
const key = dashboardHeroItemKey(candidate.item);
if (candidate.item.type !== 'live' || seen.has(key)) {
return;
}
seen.add(key);
candidates.push(candidate);
};
favoriteLive
.slice(0, HERO_LIVE_FAVORITE_CANDIDATES)
.forEach((item) => add({ origin: 'favorite', item }));
recentLive
.slice(0, HERO_LIVE_RECENT_CANDIDATES)
.forEach((item) => add({ origin: 'recent', item }));
return candidates;
}
/**
* The rotation mix: the title to resume first, then a channel on air now,
* then one favourite and one recent import; remaining places go to the next
* unfinished title, favourite and import in turn. A title never appears
* twice, and the order is stable so a slide arriving late (the live slide
* waits for its EPG answer) slots in without reshuffling the rest.
*/
export function pickDashboardHeroSources(
input: DashboardHeroSourceInput
): DashboardHeroSource[] {
const seen = new Set<string>();
const sources: DashboardHeroSource[] = [];
const push = (source: DashboardHeroSource | null) => {
if (!source || sources.length >= HERO_SLIDE_LIMIT) {
return;
}
const key = dashboardHeroItemKey(source.item);
if (seen.has(key)) {
return;
}
seen.add(key);
sources.push(source);
};
const continueAt = (index: number) => {
const item = input.continueItems[index];
return item ? ({ kind: 'continue', item } as const) : null;
};
const favoriteAt = (index: number) => {
const item = input.favorites[index];
return item ? ({ kind: 'favorite', item } as const) : null;
};
const addedAt = (index: number) => {
const item = input.recentlyAdded[index];
return item ? ({ kind: 'added', item } as const) : null;
};
push(continueAt(0));
push(input.live ? { kind: 'live', ...input.live } : null);
push(favoriteAt(0));
push(addedAt(0));
// Ends once every list has run out; duplicates only skip a place.
for (let index = 1; sources.length < HERO_SLIDE_LIMIT; index++) {
const next = [continueAt(index), favoriteAt(index), addedAt(index)];
if (next.every((source) => source === null)) {
break;
}
next.forEach(push);
}
if (sources.length === 0 && input.mostRecent) {
push({ kind: 'recent', item: input.mostRecent });
}
return sources;
}
@@ -11,7 +11,10 @@ describe('DashboardHeroTmdbService', () => {
backdrop_path: '/serial.jpg',
vote_average: 7.4,
vote_count: 12,
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
genres: [
{ id: 1, name: 'Drama' },
{ id: 2, name: 'Comedy' },
],
};
/**
@@ -88,6 +91,42 @@ describe('DashboardHeroTmdbService', () => {
expect(enrichMovie).not.toHaveBeenCalled();
});
it('maps the backdrop, rating, genres, overview and year for the slide', async () => {
enrichTv.mockResolvedValue({
...tvDetails,
genres: [
{ id: 1, name: 'Drama' },
{ id: 2, name: 'Comedy' },
{ id: 3, name: 'Crime' },
],
overview: ' A pharmacist enters big business. ',
first_air_date: '2024-11-18',
});
const service = createService();
await expect(
service.getExtras({ title: 'Big Pharma', type: 'series' })
).resolves.toEqual({
backdropUrl: expect.stringContaining('/serial.jpg'),
rating: '7.4',
genres: ['Drama', 'Comedy'],
overview: 'A pharmacist enters big business.',
year: 2024,
});
});
it('reports a missing overview and year as null', async () => {
const service = createService();
const extras = await service.getExtras({
title: 'No plot',
type: 'series',
});
expect(extras?.overview).toBeNull();
expect(extras?.year).toBeNull();
});
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
const service = createService();
@@ -256,7 +295,9 @@ describe('DashboardHeroTmdbService', () => {
},
} as DashboardHeroTmdbItem;
expect(service.keyFor(stalkerItem)).not.toBe(service.keyFor(remake));
expect(service.keyFor(stalkerItem)).not.toBe(
service.keyFor(remake)
);
await service.getExtras(stalkerItem);
await service.getExtras(remake);
@@ -1,5 +1,9 @@
import { Injectable, inject } from '@angular/core';
import { TmdbEnrichmentService, tmdbBackdropUrl } from '@iptvnator/services';
import {
TmdbEnrichmentService,
extractYear,
tmdbBackdropUrl,
} from '@iptvnator/services';
import {
DashboardTmdbAttempt,
DashboardTmdbLookupItem,
@@ -12,6 +16,10 @@ export interface DashboardHeroTmdbExtras {
readonly backdropUrl: string | null;
readonly rating: string | null;
readonly genres: readonly string[];
/** TMDB plot, in the enrichment language; null when TMDB has none */
readonly overview: string | null;
/** Release (movie) or first-air (series) year */
readonly year: number | null;
}
/** Everything the hero lookup reads off an activity row */
@@ -20,7 +28,8 @@ export type DashboardHeroTmdbItem = DashboardTmdbLookupItem;
const MAX_HERO_GENRES = 2;
/**
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
* Best-effort TMDB extras for the hero slides (backdrop, rating, genres,
* overview, year).
* Goes through the enrichment facade, so items already opened in a detail
* view resolve from the SQLite cache without network. Results are memoized
* per lookup identity for the session — dashboard revisits skip the IPC
@@ -103,6 +112,9 @@ function toHeroExtras(details: {
vote_average?: number;
vote_count?: number;
genres?: { name?: string }[];
overview?: string;
release_date?: string;
first_air_date?: string;
}): DashboardHeroTmdbExtras {
const rating =
(details.vote_count ?? 0) > 0 && details.vote_average
@@ -116,5 +128,7 @@ function toHeroExtras(details: {
.map((genre) => genre.name)
.filter((name): name is string => Boolean(name))
.slice(0, MAX_HERO_GENRES),
overview: details.overview?.trim() || null,
year: extractYear(details.release_date ?? details.first_air_date),
};
}
@@ -0,0 +1,255 @@
@if (activeSlide(); as active) {
<section
class="hero"
data-test-id="dashboard-hero"
aria-roledescription="carousel"
[attr.aria-label]="'WORKSPACE.DASHBOARD.HERO_LABEL' | translate"
[class.hero--paused]="paused()"
[class.hero--rotating]="canRotate()"
(mouseenter)="setHovered(true)"
(mouseleave)="setHovered(false)"
(focusin)="onFocusIn()"
(focusout)="onFocusOut($event)"
>
<!-- Every slide's backdrop stays mounted so switching crossfades
and the next image is already decoded. -->
<div class="hero__stage" aria-hidden="true">
@for (slide of slides(); track slide.id) {
<div
class="hero__backdrop"
[class.hero__backdrop--active]="$index === activeIndex()"
[class.hero__backdrop--poster]="
slide.backdropSource === 'poster'
"
[class.hero__backdrop--live]="slide.contentType === 'live'"
[style.--hero-hue]="slide.accentHue"
>
<span
class="hero__backdrop-fallback"
[style.background]="
slide.contentType === 'live'
? null
: slide.fallbackBackdropBackground
"
></span>
@if (slide.backdropUrl; as backdropUrl) {
<img
class="hero__backdrop-image"
[src]="backdropUrl"
alt=""
decoding="async"
[attr.loading]="
$index === activeIndex() ? 'eager' : 'lazy'
"
(error)="presenter.markImageFailed(backdropUrl)"
/>
<!-- No 16:9 backdrop: the sharp poster (or channel
logo) becomes key art on the right. -->
@if (slide.backdropSource === 'poster') {
<img
class="hero__backdrop-keyart"
[src]="backdropUrl"
alt=""
decoding="async"
/>
}
}
</div>
}
<span class="hero__scrim"></span>
</div>
@for (slide of [active]; track slide.id) {
<div
class="hero__content"
role="group"
aria-roledescription="slide"
data-test-id="dashboard-hero-slide"
[attr.data-hero-kind]="slide.kind"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.HERO_SLIDE_POSITION'
| translate
: {
index: activeIndex() + 1,
count: slides().length,
}
"
[attr.aria-live]="canRotate() && !paused() ? 'off' : 'polite'"
>
<p class="hero__eyebrow">
<span
class="hero__type"
[class.hero__type--live]="slide.contentType === 'live'"
>{{ slide.typeLabelKey | translate }}</span
>
<span class="hero__eyebrow-sep" aria-hidden="true">·</span>
<span>{{ slide.reasonLabelKey | translate }}</span>
</p>
<h1 class="hero__title">{{ slide.title }}</h1>
<p class="hero__meta" data-test-id="dashboard-hero-badges">
@if (slide.episodeBadge) {
<span class="hero__pill">{{ slide.episodeBadge }}</span>
}
@if (slide.rating) {
<span class="hero__pill hero__pill--rating"
>★ {{ slide.rating }}</span
>
}
@for (genre of slide.genres; track genre) {
<span class="hero__pill">{{ genre }}</span>
}
@if (slide.category) {
<span class="hero__pill">{{ slide.category }}</span>
}
@if (slide.timeRange) {
<span class="hero__pill hero__pill--time">{{
slide.timeRange
}}</span>
}
@if (slide.year) {
<span class="hero__pill">{{ slide.year }}</span>
}
@if (slide.source) {
<span class="hero__pill hero__pill--source">{{
slide.source
}}</span>
}
</p>
@if (slide.programmeTitle) {
<p
class="hero__programme"
data-test-id="dashboard-hero-live-epg"
>
{{ slide.programmeTitle }}
</p>
}
@if (slide.description) {
<p class="hero__description">{{ slide.description }}</p>
}
@if (slide.progress !== null) {
<span
class="hero__progress"
[class.hero__progress--live]="
slide.contentType === 'live'
"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="slide.progress"
>
<i [style.width.%]="slide.progress"></i>
</span>
}
<div class="hero__actions">
@let primary = slide.primaryAction;
<a
class="hero__button hero__button--primary"
[attr.data-test-id]="primary.testId"
[routerLink]="primary.link"
[state]="primary.state ?? {}"
>
<mat-icon aria-hidden="true">{{
primary.icon
}}</mat-icon>
<span>{{ primary.labelKey | translate }}</span>
@if (primary.remainingLabel; as remaining) {
<span class="hero__button-detail"
>·
{{
remaining.key | translate: remaining.params
}}</span
>
}
</a>
@if (slide.secondaryAction; as secondary) {
<a
class="hero__button hero__button--secondary"
[attr.data-test-id]="secondary.testId"
[routerLink]="secondary.link"
[state]="secondary.state ?? {}"
>
<mat-icon aria-hidden="true">{{
secondary.icon
}}</mat-icon>
<span>{{ secondary.labelKey | translate }}</span>
</a>
}
</div>
</div>
}
@if (slides().length > 1) {
<div class="hero__controls">
@if (canRotate()) {
<button
type="button"
class="hero__pause"
data-test-id="dashboard-hero-pause"
[attr.aria-pressed]="userPaused()"
[attr.aria-label]="
(userPaused()
? 'WORKSPACE.DASHBOARD.HERO_PLAY'
: 'WORKSPACE.DASHBOARD.HERO_PAUSE'
) | translate
"
(click)="togglePaused()"
>
<mat-icon aria-hidden="true">{{
userPaused() ? 'play_arrow' : 'pause'
}}</mat-icon>
</button>
}
<div
class="hero__dots"
role="group"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.HERO_LABEL' | translate
"
>
@for (slide of slides(); track slide.id) {
<button
type="button"
class="hero__dot"
data-test-id="dashboard-hero-dot"
[class.hero__dot--active]="$index === activeIndex()"
[attr.aria-current]="
$index === activeIndex() ? 'true' : null
"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.HERO_SHOW_SLIDE'
| translate: { title: slide.title }
"
[attr.tabindex]="$index === activeIndex() ? 0 : -1"
(click)="show($index)"
(keydown)="onDotKeydown($event, $index)"
>
<span
class="hero__dot-fill"
(animationend)="onRotationTick()"
></span>
</button>
}
</div>
</div>
}
</section>
} @else if (presenter.loading()) {
<section
class="hero hero--skeleton"
aria-hidden="true"
aria-busy="true"
data-test-id="dashboard-hero-skeleton"
>
<div class="hero__content">
<span class="hero__skeleton hero__skeleton--eyebrow"></span>
<span class="hero__skeleton hero__skeleton--title"></span>
<span class="hero__skeleton hero__skeleton--meta"></span>
<span class="hero__skeleton hero__skeleton--button"></span>
</div>
</section>
}
@@ -0,0 +1,660 @@
// Cinematic dashboard hero. Bleeds to the edges of the workspace content
// area (the page's `--dashboard-gutter` padding and the centred max-width
// are both cancelled out), keeps a height ceiling so the first rail still
// starts above the fold, and dissolves into the page background instead of
// sitting in a raised card.
//
// The scrim is the page background itself (`--app-content-bg`), so the
// light theme gets a light scrim with dark text and the dark theme the
// reverse, with the same geometry.
// The host renders nothing when there is no slide and no skeleton; with
// `display: contents` it then adds no flex gap to the rails page either.
:host {
display: contents;
}
.hero {
// Offset of the rails' content edge from the content-area edge: the page
// gutter plus whatever the centred max-width leaves on either side.
// `100cqw` is the rails page host (the `dashboard` size container).
--hero-inset: calc(
var(--dashboard-gutter, 28px) +
max(0px, (100cqw - var(--dashboard-max-width, 1440px)) / 2)
);
--hero-scrim: var(--app-content-bg, #161a22);
--hero-heading: var(--app-heading-color, #d8dce8);
--hero-body: color-mix(
in srgb,
var(--app-heading-color, #d8dce8) 72%,
var(--hero-scrim)
);
--hero-pill-bg: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 12%,
transparent
);
// Gold in the dark theme, a deeper amber on the light one: mixed with
// the theme's heading colour rather than detected per theme.
--hero-rating: color-mix(
in srgb,
#f5b400 60%,
var(--app-heading-color, #d8dce8)
);
position: relative;
height: clamp(320px, 42vh, 520px);
// Flush with the content area's top and sides; the rails page's own
// top padding and gutters are cancelled. The negative bottom margin
// tucks the first rail into the fade (the gradient does the spacing).
margin: calc(-1 * var(--dashboard-top-gutter, 24px))
calc(-1 * var(--hero-inset)) -12px;
overflow: hidden;
isolation: isolate;
color: var(--hero-heading);
}
// ── Backdrops ──────────────────────────────────────────────────────────────
.hero__stage {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.hero__backdrop {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 700ms ease;
&--active {
opacity: 1;
}
}
.hero__backdrop-fallback,
.hero__backdrop-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hero__backdrop-image {
display: block;
object-fit: cover;
object-position: center 22%;
transform: scale(1.04);
transition: transform 8s ease-out;
.hero__backdrop--active & {
transform: scale(1);
}
}
// No 16:9 backdrop (Xtream/Stalker often lack one): the poster, blurred and
// scaled, washes its colours across the whole stage…
.hero__backdrop--poster .hero__backdrop-image {
filter: blur(48px) saturate(1.4);
transform: scale(1.35);
opacity: 0.8;
}
.hero__backdrop--poster.hero__backdrop--active .hero__backdrop-image {
transform: scale(1.3);
}
// …and the sharp poster fills the right-hand side as key art, faded into
// that wash, so the slide still shows the actual title artwork.
.hero__backdrop-keyart {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: min(46%, 640px);
object-fit: cover;
object-position: center 18%;
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 45%);
mask-image: linear-gradient(90deg, transparent 0%, #000 45%);
}
// Live channels: a logo is not a backdrop. The stage is a title-tinted
// glow with a hint of the live accent; the logo, when there is one, sits
// on the right as the slide's artwork over a soft wash of its own colours.
.hero__backdrop--live {
.hero__backdrop-fallback {
background:
radial-gradient(
55% 120% at 78% 30%,
hsl(var(--hero-hue, 220) 60% 45% / 0.45),
transparent 70%
),
radial-gradient(
40% 90% at 100% 100%,
color-mix(
in srgb,
var(--app-live-color, #ff8f8f) 28%,
transparent
),
transparent 70%
),
var(--hero-scrim);
}
&.hero__backdrop--active .hero__backdrop-image,
.hero__backdrop-image {
inset: 10% 6% 10% auto;
width: 40%;
height: 80%;
object-fit: contain;
filter: blur(56px) saturate(1.6);
opacity: 0.5;
transform: none;
}
.hero__backdrop-keyart {
top: 50%;
right: calc(var(--hero-inset) + 8%);
width: min(22%, 240px);
height: 50%;
object-fit: contain;
object-position: center;
transform: translateY(-50%);
filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.45));
-webkit-mask-image: none;
mask-image: none;
}
}
.hero__scrim {
position: absolute;
inset: 0;
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--hero-scrim) 96%, transparent) 0%,
color-mix(in srgb, var(--hero-scrim) 85%, transparent) 30%,
color-mix(in srgb, var(--hero-scrim) 35%, transparent) 60%,
transparent 100%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--hero-scrim) 30%, transparent) 0%,
transparent 22%,
transparent 55%,
var(--hero-scrim) 100%
);
}
// ── Slide content ──────────────────────────────────────────────────────────
.hero__content {
position: absolute;
left: var(--hero-inset);
bottom: 34px;
z-index: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
max-width: min(560px, 55%);
animation: hero-content-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@keyframes hero-content-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.hero__eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 11px;
font-weight: 600;
line-height: 1;
letter-spacing: 1.4px;
text-transform: uppercase;
color: var(--hero-body);
}
.hero__type--live {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--app-live-color, #ff8f8f);
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
}
.hero__eyebrow-sep {
opacity: 0.6;
}
.hero__title {
margin: 0;
font-size: clamp(28px, 3.4vw, 44px);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.5px;
color: var(--hero-heading);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
}
.hero__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
font-size: 12px;
line-height: 1.4;
}
.hero__pill {
padding: 3px 8px;
border-radius: 999px;
background: var(--hero-pill-bg);
color: var(--hero-heading);
white-space: nowrap;
&--rating {
color: var(--hero-rating);
font-weight: 600;
}
&--time {
font-variant-numeric: tabular-nums;
}
&--source {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--hero-body);
}
}
.hero__programme {
margin: 0;
max-width: 100%;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
color: var(--hero-heading);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero__description {
margin: 0;
font-size: 13.5px;
line-height: 1.5;
color: var(--hero-body);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.hero__progress {
display: block;
width: 220px;
max-width: 100%;
height: 3px;
border-radius: 2px;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 18%,
transparent
);
i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--app-selection-color, #78adff);
transition: width 0.4s ease;
}
&--live i {
background: var(--app-live-color, #ff8f8f);
}
}
.hero__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.hero__button {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 18px 0 14px;
box-sizing: border-box;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
text-decoration: none;
transition:
background-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
line-height: 20px;
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 2px;
}
&:active {
transform: translateY(1px);
}
&--primary {
background: var(--app-selection-color, #78adff);
color: var(--app-selection-on-color, #071225);
box-shadow: 0 6px 18px -8px
var(--app-selection-glow, rgba(120, 173, 255, 0.6));
&:hover {
background: color-mix(
in srgb,
var(--app-selection-color, #78adff) 88%,
#fff
);
}
}
&--secondary {
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 12%,
transparent
);
color: var(--hero-heading);
border: 1px solid
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 14%, transparent);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
&:hover {
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 20%,
transparent
);
}
}
}
.hero__button-detail {
font-weight: 500;
opacity: 0.8;
}
// ── Rotation controls ──────────────────────────────────────────────────────
.hero__controls {
position: absolute;
right: var(--hero-inset);
bottom: 34px;
z-index: 1;
display: flex;
align-items: center;
gap: 2px;
padding: 2px 4px;
border-radius: 999px;
// Keeps the dots legible over bright key art in both themes.
background: color-mix(in srgb, var(--hero-scrim) 55%, transparent);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.hero__pause {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--hero-body);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
line-height: 18px;
}
&:hover {
background: var(--hero-pill-bg);
color: var(--hero-heading);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 1px;
}
}
.hero__dots {
display: flex;
align-items: center;
}
// 6px dots inside a 20px hit area; the active one stretches into a pill
// whose fill runs for one rotation interval.
.hero__dot {
display: grid;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
&::before {
content: '';
grid-area: 1 / 1;
width: 6px;
height: 6px;
border-radius: 3px;
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 32%,
transparent
);
transition:
width 0.25s ease,
background-color 0.15s ease;
}
&:hover::before {
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 55%,
transparent
);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: -2px;
border-radius: 6px;
}
&--active {
width: 30px;
&::before {
width: 18px;
}
}
}
.hero__dot-fill {
grid-area: 1 / 1;
justify-self: start;
// Aligns with the 18px pill centred in the 30px button.
margin-left: 6px;
width: 0;
height: 6px;
border-radius: 3px;
background: var(--hero-heading);
pointer-events: none;
.hero__dot--active & {
width: 18px;
}
.hero--rotating .hero__dot--active & {
animation: hero-dot-fill var(--hero-rotation-ms, 8000ms) linear both;
}
.hero--paused .hero__dot--active & {
animation-play-state: paused;
}
}
@keyframes hero-dot-fill {
from {
width: 0;
}
to {
width: 18px;
}
}
// ── Skeleton ───────────────────────────────────────────────────────────────
.hero--skeleton {
background: linear-gradient(
180deg,
var(--app-widget-bg, #1e2330) 0%,
var(--hero-scrim) 100%
);
}
.hero__skeleton {
display: block;
border-radius: 6px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 0%,
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 16%, transparent) 50%,
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 100%
);
background-size: 200% 100%;
animation: hero-skeleton-shimmer 1.6s linear infinite;
&--eyebrow {
width: 180px;
height: 11px;
}
&--title {
width: 380px;
max-width: 100%;
height: 40px;
}
&--meta {
width: 260px;
height: 20px;
border-radius: 999px;
}
&--button {
width: 190px;
height: 38px;
border-radius: 10px;
margin-top: 4px;
}
}
@keyframes hero-skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
// ── Narrow content area ────────────────────────────────────────────────────
@container dashboard (max-width: 720px) {
.hero__content {
right: var(--hero-inset);
bottom: 28px;
max-width: none;
}
.hero__controls {
top: 16px;
bottom: auto;
}
.hero__scrim {
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--hero-scrim) 80%, transparent) 0%,
color-mix(in srgb, var(--hero-scrim) 40%, transparent) 100%
),
linear-gradient(180deg, transparent 30%, var(--hero-scrim) 100%);
}
.hero__backdrop-keyart {
width: 70%;
opacity: 0.6;
}
}
@media (prefers-reduced-motion: reduce) {
.hero__backdrop,
.hero__backdrop-image {
transition: none;
}
.hero__content,
.hero__skeleton {
animation: none;
}
}
@@ -0,0 +1,218 @@
import { signal } from '@angular/core';
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DashboardHeroComponent } from './dashboard-hero.component';
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
import type { DashboardHeroSlide } from './dashboard-hero.utils';
const slide = (id: string, title: string): DashboardHeroSlide => ({
id,
kind: 'continue',
contentType: 'movie',
title,
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_MOVIE',
reasonLabelKey: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
episodeBadge: null,
rating: null,
genres: [],
year: null,
source: 'Source',
programmeTitle: null,
category: null,
timeRange: null,
description: null,
progress: null,
accentHue: 200,
backdropSource: 'fallback',
fallbackBackdropBackground: 'none',
fallbackPosterBackground: 'none',
hasBackdrop: false,
primaryAction: {
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
icon: 'play_arrow',
link: ['/workspace', id],
testId: 'dashboard-hero-primary-action',
},
secondaryAction: null,
});
describe('DashboardHeroComponent', () => {
let fixture: ComponentFixture<DashboardHeroComponent>;
let slides: ReturnType<typeof signal<DashboardHeroSlide[]>>;
let reducedMotion: boolean;
const host = () => fixture.nativeElement as HTMLElement;
const activeTitle = () =>
host().querySelector('[data-test-id=dashboard-hero-slide] h1')
?.textContent;
const dots = () =>
Array.from(
host().querySelectorAll<HTMLButtonElement>(
'[data-test-id=dashboard-hero-dot]'
)
);
const finishActiveDot = () => {
const fill = host().querySelector(
'.hero__dot--active .hero__dot-fill'
) as HTMLElement;
fill.dispatchEvent(new Event('animationend'));
fixture.detectChanges();
};
function render(): void {
window.matchMedia = jest.fn().mockReturnValue({
matches: reducedMotion,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
TestBed.configureTestingModule({
imports: [DashboardHeroComponent, TranslateModule.forRoot()],
providers: [provideRouter([])],
});
TestBed.overrideComponent(DashboardHeroComponent, {
set: {
providers: [
{
provide: DashboardHeroSlidesPresenter,
useValue: {
slides,
loading: signal(false),
markImageFailed: jest.fn(),
},
},
],
},
});
fixture = TestBed.createComponent(DashboardHeroComponent);
fixture.detectChanges();
}
beforeEach(() => {
reducedMotion = false;
slides = signal([
slide('a', 'First'),
slide('b', 'Second'),
slide('c', 'Third'),
]);
});
it('advances when the active dot finishes its rotation interval', () => {
render();
expect(activeTitle()).toBe('First');
finishActiveDot();
expect(activeTitle()).toBe('Second');
finishActiveDot();
finishActiveDot();
expect(activeTitle()).toBe('First');
});
it('does not advance while the pointer or focus is inside the hero', () => {
render();
const section = host().querySelector(
'[data-test-id=dashboard-hero]'
) as HTMLElement;
section.dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(section.classList).toContain('hero--paused');
finishActiveDot();
expect(activeTitle()).toBe('First');
section.dispatchEvent(new Event('mouseleave'));
fixture.detectChanges();
finishActiveDot();
expect(activeTitle()).toBe('Second');
});
it('lets the user pause and resume the rotation', () => {
render();
const pause = host().querySelector(
'[data-test-id=dashboard-hero-pause]'
) as HTMLButtonElement;
pause.click();
fixture.detectChanges();
expect(pause.getAttribute('aria-pressed')).toBe('true');
finishActiveDot();
expect(activeTitle()).toBe('First');
pause.click();
fixture.detectChanges();
finishActiveDot();
expect(activeTitle()).toBe('Second');
});
it('switches slides from the dots, with roving arrow keys', () => {
render();
dots()[2].click();
fixture.detectChanges();
expect(activeTitle()).toBe('Third');
expect(dots()[2].getAttribute('aria-current')).toBe('true');
expect(dots()[2].tabIndex).toBe(0);
expect(dots()[0].tabIndex).toBe(-1);
dots()[2].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight' })
);
fixture.detectChanges();
expect(activeTitle()).toBe('First');
});
it('keeps the current slide when a slide arrives late', () => {
render();
dots()[1].click();
fixture.detectChanges();
slides.set([
slide('a', 'First'),
slide('live', 'Live channel'),
slide('b', 'Second'),
slide('c', 'Third'),
]);
fixture.detectChanges();
expect(activeTitle()).toBe('Second');
expect(dots()).toHaveLength(4);
});
it('never auto-rotates under reduced motion, but the dots still work', () => {
reducedMotion = true;
render();
expect(
host().querySelector('[data-test-id=dashboard-hero-pause]')
).toBeNull();
finishActiveDot();
expect(activeTitle()).toBe('First');
dots()[1].click();
fixture.detectChanges();
expect(activeTitle()).toBe('Second');
});
it('renders a single slide without rotation controls', () => {
slides.set([slide('a', 'Only')]);
render();
expect(activeTitle()).toBe('Only');
expect(dots()).toHaveLength(0);
expect(
host()
.querySelector('[data-test-id=dashboard-hero-primary-action]')
?.getAttribute('href')
).toBe('/workspace/a');
});
it('renders nothing when there is nothing to feature', () => {
slides.set([]);
render();
expect(
host().querySelector('[data-test-id=dashboard-hero]')
).toBeNull();
});
});
@@ -0,0 +1,137 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
signal,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
import { HERO_ROTATION_MS } from './dashboard-hero-slides.utils';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/**
* Cinematic dashboard hero: a full-bleed backdrop that rotates between an
* unfinished title, a favourite channel on air now and a few discovery
* picks (see `pickDashboardHeroSources`).
*
* Rotation is driven by the active dot's CSS fill animation: its
* `animationend` advances the slide, so pausing (hover, focus inside the
* hero, the pause button) is just `animation-play-state: paused` and resumes
* where it stopped. With `prefers-reduced-motion` there is no animation and
* therefore no auto-advance; the dots still switch slides.
*
* The active slide is tracked by id, so a slide that arrives late (the live
* slide waits for its EPG answer) never yanks the user off the current one.
*/
@Component({
selector: 'lib-dashboard-hero',
imports: [MatIcon, RouterLink, TranslatePipe],
templateUrl: './dashboard-hero.component.html',
styleUrl: './dashboard-hero.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [DashboardHeroSlidesPresenter],
host: {
'[style.--hero-rotation-ms]': 'rotationMs + "ms"',
},
})
export class DashboardHeroComponent {
readonly presenter = inject(DashboardHeroSlidesPresenter);
readonly slides = this.presenter.slides;
readonly rotationMs = HERO_ROTATION_MS;
private readonly activeId = signal<string | null>(null);
private readonly hovered = signal(false);
private readonly focusWithin = signal(false);
readonly userPaused = signal(false);
readonly reducedMotion = signal(false);
readonly activeIndex = computed(() => {
const index = this.slides().findIndex(
(slide) => slide.id === this.activeId()
);
return index >= 0 ? index : 0;
});
readonly activeSlide = computed(
() => this.slides()[this.activeIndex()] ?? null
);
readonly canRotate = computed(
() => this.slides().length > 1 && !this.reducedMotion()
);
readonly paused = computed(
() => this.userPaused() || this.hovered() || this.focusWithin()
);
constructor() {
const media =
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(REDUCED_MOTION_QUERY)
: null;
if (media) {
this.reducedMotion.set(media.matches);
const onChange = (event: MediaQueryListEvent) =>
this.reducedMotion.set(event.matches);
media.addEventListener('change', onChange);
inject(DestroyRef).onDestroy(() =>
media.removeEventListener('change', onChange)
);
}
}
show(index: number): void {
const slide = this.slides()[index];
if (slide) {
this.activeId.set(slide.id);
}
}
/** The active dot's fill animation finished: time for the next slide. */
onRotationTick(): void {
if (!this.canRotate() || this.paused()) {
return;
}
this.show((this.activeIndex() + 1) % this.slides().length);
}
togglePaused(): void {
this.userPaused.update((paused) => !paused);
}
setHovered(hovered: boolean): void {
this.hovered.set(hovered);
}
onFocusIn(): void {
this.focusWithin.set(true);
}
onFocusOut(event: FocusEvent): void {
const host = event.currentTarget as HTMLElement | null;
const next = event.relatedTarget as Node | null;
if (!host || !next || !host.contains(next)) {
this.focusWithin.set(false);
}
}
/** Roving arrows between the dots, like a tab list. */
onDotKeydown(event: KeyboardEvent, index: number): void {
const count = this.slides().length;
const step =
event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
if (step === 0 || count < 2) {
return;
}
event.preventDefault();
const next = (index + step + count) % count;
this.show(next);
const group = (event.currentTarget as HTMLElement).parentElement;
group?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
}
}
@@ -1,31 +1,49 @@
import type { CollectionContentType } from '@iptvnator/portal/shared/util';
import type { DashboardRemainingLabel } from './dashboard-playback.utils';
import type { DashboardHeroSlideKind } from './dashboard-hero-slides.utils';
export interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly contentType?: CollectionContentType;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
/** A hero call to action: a router link, never an in-place side effect. */
export interface DashboardHeroAction {
readonly labelKey: string;
readonly icon: string;
readonly link: string[];
readonly posterUrl?: string;
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
/** TMDB vote average ("8.1"), patched in async when available */
readonly rating?: string | null;
/** Up to two TMDB genre names, patched in async when available */
readonly genres?: readonly string[];
/** "S2·E5" for series with a tracked episode position */
readonly episodeBadge?: string | null;
/** 0-100 watched, when a resume position is known. */
readonly watchProgress?: number | null;
/** Appended as "· 22m left" on a resume action. */
readonly remainingLabel?: DashboardRemainingLabel | null;
readonly nowPlayingTitle?: string | null;
readonly nowPlayingTimeRange?: string | null;
readonly nowPlayingProgress?: number | null;
readonly testId: string;
}
/** One rotation slide of the cinematic dashboard hero. */
export interface DashboardHeroSlide extends DashboardHeroArtwork {
/** Stable across data refreshes: the active slide is tracked by it. */
readonly id: string;
readonly kind: DashboardHeroSlideKind;
readonly contentType: CollectionContentType;
readonly title: string;
/** "Movie" / "Series" / "Live" */
readonly typeLabelKey: string;
/** Why the slide is here: "Continue watching", "Favourite channel"… */
readonly reasonLabelKey: string;
readonly episodeBadge: string | null;
/** TMDB vote average ("8.1"), patched in async when available */
readonly rating: string | null;
/** Up to two TMDB genre names, patched in async when available */
readonly genres: readonly string[];
readonly year: number | null;
/** Source name (playlist label), never a raw URL or credentials */
readonly source: string;
/** Live: programme on air now, its guide category and "HH:mm – HH:mm" */
readonly programmeTitle: string | null;
readonly category: string | null;
readonly timeRange: string | null;
/** TMDB overview for titles, guide synopsis for live programmes */
readonly description: string | null;
/** 0-100: watched share, or elapsed share of the live programme */
readonly progress: number | null;
/** Title-derived hue for the live slide's glow (0-359) */
readonly accentHue: number;
readonly primaryAction: DashboardHeroAction;
readonly secondaryAction: DashboardHeroAction | null;
}
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
@@ -101,6 +119,11 @@ function buildFallbackBackground(
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
/** Stable hue for a title, shared by every generated fallback surface. */
export function dashboardHeroHue(text: string): number {
return calculateHue(text || 'placeholder');
}
function calculateHue(text: string): number {
let hash = 0;
for (let i = 0; i < text.length; i++) {
@@ -43,6 +43,8 @@ describe('DashboardLiveEpgPresenter', () => {
let getCurrentProgramsForChannels: jest.Mock;
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
let favoriteLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
let recentLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
let dashboardRails: ReturnType<
typeof signal<typeof DEFAULT_DASHBOARD_RAILS_SETTINGS>
>;
@@ -74,6 +76,8 @@ describe('DashboardLiveEpgPresenter', () => {
]);
recentItems = signal<PortalActivityItem[]>([]);
favoriteLiveItems = signal<PortalActivityItem[]>([]);
recentLiveItems = signal<PortalActivityItem[]>([]);
dashboardRails = signal({ ...DEFAULT_DASHBOARD_RAILS_SETTINGS });
portal = {
connect: jest.fn(),
@@ -96,8 +100,8 @@ describe('DashboardLiveEpgPresenter', () => {
playlists,
// The presenter also derives the portal source list.
globalRecentItems: recentItems,
globalFavoriteLiveItems: signal([]),
globalRecentLiveItems: signal([]),
globalFavoriteLiveItems: favoriteLiveItems,
globalRecentLiveItems: recentLiveItems,
},
},
{
@@ -177,30 +181,75 @@ describe('DashboardLiveEpgPresenter', () => {
);
});
it('pins the hero row only while the hero rail shows one', () => {
const heroLive = {
id: 'x-7',
title: 'Hero channel',
type: 'live',
source: 'xtream',
playlist_id: 'p',
category_id: '1',
xtream_id: 7,
} as PortalActivityItem;
recentItems.set([heroLive]);
it('pins the hero live candidates only while the hero is enabled', () => {
const live = (id: number, title: string) =>
({
id: `x-${id}`,
title,
type: 'live',
source: 'xtream',
playlist_id: 'p',
category_id: '1',
xtream_id: id,
}) as PortalActivityItem;
favoriteLiveItems.set([live(7, 'Favourite channel')]);
recentLiveItems.set([live(8, 'Recent channel'), live(7, 'Dup')]);
TestBed.tick();
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith(['xtream::p::7']);
// Favourites first, then recent channels, each channel once.
expect(
presenter
.heroLiveCandidates()
.map(({ origin, item }) => [origin, item.title])
).toEqual([
['favorite', 'Favourite channel'],
['recent', 'Recent channel'],
]);
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([
'xtream::p::7',
'xtream::p::8',
]);
// With the rail hidden nothing is pinned: the first portal row is
// then a favourite, and pinning it would keep asking for a card
// nobody can see.
// With the hero hidden nothing is pinned: a rail card nobody can
// see must not keep the portal queue busy.
dashboardRails.set({
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
hero: false,
});
TestBed.tick();
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([null]);
expect(presenter.heroLiveCandidates()).toEqual([]);
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([]);
});
it('looks up hero candidates even when no live rail is connected', () => {
getCurrentProgramsForChannels.mockImplementation(() =>
of(
new Map<string, EpgProgram | null>([
['ard.de', program('Tagesschau')],
])
)
);
const channel = {
id: 'm-1',
title: 'Das Erste HD',
type: 'live',
source: 'm3u',
playlist_id: 'a',
category_id: '',
xtream_id: 'm-1',
epg_lookup_key: 'ard.de',
} as PortalActivityItem;
favoriteLiveItems.set([channel]);
setup([]);
expect(getCurrentProgramsForChannels).toHaveBeenCalledWith(
['ard.de'],
expect.objectContaining({ sourceUrls: [guideA] })
);
expect(presenter.heroDetailsFor(channel)?.nowPlayingTitle).toBe(
'Tagesschau'
);
});
it('prefers the portal answer and forwards what the portal presenter owns', () => {
@@ -44,6 +44,10 @@ import {
type DashboardLiveEpgLookupGroup,
} from './dashboard-live-epg.utils';
import { RAIL_ITEM_LIMIT } from './dashboard-rail.utils';
import {
selectDashboardHeroLiveCandidates,
type DashboardHeroLiveCandidate,
} from './dashboard-hero-slides.utils';
type ScopeAnswer = {
readonly scopeKey: string;
@@ -105,9 +109,34 @@ export class DashboardLiveEpgPresenter {
return byPlaylistId;
});
private readonly rails = computed(() =>
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
);
/**
* Channels that may fill the hero's live slide. Looked up and pinned
* here, independent of the rails, so the slide works with the live rails
* hidden and never waits for a rail to scroll a card into view.
*/
readonly heroLiveCandidates = computed<DashboardHeroLiveCandidate[]>(() =>
this.rails().hero
? selectDashboardHeroLiveCandidates(
this.data.globalFavoriteLiveItems(),
this.data.globalRecentLiveItems()
)
: []
);
private readonly heroLiveCards = computed(() =>
this.heroLiveCandidates().map(({ item }) =>
buildDashboardLiveEpgCard(item)
)
);
private readonly lookupGroups = computed(() =>
buildLiveEpgLookupGroups(this.cards()?.() ?? [], (card) =>
this.sourceUrlsForCard(card)
buildLiveEpgLookupGroups(
[...this.heroLiveCards(), ...(this.cards()?.() ?? [])],
(card) => this.sourceUrlsForCard(card)
)
);
@@ -131,25 +160,14 @@ export class DashboardLiveEpgPresenter {
{ initialValue: new Map<string, EpgProgram | null>() }
);
private readonly rails = computed(() =>
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
);
/** The live row behind the hero panel, when that rail shows one. */
private readonly heroLiveItem = computed<PortalActivityItem | null>(() => {
const hero = this.data.globalRecentItems()[0] ?? null;
return this.rails().hero && hero?.type === 'live' ? hero : null;
});
// The Xtream/Stalker live rows behind the hero and the two live rails.
// Their programmes come from the portal, asked for lazily per visible
// card; M3U rows stay on the XMLTV batch above.
private readonly portalItems = computed<readonly PortalActivityItem[]>(
() => {
const rails = this.rails();
const hero = this.heroLiveItem();
return [
...(hero ? [hero] : []),
...this.heroLiveCandidates().map(({ item }) => item),
...(rails.liveFavorites
? this.data
.globalFavoriteLiveItems()
@@ -167,18 +185,17 @@ export class DashboardLiveEpgPresenter {
constructor() {
this.portal.connect(this.portalItems);
// The hero sits at the top of the page and is never scrolled into
// view, so its key is wanted regardless of what the rails report.
// Only the hero: the first entry of `portalItems` is a favourite
// when that rail is hidden, and pinning it would keep asking for a
// card nobody can see.
// view, so its candidates are wanted regardless of what the rails
// report. Only those: a rail card nobody can see stays unpinned.
effect(() => {
const hero = this.heroLiveItem();
const heroKey = hero ? buildDashboardPortalLiveEpgKey(hero) : null;
untracked(() => this.portal.setPinnedKeys([heroKey]));
const keys = this.heroLiveCandidates().map(({ item }) =>
buildDashboardPortalLiveEpgKey(item)
);
untracked(() => this.portal.setPinnedKeys(keys));
});
}
/** The live cards whose rails are enabled, hero included. */
/** The live cards whose rails are enabled (hero candidates are added here). */
connect(cards: Signal<readonly DashboardRailCard[]>): void {
this.cards.set(cards);
}
@@ -206,6 +223,11 @@ export class DashboardLiveEpgPresenter {
});
}
/** Current programme of a hero live candidate, or `null`. */
heroDetailsFor(item: PortalActivityItem): DashboardLiveEpgDetails | null {
return this.detailsFor(buildDashboardLiveEpgCard(item));
}
/** `null` when nothing is known about the card's current programme. */
detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null {
if (!card) {
@@ -285,3 +307,19 @@ function mergeAnswers(
}
return merged;
}
/** The fields the EPG lookups read, for a live row that has no rail card. */
function buildDashboardLiveEpgCard(
item: PortalActivityItem
): DashboardRailCard {
return {
id: `hero-live-${item.playlist_id}-${item.xtream_id ?? item.id}`,
title: item.title,
icon: 'live_tv',
contentType: 'live',
link: [],
epgLookupKey: item.epg_lookup_key,
epgPlaylistId: item.playlist_id,
liveEpgSourceKey: buildDashboardPortalLiveEpgKey(item),
};
}
@@ -74,6 +74,10 @@ export interface DashboardLiveEpgDetails {
readonly nowPlayingTitle: string | null;
readonly nowPlayingTimeRange: string | null;
readonly nowPlayingProgress: number | null;
/** Programme synopsis; set only when the guide has one (hero slide). */
readonly nowPlayingDescription?: string;
/** Guide category ("Sport"); set only when the guide has one. */
readonly nowPlayingCategory?: string;
}
/**
@@ -90,6 +94,8 @@ export function buildDashboardLiveEpgDetails(
return null;
}
const description = program.desc?.trim();
const category = program.category?.trim();
const details: DashboardLiveEpgDetails = {
nowPlayingTitle: program.title?.trim() || null,
nowPlayingTimeRange: formatEpgTimeRange(program, offsetMinutes),
@@ -97,6 +103,8 @@ export function buildDashboardLiveEpgDetails(
program,
epgProviderClockMs(nowMs, offsetMinutes)
),
...(description ? { nowPlayingDescription: description } : {}),
...(category ? { nowPlayingCategory: category } : {}),
};
return details.nowPlayingTitle ||
@@ -204,17 +212,19 @@ export function buildLiveEpgLookupGroups(
type DashboardLiveEpgRailSettings = Pick<
DashboardRailsSettings,
'hero' | 'liveFavorites' | 'recentlyWatchedLive'
'liveFavorites' | 'recentlyWatchedLive'
>;
/**
* The rails' live cards whose rails are enabled. Hero candidates are not
* passed here: `DashboardLiveEpgPresenter` derives and pins them itself.
*/
export function buildLiveEpgCardsForEnabledRails(
rails: DashboardLiveEpgRailSettings,
heroLiveCard: DashboardRailCard | null,
liveFavoriteCards: readonly DashboardRailCard[],
recentLiveCards: readonly DashboardRailCard[]
): DashboardRailCard[] {
return [
...(rails.hero && heroLiveCard ? [heroLiveCard] : []),
...(rails.liveFavorites ? liveFavoriteCards : []),
...(rails.recentlyWatchedLive ? recentLiveCards : []),
];
@@ -1,3 +1,8 @@
import {
resolvePortalActivityWatchKind,
type PlaybackPositionData,
type PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
export interface DashboardRemainingLabel {
@@ -83,3 +88,23 @@ export function formatRemainingLabel(
params: { hours, minutes },
};
}
/**
* "S1·E5" for an item whose progress is tracked per episode. Keyed on the
* WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes as a
* movie but still names the episode it is on.
*/
export function buildDashboardEpisodeBadge(
item: Pick<PortalActivityItem, 'type' | 'watch_kind'>,
position: PlaybackPositionData | null,
translate: (key: string, params: Record<string, number>) => string
): string | null {
return resolvePortalActivityWatchKind(item) === 'series' &&
position?.seasonNumber != null &&
position?.episodeNumber != null
? translate('WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', {
season: position.seasonNumber,
episode: position.episodeNumber,
})
: null;
}
@@ -10,198 +10,12 @@
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- Hero: real card if a recent item resolved, skeleton while still
loading the first global recent item, nothing if no recent items
exist. Each block below renders as soon as ITS data is ready —
no longer pinned on the slowest rail's I/O. -->
<!-- Hero: rotating cinematic banner. Renders its own skeleton while
the first history load runs, and nothing when there is nothing
to feature. Each block below renders as soon as ITS data is
ready — no longer pinned on the slowest rail's I/O. -->
@if (dashboardRails().hero) {
@if (hero(); as item) {
<a
class="hero"
data-test-id="dashboard-hero"
[class.hero--with-image]="
!!item.posterUrl || !!item.backdropUrl
"
[class.hero--has-backdrop]="item.hasBackdrop"
[class.hero--has-default-backdrop]="
item.backdropSource === 'fallback'
"
[class.hero--live]="item.contentType === 'live'"
[routerLink]="item.link"
[state]="item.state ?? {}"
>
<span
class="hero__backdrop"
aria-hidden="true"
[style.backgroundImage]="
item.backdropUrl
? 'url(' + item.backdropUrl + ')'
: item.fallbackBackdropBackground
"
></span>
@if (item.backdropUrl; as backdropUrl) {
<img
class="hero__image-probe"
[src]="backdropUrl"
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(backdropUrl)"
/>
}
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__poster">
@if (item.posterUrl; as posterUrl) {
<img
[src]="posterUrl"
[alt]="item.title"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(posterUrl)"
/>
} @else {
<span
class="hero__poster-fallback"
[style.backgroundImage]="
item.fallbackPosterBackground
"
>
<mat-icon>{{ item.icon }}</mat-icon>
</span>
}
</span>
<span class="hero__body">
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
@if (
item.episodeBadge ||
item.rating ||
(item.genres && item.genres.length > 0)
) {
<span
class="hero__badges"
data-test-id="dashboard-hero-badges"
>
@if (item.episodeBadge) {
<span
class="hero__badge hero__badge--episode"
>{{ item.episodeBadge }}</span
>
}
@if (item.rating) {
<span
class="hero__badge hero__badge--rating"
>★ {{ item.rating }}</span
>
}
@for (genre of item.genres; track genre) {
<span class="hero__badge">{{ genre }}</span>
}
</span>
}
@if (
item.contentType === 'live' &&
(item.nowPlayingTitle ||
item.nowPlayingTimeRange ||
(item.nowPlayingProgress !== null &&
item.nowPlayingProgress !== undefined))
) {
<span
class="hero__live-epg"
data-test-id="dashboard-hero-live-epg"
>
<span class="hero__live-epg-topline">
<span class="hero__live-epg-chip"
>LIVE</span
>
@if (item.nowPlayingTimeRange) {
<span class="hero__live-epg-time">{{
item.nowPlayingTimeRange
}}</span>
}
</span>
@if (item.nowPlayingTitle) {
<span class="hero__live-epg-title">{{
item.nowPlayingTitle
}}</span>
}
<span
class="hero__live-epg-progress"
[class.hero__live-epg-progress--idle]="
item.nowPlayingProgress === null ||
item.nowPlayingProgress === undefined
"
aria-hidden="true"
>
<i
[style.width.%]="
item.nowPlayingProgress ?? 0
"
></i>
</span>
</span>
}
@if (
item.watchProgress !== null &&
item.watchProgress !== undefined
) {
<span class="hero__progress" aria-hidden="true">
<span
class="hero__progress-bar"
[style.width.%]="item.watchProgress"
></span>
</span>
<span class="hero__progress-meta">
@if (item.remainingLabel) {
<span>{{
item.remainingLabel.key
| translate
: item.remainingLabel.params
}}</span>
<span class="hero__progress-meta-sep"
>·</span
>
}
<span>{{
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
| translate
: { value: item.watchProgress }
}}</span>
</span>
}
<span class="hero__cta">
<mat-icon>play_arrow</mat-icon>
<span>{{
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING'
| translate
}}</span>
</span>
</span>
</a>
} @else if (data.globalRecentLoading()) {
<section
class="rails-page__skeleton-hero"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-hero-poster"></div>
<div class="rails-page__skeleton-hero-body">
<div class="rails-page__skeleton-hero-title"></div>
<div
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
></div>
<div class="rails-page__skeleton-hero-subtitle"></div>
<div class="rails-page__skeleton-hero-cta"></div>
</div>
</section>
}
<lib-dashboard-hero />
}
<!-- Continue watching (movies + series) -->
@@ -1,8 +1,14 @@
:host {
// Read by the full-bleed hero to cancel the gutters and centring out.
--dashboard-gutter: 28px;
--dashboard-top-gutter: 24px;
--dashboard-max-width: 1440px;
display: block;
padding: 24px 28px 40px;
padding: var(--dashboard-top-gutter) var(--dashboard-gutter) 40px;
box-sizing: border-box;
min-height: 100%;
container: dashboard / inline-size;
}
:host(.rails-page-host--empty) {
@@ -14,7 +20,7 @@
}
.rails-page {
max-width: 1440px;
max-width: var(--dashboard-max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
@@ -32,504 +38,6 @@
overflow: hidden;
}
// ── Hero ────────────────────────────────────────────────────────────────────
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
// poster card + text block. Mirrors content-hero fallback (no dedicated
// backdrop asset available, so we reuse the poster blurred + scaled).
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
gap: 20px;
padding: 16px;
text-decoration: none;
color: inherit;
border-radius: 18px;
overflow: hidden;
isolation: isolate;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 12px 32px -18px var(--app-selection-glow, rgba(120, 173, 255, 0.4));
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
// Tighter than v0.21 (was 160px). Matches the v0.22 mockup's 150px
// poster intent, freeing space for the progress + remaining label
// without growing total hero height.
--hero-poster-width: 140px;
&:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 20px 40px -20px
var(--app-selection-glow, rgba(120, 173, 255, 0.55));
}
// No-image fallback keeps the accent gradient so the hero is never flat.
&:not(.hero--with-image) {
background:
var(
--app-accent-gradient,
linear-gradient(
135deg,
rgba(99, 102, 241, 0.12) 0%,
rgba(168, 85, 247, 0.07) 100%
)
),
var(--app-widget-header-bg, #232840);
}
}
// Layer 0: backdrop. Two modes:
// (1) Real backdrop (`.hero--has-backdrop`): sharp wide image with no blur.
// (2) Blurred-poster fallback: heavy blur + scale so the poster's colours
// wash across the full panel without the portrait edges showing.
.hero__backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center 25%;
background-repeat: no-repeat;
z-index: 0;
pointer-events: none;
// Fallback: blurred poster.
filter: blur(40px) saturate(1.5);
transform: scale(1.5);
opacity: 0.9;
}
.hero--has-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 1;
}
.hero--has-default-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 0.95;
}
.hero__image-probe {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
opacity: 0;
pointer-events: none;
}
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
// right edge (text legibility). Middle stays mostly transparent so the
// backdrop colours show through. Darkens further when a sharp backdrop is
// present, since real photography has more focal variation than a blur wash.
.hero__vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(
90deg,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 55%,
transparent
)
0%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 10%,
transparent
)
45%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 65%,
transparent
)
100%
),
linear-gradient(
to bottom,
transparent 55%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 35%,
transparent
)
100%
);
}
.hero--has-backdrop .hero__vignette {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.2) 35%,
rgba(0, 0, 0, 0.78) 100%
),
linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.45) 100%
);
}
// Layer 2: sharp poster in native 2:3 aspect (no more 16:9 crop).
.hero__poster {
position: relative;
z-index: 2;
width: var(--hero-poster-width);
aspect-ratio: 2 / 3;
border-radius: 12px;
overflow: hidden;
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.1));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.55);
align-self: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.hero__poster-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(
circle at 50% 40%,
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
transparent 60%
);
background-size: cover;
background-position: center;
mat-icon {
font-size: 56px;
width: 56px;
height: 56px;
color: var(--app-selection-color, #78adff);
opacity: 0.75;
}
}
// Live channel logos (small square images) get destroyed by the default
// `object-fit: cover` poster treatment. The `.hero--live` modifier centers
// the logo with breathing room over a tinted backdrop. The blurred
// `.hero__backdrop` already provides the ambient color wash for the wider hero.
.hero--live .hero__poster {
background:
radial-gradient(
circle at 50% 35%,
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
transparent 65%
),
var(--app-widget-bg, #1e2330);
img {
box-sizing: border-box;
object-fit: contain;
padding: 22% 18%;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.45));
}
}
.hero__body {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.hero__title {
margin: 6px 0 4px;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.15;
// Display-weight tracking: looser than -0.02em which crushes glyphs at
// this size (~28px). Matches Apple TV+ "Up Next" card scale.
letter-spacing: -0.015em;
color: var(--app-heading-color, #d8dce8);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.hero--with-image & {
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
}
.hero__subtitle {
margin: 0;
font-size: 0.88rem;
font-weight: 500;
line-height: 1.45;
letter-spacing: 0.002em;
color: var(--app-body-color, #8b93a8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.hero--with-image & {
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
}
.hero__badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.hero__badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--app-body-color, #8b93a8);
background: rgba(128, 138, 160, 0.14);
border: 1px solid rgba(128, 138, 160, 0.22);
.hero--with-image & {
color: rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.35);
border-color: rgba(255, 255, 255, 0.18);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
&--rating {
color: #ffd166;
.hero--with-image & {
color: #ffd166;
}
}
&--episode {
color: #7ddb8a;
.hero--with-image & {
color: #7ddb8a;
}
}
}
.hero__live-epg {
display: flex;
flex-direction: column;
gap: 6px;
width: min(440px, 100%);
margin-top: 12px;
min-width: 0;
}
.hero__live-epg-topline {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.hero__live-epg-chip {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
font-family: var(
--font-mono,
ui-monospace,
'SF Mono',
'JetBrains Mono',
Menlo,
monospace
);
font-size: 0.68rem;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
color: var(--app-live-color, #ff8f8f);
text-transform: uppercase;
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--app-live-color, #ff8f8f);
box-shadow: 0 0 7px var(--app-live-color, #ff8f8f);
}
}
.hero__live-epg-time {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-muted-color, #8891a4);
font-size: 0.78rem;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
.hero--with-image & {
color: rgba(255, 255, 255, 0.78);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
}
}
.hero__live-epg-title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-heading-color, #d8dce8);
font-size: 0.95rem;
font-weight: 650;
line-height: 1.25;
.hero--with-image & {
color: rgba(255, 255, 255, 0.96);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
}
}
.hero__live-epg-progress {
position: relative;
display: block;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 14%,
transparent
);
.hero--with-image & {
background: rgba(255, 255, 255, 0.2);
}
i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--app-live-color, #ff8f8f);
transition: width 0.4s ease;
}
&--idle i {
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 22%,
transparent
);
}
}
// Watch-progress visualisation. Appears between the subtitle and the CTA
// only when a resume position is known — VOD/series only (M3U and live
// channels have no playback positions in the schema).
.hero__progress {
display: block;
position: relative;
margin-top: 12px;
height: 4px;
width: min(320px, 70%);
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-on-surface, #e6e1e5) 14%,
transparent
);
overflow: hidden;
.hero--with-image & {
background: rgba(255, 255, 255, 0.18);
}
}
.hero__progress-bar {
display: block;
height: 100%;
background: var(--app-selection-color, #78adff);
border-radius: 999px;
transition: width 0.4s ease;
}
.hero__progress-meta {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.74rem;
font-weight: 500;
color: var(--app-body-color, #8b93a8);
letter-spacing: 0.005em;
.hero--with-image & {
color: rgba(255, 255, 255, 0.78);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
}
}
.hero__progress-meta-sep {
opacity: 0.5;
}
.hero__cta {
margin-top: 14px;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px 10px 14px;
border-radius: 999px;
background: var(--app-selection-color, #78adff);
color: var(--app-selection-on-color, #071225);
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
letter-spacing: 0.005em;
// Explicit min-height anchors the text baseline regardless of font-smoothing
min-height: 36px;
box-sizing: border-box;
box-shadow: 0 4px 12px -4px
var(--app-selection-glow, rgba(120, 173, 255, 0.5));
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
}
}
// ── Loading skeleton ────────────────────────────────────────────────────────
// Geometry mirrors the real hero + rails so the layout doesn't shift when
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
@@ -555,61 +63,6 @@
}
}
// ── Hero ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
gap: 24px;
padding: 20px;
border-radius: 18px;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
}
.rails-page__skeleton-hero-poster {
@extend %skeleton-shimmer;
width: 160px;
aspect-ratio: 2 / 3;
border-radius: 12px;
align-self: center;
}
.rails-page__skeleton-hero-body {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
min-width: 0;
}
.rails-page__skeleton-hero-title {
@extend %skeleton-shimmer;
width: 60%;
height: 28px;
border-radius: 6px;
}
.rails-page__skeleton-hero-title--short {
width: 35%;
}
.rails-page__skeleton-hero-subtitle {
@extend %skeleton-shimmer;
width: 45%;
height: 14px;
border-radius: 4px;
margin-top: 2px;
}
.rails-page__skeleton-hero-cta {
@extend %skeleton-shimmer;
width: 160px;
height: 36px;
border-radius: 999px;
margin-top: 14px;
}
// ── Rail ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-rail {
display: flex;
@@ -643,10 +96,6 @@
}
@media (prefers-reduced-motion: reduce) {
.rails-page__skeleton-hero-poster,
.rails-page__skeleton-hero-title,
.rails-page__skeleton-hero-subtitle,
.rails-page__skeleton-hero-cta,
.rails-page__skeleton-header,
.rails-page__skeleton-card {
animation: none;
@@ -657,37 +106,18 @@
// ── Responsive ──────────────────────────────────────────────────────────────
@media (max-width: 720px) {
:host {
padding: 16px 16px 32px;
--dashboard-gutter: 16px;
--dashboard-top-gutter: 16px;
padding-bottom: 32px;
}
.rails-page {
gap: 24px;
}
.hero {
--hero-poster-width: 120px;
grid-template-columns: var(--hero-poster-width) minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
// Skeleton hero mirrors real hero at this breakpoint.
.rails-page__skeleton-hero {
grid-template-columns: 120px minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
.rails-page__skeleton-hero-poster {
width: 120px;
}
.rails-page__skeleton-card {
flex: 0 0 min(var(--cover-rail-width, 172px), 140px);
width: min(var(--cover-rail-width, 172px), 140px);
}
.hero__title {
font-size: 1.3rem;
}
}
@@ -289,6 +289,30 @@ describe('EPG enrichment helpers', () => {
nowPlayingProgress: 25,
});
});
it('adds the synopsis and category only when the guide has them', () => {
const now = START_S * 1000;
expect(
buildDashboardLiveEpgDetails(
baseProgram({
desc: ' Live from Moscow. ',
category: 'Sport',
}),
now
)
).toMatchObject({
nowPlayingDescription: 'Live from Moscow.',
nowPlayingCategory: 'Sport',
});
const bare = buildDashboardLiveEpgDetails(
baseProgram({ desc: ' ', category: null }),
now
);
expect(bare).not.toHaveProperty('nowPlayingDescription');
expect(bare).not.toHaveProperty('nowPlayingCategory');
});
});
});
@@ -544,29 +568,7 @@ describe('Live rail helpers', () => {
expect(first).toBe('a::movie::10|b::series::20');
});
it('omits hero cards from live EPG lookup sources when the hero rail is disabled', () => {
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
const favorite = channelCard({
id: 'favorite',
epgLookupKey: 'favorite',
});
const recent = channelCard({ id: 'recent', epgLookupKey: 'recent' });
expect(
buildLiveEpgCardsForEnabledRails(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
hero: false,
},
hero,
[favorite],
[recent]
).map((card) => card.id)
).toEqual(['favorite', 'recent']);
});
it('omits disabled live rails from live EPG lookup sources', () => {
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
const favorite = channelCard({
id: 'favorite',
epgLookupKey: 'favorite',
@@ -578,13 +580,22 @@ describe('Live rail helpers', () => {
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
liveFavorites: false,
recentlyWatchedLive: false,
},
hero,
[favorite],
[recent]
).map((card) => card.id)
).toEqual(['hero']);
).toEqual(['recent']);
expect(
buildLiveEpgCardsForEnabledRails(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
liveFavorites: false,
recentlyWatchedLive: false,
},
[favorite],
[recent]
)
).toEqual([]);
});
});
@@ -4,7 +4,6 @@ import {
computed,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@@ -13,15 +12,11 @@ import {
isStalkerAccountPlaylist,
isXtreamAccountPlaylist,
normalizeDashboardRailsSettings,
type PlaybackPositionData,
playlistDisplayLabel,
resolvePortalActivityWatchKind,
} from '@iptvnator/shared/interfaces';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterLink } from '@angular/router';
import { Router } from '@angular/router';
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
@@ -55,20 +50,18 @@ import {
resolveSourceExpiryBadge,
SOURCE_EXPIRY_TICK_MS,
} from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
import { DashboardRailComponent } from './dashboard-rail.component';
import type {
DashboardRailCard,
DashboardRailActionSelection,
} from './dashboard-rail.component';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import type { DashboardHeroModel } from './dashboard-hero.utils';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
import { resolveDashboardHeroArtwork } from './dashboard-hero.utils';
import { DashboardHeroComponent } from './dashboard-hero.component';
import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import {
buildDashboardEpisodeBadge,
buildPlaybackPositionReloadKey,
formatRemainingLabel,
isContinueWatchingRecentItem,
@@ -94,11 +87,9 @@ import type {
@Component({
selector: 'lib-workspace-dashboard-rails',
imports: [
DashboardHeroComponent,
DashboardRailComponent,
EmptyStateComponent,
MatButtonModule,
MatIcon,
RouterLink,
TranslatePipe,
],
templateUrl: './workspace-dashboard-rails.component.html',
@@ -130,7 +121,6 @@ export class WorkspaceDashboardRailsComponent {
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly heroTmdb = inject(DashboardHeroTmdbService);
private readonly sourceExpiry = inject(DashboardSourceExpiryService);
readonly trendingService = inject(DashboardTrendingService);
readonly recommendationsService = inject(DashboardRecommendationsService);
@@ -143,77 +133,10 @@ export class WorkspaceDashboardRailsComponent {
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
readonly liveRailTitleKeyForSource = liveRailTitleKeyForSource;
readonly failedHeroImages = signal<Record<string, true>>({});
readonly dashboardRails = computed(() =>
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
);
private readonly heroRecentItem = computed(
() => this.data.globalRecentItems()[0] ?? null
);
/** TMDB extras for the current hero item, patched in after first paint */
private readonly heroTmdbExtras = signal<{
key: string;
extras: DashboardHeroTmdbExtras | null;
} | null>(null);
private readonly heroLiveCard = computed<DashboardRailCard | null>(() => {
const item = this.heroRecentItem();
return item?.type === 'live' ? this.toRecentCard(item) : null;
});
readonly hero = computed<DashboardHeroModel | null>(() => {
const item = this.heroRecentItem();
if (!item) {
return null;
}
// Single reactive read, gated on the TMDB opt-in so cached
// extras vanish immediately when the user opts out mid-session
const extrasState = this.heroTmdbExtras();
const extras =
this.heroTmdb.isEnabled() &&
extrasState?.key === this.heroTmdbKey(item)
? extrasState.extras
: null;
const artwork = resolveDashboardHeroArtwork(
{
backdropUrl: item.backdrop_url || (extras?.backdropUrl ?? null),
posterUrl: item.poster_url,
title: item.title,
},
this.failedHeroImages()
);
const position = this.data.getPlaybackPositionForItem(item);
const liveEpgDetails =
item.type === 'live'
? this.liveEpg.detailsFor(this.heroLiveCard())
: null;
const episodeBadge = this.buildEpisodeBadge(item, position);
return {
...artwork,
contentType: item.type,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
subtitle: this.buildHeroSubtitle(item),
title: item.title,
rating: extras?.rating ?? null,
genres: extras?.genres ?? [],
episodeBadge,
watchProgress:
item.type === 'live' ? null : playbackProgressPercent(position),
remainingLabel:
item.type === 'live' ? null : formatRemainingLabel(position),
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
};
});
readonly continueWatchingBaseCards = computed<DashboardRailCard[]>(() => {
this.languageTick();
return this.data
@@ -255,12 +178,11 @@ export class WorkspaceDashboardRailsComponent {
})
);
// The live cards whose rails are enabled; the presenter looks their
// programmes up per XMLTV source scope.
// The live cards whose rails are enabled; the presenter adds the hero's
// live candidates and looks the programmes up per XMLTV source scope.
private readonly enabledLiveCards = computed(() =>
buildLiveEpgCardsForEnabledRails(
this.dashboardRails(),
this.heroLiveCard(),
this.liveFavoriteCards(),
this.recentLiveCards()
)
@@ -405,27 +327,6 @@ export class WorkspaceDashboardRailsComponent {
untracked(() => void this.data.reloadPlaybackPositions());
});
// TMDB extras for the hero (backdrop/rating/genres) — async after
// first paint, staleness-guarded against hero changes in flight.
effect(() => {
const item = this.heroRecentItem();
if (!item || (item.type !== 'movie' && item.type !== 'series')) {
return;
}
const key = this.heroTmdbKey(item);
untracked(() => {
if (this.heroTmdbExtras()?.key === key) {
return;
}
void this.heroTmdb.getExtras(item).then((extras) => {
const current = untracked(() => this.heroRecentItem());
if (current && this.heroTmdbKey(current) === key) {
this.heroTmdbExtras.set({ key, extras });
}
});
});
});
// Subscription-expiry badges for the source cards. Xtream rides the
// shared PortalStatusService cache; Stalker reads the import-time
// snapshot (memoized per playlist), so this stays cheap on re-entry.
@@ -478,12 +379,6 @@ export class WorkspaceDashboardRailsComponent {
this.shellActions.openAddPlaylistDialog(type);
}
markHeroImageFailed(url: string): void {
this.failedHeroImages.update((state) =>
state[url] ? state : { ...state, [url]: true }
);
}
onSourceActionSelected(selection: DashboardRailActionSelection): void {
const playlist = this.data
.playlists()
@@ -576,46 +471,14 @@ export class WorkspaceDashboardRailsComponent {
);
}
/**
* Only the source name under the hero title. Provider kind (Xtream /
* Stalker / M3U) and content kind (movie / series) are the app's own
* taxonomy, not a property of the title, and the badges row already
* says "S1·E5"; a stored playlist name can be a pasted URL with
* credentials or a MAC, so it goes through `playlistDisplayLabel`.
*/
private buildHeroSubtitle(item: GlobalRecentItem): string {
return playlistDisplayLabel(
item.playlist_name,
this.data.getRecentItemProviderLabel(item)
);
}
/**
* "S1·E5" for an item whose progress is tracked per episode. Keyed on
* the WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes
* as a movie but still names the episode it is on.
*/
private buildEpisodeBadge(
item: GlobalRecentItem,
position: PlaybackPositionData | null
): string | null {
return resolvePortalActivityWatchKind(item) === 'series' &&
position?.seasonNumber != null &&
position?.episodeNumber != null
? this.translate.instant(
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
{
season: position.seasonNumber,
episode: position.episodeNumber,
}
)
: null;
}
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
const position = this.data.getPlaybackPositionForItem(item);
const watchProgress = playbackProgressPercent(position);
const episodeBadge = this.buildEpisodeBadge(item, position);
const episodeBadge = buildDashboardEpisodeBadge(
item,
position,
(key, params) => this.translate.instant(key, params)
);
return {
id: this.recentCardId(item),
title: item.title,
@@ -686,10 +549,6 @@ export class WorkspaceDashboardRailsComponent {
};
}
private heroTmdbKey(item: GlobalRecentItem): string {
return this.heroTmdb.keyFor(item);
}
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {
const subtitle = [
item.year !== null ? String(item.year) : null,