feat(dashboard): add configurable dashboard rails

This commit is contained in:
4gray committed 2026-06-14 10:54:57 +02:00
1 parent bc639cd36d
commit 101d9ba162
39 files changed
+1685 -248

No files matched your search

@@ -265,10 +265,8 @@ export class DashboardDataService {
this.globalRecentItems().filter((item) => item.type === 'live')
);
// Favorited live channels — the dashboard's "Live now" rail prefers this
// source so the EPG enrichment lands on the channels users actually care
// about. Falls back to globalRecentLiveItems when this is empty so a
// fresh-install user still sees something useful.
// Favorited live channels — the dashboard's "Live now" rail uses this
// source only. Recently watched live channels have their own dashboard rail.
readonly globalFavoriteLiveItems = computed<DashboardFavoriteItem[]>(() =>
this.globalFavoriteItems().filter((item) => item.type === 'live')
);
+54 -2
View File
@@ -1,3 +1,55 @@
# feature
# Workspace Dashboard Feature
This library was generated with [Nx](https://nx.dev).
This library owns the workspace dashboard rails UI. The dashboard is a
read-only surface over existing playlist, recent, favorites, EPG, and Xtream
catalog data; it should not introduce Electron IPC, SQLite schema, or route
contracts on its own.
## Dashboard Surfaces
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.
- `continueWatching` shows recent movies and series from
`DashboardDataService.globalRecentVodItems()` using cover cards with playback
progress when a saved resume position is available.
- `liveFavorites` shows favorited live TV channels from
`DashboardDataService.globalFavoriteLiveItems()` using the channel layout
with EPG title, time range, and progress when XMLTV data is available.
- `recentlyWatchedLive` shows recently watched live TV channels from
`DashboardDataService.globalRecentLiveItems()` using the same channel layout.
- `favoriteMoviesAndSeries` shows favorited movies and series from
`DashboardDataService.globalFavoriteItems()`, excluding live favorites, using
cover cards.
- `recentSources` shows recently used playlist/source entries.
- `xtreamRecentlyAdded` shows recently added Xtream catalog items.
## Settings
Per-surface visibility lives on `Settings.dashboardRails` as a
`DashboardRailsSettings` object. Every surface defaults to enabled. Stored
settings are deep-merged with `DEFAULT_DASHBOARD_RAILS_SETTINGS` by
`SettingsStore`, so existing users and older partial settings keep newly added
surfaces visible unless they explicitly turn them off.
The global `showDashboard` flag remains a top-level `Settings` property because
workspace startup and route guards already depend on that contract. The Settings
UI groups `showDashboard` and the per-surface checkboxes in the Dashboard
section. When `showDashboard` is off, the per-surface checkboxes are disabled
because the dashboard route itself is hidden.
## Navigation
Rail cards use the navigation state provided by `DashboardDataService` for the
underlying item. Rail "See all" links may also pass router state:
- live rails open the relevant collection on Live TV.
- cover rails for movies/series open Global Recent or Global Favorites on
Movies when movie items are present, otherwise on Series.
This keeps the global collection pages from defaulting to Live TV when a
dashboard rail is clearly about movies or series.
@@ -12,6 +12,7 @@
<a
class="rail__see-all"
[routerLink]="link"
[state]="seeAllState() ?? {}"
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
>
<span class="rail__see-all-label">
@@ -96,6 +96,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
readonly label = input.required<string>();
readonly items = input.required<DashboardRailCard[]>();
readonly seeAllLink = input<string[] | null>(null);
readonly seeAllState = input<Record<string, unknown> | null>(null);
readonly aspectRatio = input<string>('2 / 3');
readonly layout = input<DashboardRailLayout>('cover');
readonly testId = input<string | null>(null);
@@ -14,150 +14,237 @@
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. -->
@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
@if (dashboardRails().hero) {
@if (hero(); as item) {
<a
class="hero"
data-test-id="dashboard-hero"
[class.hero--with-image]="
!!item.posterUrl || !!item.backdropUrl
"
></span>
@if (item.backdropUrl; as backdropUrl) {
<img
class="hero__image-probe"
[src]="backdropUrl"
alt=""
[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"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(backdropUrl)"
/>
}
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__poster">
@if (item.posterUrl; as posterUrl) {
[style.backgroundImage]="
item.backdropUrl
? 'url(' + item.backdropUrl + ')'
: item.fallbackBackdropBackground
"
></span>
@if (item.backdropUrl; as backdropUrl) {
<img
[src]="posterUrl"
[alt]="item.title"
class="hero__image-probe"
[src]="backdropUrl"
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(posterUrl)"
(error)="markHeroImageFailed(backdropUrl)"
/>
} @else {
<span
class="hero__poster-fallback"
[style.backgroundImage]="
item.fallbackPosterBackground
"
>
<mat-icon>{{ item.icon }}</mat-icon>
</span>
}
</span>
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__body">
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
<span class="hero__progress" aria-hidden="true">
<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__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
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.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.PERCENT_WATCHED'
| translate: { value: item.watchProgress }
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING'
| translate
}}</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>
</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>
}
}
<!-- Continue watching (movies + series) -->
@if (continueWatchingCards().length > 0) {
@if (
dashboardRails().continueWatching &&
continueWatchingCards().length > 0
) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
[items]="continueWatchingCards()"
[totalCount]="data.globalRecentVodItems().length"
[seeAllLink]="['/workspace/global-recent']"
[seeAllState]="continueWatchingSeeAllState()"
[testId]="'dashboard-continue-watching-rail'"
/>
}
<!-- Live rail — favorited live channels first, falls back to
recently-watched live. Title flips based on source so the label
is always honest about what's rendered. -->
@if (liveOnFavoritesCardsEnriched().length > 0) {
<!-- Live favorites: no fallback. Recently watched live gets its own rail. -->
@if (
dashboardRails().liveFavorites &&
liveFavoriteCardsEnriched().length > 0
) {
<lib-dashboard-rail
[label]="liveRailTitleKey() | translate"
[items]="liveOnFavoritesCardsEnriched()"
[totalCount]="liveRailTotalCount()"
[seeAllLink]="
liveRailSource() === 'favorites'
? ['/workspace/global-favorites']
: ['/workspace/global-recent']
"
[label]="liveRailTitleKeyForSource('favorites') | translate"
[items]="liveFavoriteCardsEnriched()"
[totalCount]="data.globalFavoriteLiveItems().length"
[seeAllLink]="['/workspace/global-favorites']"
[seeAllState]="liveSeeAllState"
layout="channel"
[testId]="'dashboard-live-recent-rail'"
[testId]="'dashboard-live-favorites-rail'"
/>
}
@if (
dashboardRails().recentlyWatchedLive &&
recentLiveCardsEnriched().length > 0
) {
<lib-dashboard-rail
[label]="liveRailTitleKeyForSource('recent') | translate"
[items]="recentLiveCardsEnriched()"
[totalCount]="data.globalRecentLiveItems().length"
[seeAllLink]="['/workspace/global-recent']"
[seeAllState]="liveSeeAllState"
layout="channel"
[testId]="'dashboard-recent-live-rail'"
/>
}
@if (
dashboardRails().favoriteMoviesAndSeries &&
favoriteMoviesAndSeriesCards().length > 0
) {
<lib-dashboard-rail
[label]="
'WORKSPACE.DASHBOARD.FAVORITE_MOVIES_AND_SERIES'
| translate
"
[items]="favoriteMoviesAndSeriesCards()"
[totalCount]="
data.globalFavoriteItems().length -
data.globalFavoriteLiveItems().length
"
[seeAllLink]="['/workspace/global-favorites']"
[seeAllState]="favoriteMoviesAndSeriesSeeAllState()"
[testId]="'dashboard-favorite-vod-rail'"
/>
}
@if (
dashboardRails().continueWatching &&
dashboardRails().recentlyWatchedLive &&
continueWatchingCards().length === 0 &&
liveOnFavoritesCards().length === 0 &&
recentLiveCards().length === 0 &&
data.globalRecentLoading()
) {
<section
@@ -174,14 +261,8 @@
</section>
}
<!-- Mixed Global Favorites rail intentionally NOT rendered on the
dashboard. Live favorites are promoted into the live rail above
(with current EPG), and the full mixed catalogue lives on the
dedicated /workspace/global-favorites page where the collection
UI can give it the per-type filters it needs. -->
<!-- Sources rail -->
@if (sourceCards().length > 0) {
@if (dashboardRails().recentSources && sourceCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
[items]="sourceCards()"
@@ -191,7 +272,7 @@
[testId]="'dashboard-recent-sources-rail'"
(actionSelected)="onSourceActionSelected($event)"
/>
} @else if (!data.playlistsLoaded()) {
} @else if (dashboardRails().recentSources && !data.playlistsLoaded()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -207,7 +288,10 @@
}
<!-- Xtream recently-added rail (only relevant if there are Xtream playlists) -->
@if (xtreamRecentlyAddedCards().length > 0) {
@if (
dashboardRails().xtreamRecentlyAdded &&
xtreamRecentlyAddedCards().length > 0
) {
<lib-dashboard-rail
[label]="
'WORKSPACE.DASHBOARD.XTREAM_RECENTLY_ADDED' | translate
@@ -217,6 +301,7 @@
[testId]="'dashboard-xtream-recently-added-rail'"
/>
} @else if (
dashboardRails().xtreamRecentlyAdded &&
xtreamPlaylistCount() > 0 && data.xtreamRecentlyAddedLoading()
) {
<section
@@ -295,6 +295,119 @@
}
}
.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).
@@ -1,5 +1,7 @@
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
buildDashboardCollectionViewState,
buildDashboardLiveEpgDetails,
buildLiveEpgLookupKeys,
buildPlaybackPositionReloadKey,
buildDashboardSourceActions,
@@ -7,11 +9,14 @@ import {
formatEpgTimeRange,
formatRemainingLabel,
getLiveEpgProgramForCard,
isContinueWatchingRecentItem,
liveRailTitleKeyForSource,
playbackProgressPercent,
resolveDashboardHeroArtwork,
buildDashboardRailSeeAllState,
} from './workspace-dashboard-rails.component';
import type { DashboardRailCard } from './dashboard-rail.component';
import { COLLECTION_VIEW_STATE_KEY } from '@iptvnator/portal/shared/util';
describe('buildDashboardSourceActions', () => {
const basePlaylist = {
@@ -204,6 +209,31 @@ describe('EPG enrichment helpers', () => {
).toBeNull();
});
});
describe('buildDashboardLiveEpgDetails', () => {
it('builds the current programme details used by the live hero banner', () => {
const program = baseProgram({ title: 'Market Open' });
const start = program.startTimestamp as number;
const stop = program.stopTimestamp as number;
const now = start + (stop - start) / 4;
expect(buildDashboardLiveEpgDetails(program, now)).toMatchObject({
nowPlayingTitle: 'Market Open',
nowPlayingTimeRange: expect.stringMatching(
/^\d{2}:\d{2} – \d{2}:\d{2}$/
),
nowPlayingProgress: 25,
});
});
});
});
describe('Continue watching helpers', () => {
it('keeps live TV out of the Continue Watching rail', () => {
expect(isContinueWatchingRecentItem({ type: 'movie' })).toBe(true);
expect(isContinueWatchingRecentItem({ type: 'series' })).toBe(true);
expect(isContinueWatchingRecentItem({ type: 'live' })).toBe(false);
});
});
describe('Live rail helpers', () => {
@@ -263,10 +293,53 @@ describe('Live rail helpers', () => {
'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
);
expect(liveRailTitleKeyForSource('recent')).toBe(
'WORKSPACE.DASHBOARD.LIVE_RECENT'
'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV'
);
});
it('builds collection view state for dashboard rail see-all links', () => {
expect(buildDashboardCollectionViewState('movie')).toEqual({
[COLLECTION_VIEW_STATE_KEY]: {
selectedContentType: 'movie',
},
});
expect(buildDashboardCollectionViewState('series')).toEqual({
[COLLECTION_VIEW_STATE_KEY]: {
selectedContentType: 'series',
},
});
});
it('builds see-all state from the first card content type in mixed rails', () => {
expect(
buildDashboardRailSeeAllState([
channelCard({ contentType: 'live' }),
channelCard({
id: 'movie-card',
title: 'Movie',
contentType: 'movie',
}),
])
).toEqual({
[COLLECTION_VIEW_STATE_KEY]: {
selectedContentType: 'live',
},
});
expect(
buildDashboardRailSeeAllState([
channelCard({
id: 'series-card',
title: 'Series',
contentType: 'series',
}),
])
).toEqual({
[COLLECTION_VIEW_STATE_KEY]: {
selectedContentType: 'series',
},
});
});
it('builds a stable playback-position reload key from VOD and series items only', () => {
const first = buildPlaybackPositionReloadKey([
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
@@ -10,7 +10,10 @@ import {
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { interval, of, startWith, switchMap } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgProgram,
normalizeDashboardRailsSettings,
} from '@iptvnator/shared/interfaces';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
@@ -32,6 +35,7 @@ import { PlaylistActions } from '@iptvnator/m3u-state';
import {
PlaylistDeleteActionService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
DashboardDataService,
@@ -46,6 +50,11 @@ import type {
DashboardRailActionSelection,
} from './dashboard-rail.component';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
buildCollectionViewState,
COLLECTION_VIEW_STATE_KEY,
CollectionContentType,
} from '@iptvnator/portal/shared/util';
// Cap dashboard rails at 20 items. Users get ~3× what's visible at once,
// the DOM stays cheap, and the "Manage all" link is one click away for the
@@ -80,6 +89,9 @@ interface DashboardHeroModel {
/** 0–100 watched, when a resume position is known. */
readonly watchProgress?: number | null;
readonly remainingLabel?: DashboardRemainingLabel | null;
readonly nowPlayingTitle?: string | null;
readonly nowPlayingTimeRange?: string | null;
readonly nowPlayingProgress?: number | null;
}
export interface DashboardRemainingLabel {
@@ -266,6 +278,33 @@ export function calcEpgProgress(
return Math.max(0, Math.min(100, ratio * 100));
}
export interface DashboardLiveEpgDetails {
readonly nowPlayingTitle: string | null;
readonly nowPlayingTimeRange: string | null;
readonly nowPlayingProgress: number | null;
}
export function buildDashboardLiveEpgDetails(
program: EpgProgram | null,
nowMs: number
): DashboardLiveEpgDetails | null {
if (!program) {
return null;
}
const details: DashboardLiveEpgDetails = {
nowPlayingTitle: program.title?.trim() || null,
nowPlayingTimeRange: formatEpgTimeRange(program),
nowPlayingProgress: calcEpgProgress(program, nowMs),
};
return details.nowPlayingTitle ||
details.nowPlayingTimeRange ||
details.nowPlayingProgress !== null
? details
: null;
}
function liveEpgLookupKeyForCard(card: DashboardRailCard): string {
return card.epgLookupKey?.trim() || card.title.trim();
}
@@ -303,7 +342,36 @@ export function liveRailTitleKeyForSource(
): string {
return source === 'favorites'
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
: 'WORKSPACE.DASHBOARD.LIVE_RECENT';
: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV';
}
export function buildDashboardCollectionViewState(
selectedContentType: CollectionContentType
): Record<string, unknown> {
return {
[COLLECTION_VIEW_STATE_KEY]: buildCollectionViewState({
selectedContentType,
}),
};
}
export function buildDashboardRailSeeAllState(
cards: readonly Pick<DashboardRailCard, 'contentType'>[],
fallbackContentType: CollectionContentType = 'movie'
): Record<string, unknown> {
const firstContentType =
cards.find(
(
card
): card is Pick<DashboardRailCard, 'contentType'> & {
contentType: CollectionContentType;
} =>
card.contentType === 'live' ||
card.contentType === 'movie' ||
card.contentType === 'series'
)?.contentType ?? fallbackContentType;
return buildDashboardCollectionViewState(firstContentType);
}
export function buildPlaybackPositionReloadKey(
@@ -319,6 +387,12 @@ export function buildPlaybackPositionReloadKey(
.join('|');
}
export function isContinueWatchingRecentItem(
item: Pick<GlobalRecentItem, 'type'>
): boolean {
return item.type === 'movie' || item.type === 'series';
}
// ── Playback-position helpers (used by both hero + Continue Watching cards)
// — kept as plain functions so they're easy to unit-test without mounting the
// component or the data service.
@@ -430,6 +504,7 @@ export class WorkspaceDashboardRailsComponent {
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly epgService = inject(EpgService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
@@ -438,10 +513,23 @@ 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
);
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.data.globalRecentItems()[0] ?? null;
const item = this.heroRecentItem();
if (!item) {
return null;
}
@@ -456,6 +544,10 @@ export class WorkspaceDashboardRailsComponent {
);
const position = this.data.getPlaybackPositionForItem(item);
const liveEpgDetails =
item.type === 'live'
? this.getLiveEpgDetailsForCard(this.heroLiveCard())
: null;
return {
...artwork,
@@ -467,63 +559,55 @@ export class WorkspaceDashboardRailsComponent {
title: item.title,
watchProgress: playbackProgressPercent(position),
remainingLabel: formatRemainingLabel(position),
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
};
});
// Split the mixed "recently watched" history into two rails. VOD items
// (movies/series) get one rail; live channels get their own. Two card
// formats — one rail each — so users can scan each surface at a glance
// instead of decoding a mixed grid of fallback posters and channel logos.
readonly continueWatchingCards = computed<DashboardRailCard[]>(() => {
readonly continueWatchingBaseCards = computed<DashboardRailCard[]>(() => {
this.languageTick();
return this.data
.globalRecentVodItems()
.filter(isContinueWatchingRecentItem)
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item));
});
// Live rail source — favorited live channels take precedence so the EPG
// enrichment lands on the channels the user actually cares about. When
// no favorites exist (fresh install, or a user who hasn't starred any
// channel yet), fall back to recently-watched live so the rail still
// has something to show. `liveRailSource` drives the title swap.
readonly liveRailSource = computed<'favorites' | 'recent'>(() =>
this.data.globalFavoriteLiveItems().length > 0 ? 'favorites' : 'recent'
readonly continueWatchingCards = computed<DashboardRailCard[]>(() =>
this.continueWatchingBaseCards()
);
readonly liveOnFavoritesCards = computed<DashboardRailCard[]>(() => {
if (this.liveRailSource() === 'favorites') {
return this.data
.globalFavoriteLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item));
}
return this.data
readonly liveFavoriteCards = computed<DashboardRailCard[]>(() =>
this.data
.globalFavoriteLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
readonly recentLiveCards = computed<DashboardRailCard[]>(() =>
this.data
.globalRecentLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item));
});
// Title key flips with the source — "Live now on your favorites" when
// we're rendering favorites, "Continue with live TV" when we're falling
// back to recently-watched. Always honest about what the user is seeing.
readonly liveRailTitleKey = computed(() =>
liveRailTitleKeyForSource(this.liveRailSource())
);
readonly liveRailTotalCount = computed(() =>
this.liveRailSource() === 'favorites'
? this.data.globalFavoriteLiveItems().length
: this.data.globalRecentLiveItems().length
.map((item) => this.toRecentCard(item))
);
// Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain
// (tvg-id -> tvg-name -> name), with the card title as a final fallback.
// Xtream/Stalker live items often have no XMLTV side-channel and will
// simply return null — the card renders without the program row.
private readonly liveChannelLookupKeys = computed(() =>
buildLiveEpgLookupKeys(this.liveOnFavoritesCards())
);
private readonly liveChannelLookupKeys = computed(() => {
const heroLiveCard = this.heroLiveCard();
return buildLiveEpgLookupKeys([
...(heroLiveCard ? [heroLiveCard] : []),
...(this.dashboardRails().liveFavorites
? this.liveFavoriteCards()
: []),
...(this.dashboardRails().recentlyWatchedLive
? this.recentLiveCards()
: []),
]);
});
private readonly playbackPositionReloadKey = computed(() =>
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
@@ -549,34 +633,32 @@ export class WorkspaceDashboardRailsComponent {
{ initialValue: new Map<string, EpgProgram | null>() }
);
readonly liveOnFavoritesCardsEnriched = computed<DashboardRailCard[]>(
() => {
const epgMap = this.liveEpgPrograms();
// Recompute the now-window each tick so progress moves between
// 30s ticks even if the program identity is unchanged.
const now = Date.now();
return this.liveOnFavoritesCards().map((card) => {
const program = getLiveEpgProgramForCard(card, epgMap);
if (!program) {
return card;
}
return {
...card,
nowPlayingTitle: program.title || null,
nowPlayingTimeRange: formatEpgTimeRange(program),
nowPlayingProgress: calcEpgProgress(program, now),
};
});
}
readonly liveFavoriteCardsEnriched = computed<DashboardRailCard[]>(() =>
this.enrichLiveCards(this.liveFavoriteCards())
);
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
readonly recentLiveCardsEnriched = computed<DashboardRailCard[]>(() =>
this.enrichLiveCards(this.recentLiveCards())
);
readonly favoriteMoviesAndSeriesCards = computed<DashboardRailCard[]>(() =>
this.data
.globalFavoriteItems()
.filter((item) => item.type === 'movie' || item.type === 'series')
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
readonly continueWatchingSeeAllState = computed(() =>
buildDashboardRailSeeAllState(this.continueWatchingBaseCards())
);
readonly favoriteMoviesAndSeriesSeeAllState = computed(() =>
this.buildNonLiveSeeAllState(this.favoriteMoviesAndSeriesCards())
);
readonly liveSeeAllState = buildDashboardCollectionViewState('live');
readonly xtreamRecentlyAddedCards = computed<DashboardRailCard[]>(() =>
this.data
.xtreamRecentlyAddedItems()
@@ -665,6 +747,40 @@ export class WorkspaceDashboardRailsComponent {
}
}
private enrichLiveCards(
cards: readonly DashboardRailCard[]
): DashboardRailCard[] {
return cards.map((card) => {
const details = this.getLiveEpgDetailsForCard(card);
if (!details) {
return card;
}
return { ...card, ...details };
});
}
private getLiveEpgDetailsForCard(
card: DashboardRailCard | null
): DashboardLiveEpgDetails | null {
if (!card) {
return null;
}
const program = getLiveEpgProgramForCard(card, this.liveEpgPrograms());
// Recompute the now-window each tick so progress moves between
// 30s ticks even if the program identity is unchanged.
return buildDashboardLiveEpgDetails(program, Date.now());
}
private buildNonLiveSeeAllState(
cards: readonly DashboardRailCard[]
): Record<string, unknown> {
return buildDashboardCollectionViewState(
cards.some((card) => card.contentType === 'movie')
? 'movie'
: 'series'
);
}
private buildHeroSubtitle(item: GlobalRecentItem): string {
const parts = [
item.playlist_name,