refactor: split dashboard rails and settings logic

This commit is contained in:
4gray committed 2026-06-14 12:01:52 +02:00
1 parent 97678d4a66
commit cc704a429f
13 files changed
+971 -812

No files matched your search

@@ -1,2 +1,6 @@
export * from './lib/rails/workspace-dashboard-rails.component';
export * from './lib/rails/dashboard-rail.component';
export * from './lib/rails/dashboard-hero.utils';
export * from './lib/rails/dashboard-live-epg.utils';
export * from './lib/rails/dashboard-playback.utils';
export * from './lib/rails/dashboard-rail.utils';
@@ -0,0 +1,105 @@
import type { CollectionContentType } from '@iptvnator/portal/shared/util';
import type { DashboardRemainingLabel } from './dashboard-playback.utils';
export interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly contentType?: CollectionContentType;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly icon: string;
readonly link: string[];
readonly posterUrl?: string;
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
/** 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 type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
export interface DashboardHeroArtworkInput {
readonly backdropUrl?: string | null;
readonly posterUrl?: string | null;
readonly title: string;
}
export interface DashboardHeroArtwork {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly posterUrl?: string;
}
export function resolveDashboardHeroArtwork(
item: DashboardHeroArtworkInput,
failedImages: Record<string, true>
): DashboardHeroArtwork {
const posterUrl =
item.posterUrl && !failedImages[item.posterUrl]
? item.posterUrl
: undefined;
const explicitBackdropUrl =
item.backdropUrl && !failedImages[item.backdropUrl]
? item.backdropUrl
: undefined;
const backdropUrl = explicitBackdropUrl ?? posterUrl;
const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl
? 'backdrop'
: posterUrl
? 'poster'
: 'fallback';
return {
backdropUrl,
backdropSource,
fallbackBackdropBackground: buildFallbackBackground(
item.title,
50,
15,
80,
5,
60
),
fallbackPosterBackground: buildFallbackBackground(
item.title,
40,
25,
50,
15,
40
),
hasBackdrop: backdropSource === 'backdrop',
posterUrl,
};
}
function buildFallbackBackground(
title: string,
saturationA: number,
lightnessA: number,
saturationB: number,
lightnessB: number,
hueOffset: number
): string {
const hue = calculateHue(title || 'placeholder');
const h2 = (hue + hueOffset) % 360;
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
function calculateHue(text: string): number {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
}
@@ -0,0 +1,137 @@
import type {
DashboardRailsSettings,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import type { DashboardRailCard } from './dashboard-rail.component';
// EPG "now" data ticks every 30s: short enough that the progress bar moves
// visibly between long-tail program changes, long enough that we don't hammer
// the SQLite backend with a batched IPC every animation frame.
export const LIVE_EPG_TICK_MS = 30_000;
// Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp`
// when present. The parsed XMLTV pipeline populates both, but legacy rows
// only carry the strings.
function epgTimestampMs(
program: EpgProgram,
side: 'start' | 'stop'
): number | null {
const cached =
side === 'start' ? program.startTimestamp : program.stopTimestamp;
if (cached != null) {
return cached;
}
const iso = side === 'start' ? program.start : program.stop;
const ms = iso ? new Date(iso).getTime() : NaN;
return Number.isFinite(ms) ? ms : null;
}
function formatEpgTime(ms: number): string {
const d = new Date(ms);
return `${d.getHours().toString().padStart(2, '0')}:${d
.getMinutes()
.toString()
.padStart(2, '0')}`;
}
export function formatEpgTimeRange(program: EpgProgram): string | null {
const start = epgTimestampMs(program, 'start');
const stop = epgTimestampMs(program, 'stop');
if (start == null || stop == null) {
return null;
}
return `${formatEpgTime(start)} – ${formatEpgTime(stop)}`;
}
export function calcEpgProgress(
program: EpgProgram,
nowMs: number
): number | null {
const start = epgTimestampMs(program, 'start');
const stop = epgTimestampMs(program, 'stop');
if (start == null || stop == null || stop <= start) {
return null;
}
const ratio = (nowMs - start) / (stop - start);
if (!Number.isFinite(ratio)) {
return null;
}
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();
}
export function buildLiveEpgLookupKeys(
cards: readonly DashboardRailCard[]
): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const card of cards) {
const key = liveEpgLookupKeyForCard(card);
if (!key || seen.has(key)) continue;
seen.add(key);
keys.push(key);
}
return keys;
}
type DashboardLiveEpgRailSettings = Pick<
DashboardRailsSettings,
'hero' | 'liveFavorites' | 'recentlyWatchedLive'
>;
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 : []),
];
}
export function getLiveEpgProgramForCard(
card: DashboardRailCard,
epgMap: ReadonlyMap<string, EpgProgram | null>
): EpgProgram | null {
const key = liveEpgLookupKeyForCard(card);
const program = epgMap.get(key);
if (program) {
return program;
}
const titleKey = card.title.trim();
return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null;
}
@@ -0,0 +1,85 @@
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
export interface DashboardRemainingLabel {
readonly key: string;
readonly params: Record<string, number>;
}
export function buildPlaybackPositionReloadKey(
items: readonly Pick<
GlobalRecentItem,
'playlist_id' | 'type' | 'xtream_id'
>[]
): string {
return items
.filter((item) => item.type === 'movie' || item.type === 'series')
.map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`)
.sort()
.join('|');
}
export function isContinueWatchingRecentItem(
item: Pick<GlobalRecentItem, 'type'>
): boolean {
return item.type === 'movie' || item.type === 'series';
}
export function playbackProgressPercent(
position: { positionSeconds: number; durationSeconds?: number } | null
): number | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const ratio = position.positionSeconds / position.durationSeconds;
if (!Number.isFinite(ratio)) {
return null;
}
// Integer percent keeps "92% watched" out of "92.4% watched" territory,
// and matches the resolution of a 3px-tall progress bar on a 280px card.
return Math.max(0, Math.min(100, Math.floor(ratio * 100)));
}
export function formatRemainingLabel(
position: { positionSeconds: number; durationSeconds?: number } | null
): DashboardRemainingLabel | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const remaining = Math.max(
0,
Math.round(position.durationSeconds - position.positionSeconds)
);
if (remaining < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: remaining },
};
}
const totalMinutes = Math.round(remaining / 60);
if (totalMinutes < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: totalMinutes },
};
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
params: { hours },
};
}
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
params: { hours, minutes },
};
}
@@ -0,0 +1,142 @@
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
buildCollectionViewState,
COLLECTION_VIEW_STATE_KEY,
CollectionContentType,
} from '@iptvnator/portal/shared/util';
import type {
DashboardRailAction,
DashboardRailCard,
} from './dashboard-rail.component';
import type { DashboardRailsSettings } from '@iptvnator/shared/interfaces';
// Cap dashboard rails at 20 items. Users get ~3x what's visible at once,
// the DOM stays cheap, and the "Manage all" link is one click away for the
// full list. Matches the single-rail density of Netflix / Apple TV+.
export const RAIL_ITEM_LIMIT = 20;
// Six placeholder slots per skeleton rail fills a typical viewport without
// taking the whole page. Mirrors the recently-added skeleton density.
export const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
export const SKELETON_RAILS = [1, 2, 3] as const;
export type DashboardSourceActionId =
| 'refresh'
| 'playlist-info'
| 'account-info'
| 'remove';
export function buildDashboardSourceActions(
playlist: PlaylistMeta,
canRefresh: boolean
): DashboardRailAction[] {
const actions: DashboardRailAction[] = [];
if (canRefresh) {
actions.push({
id: 'refresh',
icon: 'sync',
labelKey: playlist.serverUrl
? 'HOME.PLAYLISTS.REFRESH_XTREAM'
: 'HOME.PLAYLISTS.REFRESH',
});
}
actions.push({
id: 'playlist-info',
icon: 'edit',
labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS',
});
if (isXtreamAccountPlaylist(playlist)) {
actions.push({
id: 'account-info',
icon: 'person',
labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO',
});
}
actions.push({
id: 'remove',
icon: 'delete',
labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE',
destructive: true,
separatorBefore: true,
});
return actions;
}
export function isXtreamAccountPlaylist(
playlist: PlaylistMeta
): playlist is PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
} {
return Boolean(
playlist.serverUrl && playlist.username && playlist.password
);
}
export function liveRailTitleKeyForSource(
source: 'favorites' | 'recent'
): string {
return source === 'favorites'
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
: '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);
}
type DashboardRecentRailSettings = Pick<
DashboardRailsSettings,
'continueWatching' | 'recentlyWatchedLive'
>;
export interface DashboardRecentContentSkeletonInput {
readonly continueWatchingCount: number;
readonly globalRecentLoading: boolean;
readonly recentLiveCount: number;
}
export function shouldShowRecentContentSkeleton(
rails: DashboardRecentRailSettings,
input: DashboardRecentContentSkeletonInput
): boolean {
if (!input.globalRecentLoading) {
return false;
}
return (
(rails.continueWatching && input.continueWatchingCount === 0) ||
(rails.recentlyWatchedLive && input.recentLiveCount === 0)
);
}
@@ -2,22 +2,26 @@ import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DEFAULT_DASHBOARD_RAILS_SETTINGS } from '@iptvnator/shared/interfaces';
import {
buildDashboardCollectionViewState,
buildDashboardRailSeeAllState,
buildDashboardSourceActions,
liveRailTitleKeyForSource,
shouldShowRecentContentSkeleton,
} from './dashboard-rail.utils';
import {
buildDashboardLiveEpgDetails,
buildLiveEpgCardsForEnabledRails,
buildLiveEpgLookupKeys,
buildPlaybackPositionReloadKey,
buildDashboardSourceActions,
calcEpgProgress,
formatEpgTimeRange,
formatRemainingLabel,
getLiveEpgProgramForCard,
} from './dashboard-live-epg.utils';
import {
buildPlaybackPositionReloadKey,
formatRemainingLabel,
isContinueWatchingRecentItem,
liveRailTitleKeyForSource,
playbackProgressPercent,
resolveDashboardHeroArtwork,
buildDashboardRailSeeAllState,
shouldShowRecentContentSkeleton,
} from './workspace-dashboard-rails.component';
} from './dashboard-playback.utils';
import { resolveDashboardHeroArtwork } from './dashboard-hero.utils';
import type { DashboardRailCard } from './dashboard-rail.component';
import { COLLECTION_VIEW_STATE_KEY } from '@iptvnator/portal/shared/util';
@@ -516,9 +520,7 @@ describe('playback-position helpers', () => {
describe('formatRemainingLabel', () => {
it('returns null without a usable position/duration', () => {
expect(formatRemainingLabel(null)).toBeNull();
expect(
formatRemainingLabel({ positionSeconds: 100 })
).toBeNull();
expect(formatRemainingLabel({ positionSeconds: 100 })).toBeNull();
expect(
formatRemainingLabel({
positionSeconds: 100,
@@ -11,7 +11,6 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { interval, of, startWith, switchMap } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import {
type DashboardRailsSettings,
type EpgProgram,
normalizeDashboardRailsSettings,
} from '@iptvnator/shared/interfaces';
@@ -46,472 +45,38 @@ import {
} from '@iptvnator/workspace/dashboard/data-access';
import { DashboardRailComponent } from './dashboard-rail.component';
import type {
DashboardRailAction,
DashboardRailCard,
DashboardRailActionSelection,
} from './dashboard-rail.component';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import type { DashboardHeroModel } from './dashboard-hero.utils';
import { resolveDashboardHeroArtwork } from './dashboard-hero.utils';
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
// full list. Matches the single-rail density of Netflix / Apple TV+.
const RAIL_ITEM_LIMIT = 20;
// EPG "now" data ticks every 30s — short enough that the progress bar moves
// visibly between long-tail program changes, long enough that we don't hammer
// the SQLite backend with a batched IPC every animation frame. Matches the
// channel-list-container's existing progress cadence so the two surfaces
// can't drift out of sync.
const LIVE_EPG_TICK_MS = 30_000;
// Six placeholder slots per skeleton rail — fills a typical viewport without
// taking the whole page. Mirrors the recently-added skeleton density.
const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
const SKELETON_RAILS = [1, 2, 3] as const;
interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly contentType?: 'live' | 'movie' | 'series';
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly icon: string;
readonly link: string[];
readonly posterUrl?: string;
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
/** 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 {
readonly key: string;
readonly params: Record<string, number>;
}
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
export interface DashboardHeroArtworkInput {
readonly backdropUrl?: string | null;
readonly posterUrl?: string | null;
readonly title: string;
}
export interface DashboardHeroArtwork {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly posterUrl?: string;
}
export function resolveDashboardHeroArtwork(
item: DashboardHeroArtworkInput,
failedImages: Record<string, true>
): DashboardHeroArtwork {
const posterUrl =
item.posterUrl && !failedImages[item.posterUrl]
? item.posterUrl
: undefined;
const explicitBackdropUrl =
item.backdropUrl && !failedImages[item.backdropUrl]
? item.backdropUrl
: undefined;
const backdropUrl = explicitBackdropUrl ?? posterUrl;
const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl
? 'backdrop'
: posterUrl
? 'poster'
: 'fallback';
return {
backdropUrl,
backdropSource,
fallbackBackdropBackground: buildFallbackBackground(
item.title,
50,
15,
80,
5,
60
),
fallbackPosterBackground: buildFallbackBackground(
item.title,
40,
25,
50,
15,
40
),
hasBackdrop: backdropSource === 'backdrop',
posterUrl,
};
}
function buildFallbackBackground(
title: string,
saturationA: number,
lightnessA: number,
saturationB: number,
lightnessB: number,
hueOffset: number
): string {
const hue = calculateHue(title || 'placeholder');
const h2 = (hue + hueOffset) % 360;
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
function calculateHue(text: string): number {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
}
export type DashboardSourceActionId =
| 'refresh'
| 'playlist-info'
| 'account-info'
| 'remove';
export function buildDashboardSourceActions(
playlist: PlaylistMeta,
canRefresh: boolean
): DashboardRailAction[] {
const actions: DashboardRailAction[] = [];
if (canRefresh) {
actions.push({
id: 'refresh',
icon: 'sync',
labelKey: playlist.serverUrl
? 'HOME.PLAYLISTS.REFRESH_XTREAM'
: 'HOME.PLAYLISTS.REFRESH',
});
}
actions.push({
id: 'playlist-info',
icon: 'edit',
labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS',
});
if (isXtreamAccountPlaylist(playlist)) {
actions.push({
id: 'account-info',
icon: 'person',
labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO',
});
}
actions.push({
id: 'remove',
icon: 'delete',
labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE',
destructive: true,
separatorBefore: true,
});
return actions;
}
// Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp`
// when present (the parsed XMLTV pipeline populates both, but legacy rows
// only carry the strings).
function epgTimestampMs(
program: EpgProgram,
side: 'start' | 'stop'
): number | null {
const cached =
side === 'start' ? program.startTimestamp : program.stopTimestamp;
if (cached != null) {
return cached;
}
const iso = side === 'start' ? program.start : program.stop;
const ms = iso ? new Date(iso).getTime() : NaN;
return Number.isFinite(ms) ? ms : null;
}
function formatEpgTime(ms: number): string {
const d = new Date(ms);
return `${d.getHours().toString().padStart(2, '0')}:${d
.getMinutes()
.toString()
.padStart(2, '0')}`;
}
export function formatEpgTimeRange(program: EpgProgram): string | null {
const start = epgTimestampMs(program, 'start');
const stop = epgTimestampMs(program, 'stop');
if (start == null || stop == null) {
return null;
}
return `${formatEpgTime(start)} – ${formatEpgTime(stop)}`;
}
export function calcEpgProgress(
program: EpgProgram,
nowMs: number
): number | null {
const start = epgTimestampMs(program, 'start');
const stop = epgTimestampMs(program, 'stop');
if (start == null || stop == null || stop <= start) {
return null;
}
const ratio = (nowMs - start) / (stop - start);
if (!Number.isFinite(ratio)) {
return null;
}
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();
}
export function buildLiveEpgLookupKeys(
cards: readonly DashboardRailCard[]
): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const card of cards) {
const key = liveEpgLookupKeyForCard(card);
if (!key || seen.has(key)) continue;
seen.add(key);
keys.push(key);
}
return keys;
}
type DashboardLiveEpgRailSettings = Pick<
DashboardRailsSettings,
'hero' | 'liveFavorites' | 'recentlyWatchedLive'
>;
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 : []),
];
}
export function getLiveEpgProgramForCard(
card: DashboardRailCard,
epgMap: ReadonlyMap<string, EpgProgram | null>
): EpgProgram | null {
const key = liveEpgLookupKeyForCard(card);
const program = epgMap.get(key);
if (program) {
return program;
}
const titleKey = card.title.trim();
return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null;
}
export function liveRailTitleKeyForSource(
source: 'favorites' | 'recent'
): string {
return source === 'favorites'
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
: '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(
items: readonly Pick<
GlobalRecentItem,
'playlist_id' | 'type' | 'xtream_id'
>[]
): string {
return items
.filter((item) => item.type === 'movie' || item.type === 'series')
.map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`)
.sort()
.join('|');
}
export function isContinueWatchingRecentItem(
item: Pick<GlobalRecentItem, 'type'>
): boolean {
return item.type === 'movie' || item.type === 'series';
}
type DashboardRecentRailSettings = Pick<
DashboardRailsSettings,
'continueWatching' | 'recentlyWatchedLive'
>;
export interface DashboardRecentContentSkeletonInput {
readonly continueWatchingCount: number;
readonly globalRecentLoading: boolean;
readonly recentLiveCount: number;
}
export function shouldShowRecentContentSkeleton(
rails: DashboardRecentRailSettings,
input: DashboardRecentContentSkeletonInput
): boolean {
if (!input.globalRecentLoading) {
return false;
}
return (
(rails.continueWatching && input.continueWatchingCount === 0) ||
(rails.recentlyWatchedLive && input.recentLiveCount === 0)
);
}
// ── 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.
export function playbackProgressPercent(
position: { positionSeconds: number; durationSeconds?: number } | null
): number | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const ratio = position.positionSeconds / position.durationSeconds;
if (!Number.isFinite(ratio)) {
return null;
}
// Integer percent — keeps "92% watched" out of "92.4% watched" territory,
// and matches the resolution of a 3px-tall progress bar on a 280px card.
return Math.max(0, Math.min(100, Math.floor(ratio * 100)));
}
export function formatRemainingLabel(
position: { positionSeconds: number; durationSeconds?: number } | null
): DashboardRemainingLabel | null {
if (
!position ||
position.durationSeconds == null ||
position.durationSeconds <= 0
) {
return null;
}
const remaining = Math.max(
0,
Math.round(position.durationSeconds - position.positionSeconds)
);
if (remaining < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
params: { seconds: remaining },
};
}
const totalMinutes = Math.round(remaining / 60);
if (totalMinutes < 60) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
params: { minutes: totalMinutes },
};
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
params: { hours },
};
}
return {
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
params: { hours, minutes },
};
}
function isXtreamAccountPlaylist(
playlist: PlaylistMeta
): playlist is PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
} {
return Boolean(
playlist.serverUrl && playlist.username && playlist.password
);
}
buildDashboardLiveEpgDetails,
buildLiveEpgCardsForEnabledRails,
buildLiveEpgLookupKeys,
getLiveEpgProgramForCard,
LIVE_EPG_TICK_MS,
} from './dashboard-live-epg.utils';
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
import {
buildPlaybackPositionReloadKey,
formatRemainingLabel,
isContinueWatchingRecentItem,
playbackProgressPercent,
} from './dashboard-playback.utils';
import {
buildDashboardCollectionViewState,
buildDashboardRailSeeAllState,
buildDashboardSourceActions,
isXtreamAccountPlaylist,
liveRailTitleKeyForSource,
RAIL_ITEM_LIMIT,
shouldShowRecentContentSkeleton,
SKELETON_CARDS_PER_RAIL,
SKELETON_RAILS,
} from './dashboard-rail.utils';
import type { DashboardSourceActionId } from './dashboard-rail.utils';
@Component({
selector: 'lib-workspace-dashboard-rails',