mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
refactor: split dashboard rails and settings logic
This commit is contained in:
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)
|
||||
);
|
||||
}
|
||||
+13
-11
@@ -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,
|
||||
|
||||
+27
-462
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user