diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html
index ce0ace52f..a64d7d1cd 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html
@@ -45,79 +45,165 @@
chevron_left
-
+
@for (card of items(); track card.id) {
-
+ } @else {
+
+
+ @if (
+ card.imageUrl && !failedImages()[card.id]
+ ) {
+ @if (card.contentType === 'live') {
+
+ }
+
![]()
+ } @else {
+
+ {{ card.icon }}
+
+ }
+
+
+
+ {{
+ card.title
+ }}
+ @if (card.subtitle) {
+ {{
+ card.subtitle
+ }}
+ }
+
+
+
+ @if (card.actions && card.actions.length > 0) {
+
+ }
+
+ }
}
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
index 9b1d6de73..3e1e31bb5 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss
@@ -429,3 +429,195 @@
--rail-card-width: min(var(--cover-rail-width, 172px), 140px);
}
}
+
+// ── Channel layout (compact horizontal info row) ────────────────────────────
+// Used by the "Live now on your favorites" rail. Two stacked rows:
+// 1. logo + channel name + current programme title
+// 2. LIVE chip + progress bar + time range
+// Card is wider but much shorter than the 2:3 poster — TV station logos are
+// small (100–256px square) so a poster card wastes most of its height on
+// padding. Mirrors the live strip in the v0.22 mockup.
+.rail__track[data-layout='channel'] {
+ --rail-card-width: var(--channel-rail-width, 280px);
+ --rail-gap: 10px;
+ scroll-snap-type: x mandatory;
+}
+
+.rail__card--channel {
+ background: var(--app-widget-bg, #1e2330);
+ border: 1px solid
+ var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
+ border-radius: var(--rail-radius);
+ padding: 10px 12px;
+ transition:
+ transform 0.2s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ border-color: var(
+ --app-selection-border,
+ rgba(47, 123, 255, 0.4)
+ );
+ box-shadow:
+ 0 6px 16px -10px rgba(0, 0, 0, 0.45),
+ 0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.3));
+
+ .rail__channel-name {
+ color: var(--app-selection-color, #2f7bff);
+ }
+ }
+}
+
+.rail__channel-link {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ color: inherit;
+ text-decoration: none;
+ border-radius: inherit;
+
+ &:focus-visible {
+ outline: 2px solid var(--app-selection-color, #2f7bff);
+ outline-offset: 3px;
+ }
+}
+
+.rail__channel-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.rail__channel-logo {
+ flex: 0 0 auto;
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ background: var(--app-content-bg, #161a22);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ // Drop-shadow gives small logos some lift against the card surface
+ // without the dark wash from the poster-style live treatment.
+ filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.35));
+ }
+
+ &--fallback mat-icon {
+ font-size: 22px;
+ width: 22px;
+ height: 22px;
+ color: var(--app-selection-color, #78adff);
+ opacity: 0.7;
+ }
+}
+
+.rail__channel-meta {
+ min-width: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.rail__channel-name {
+ font-size: 0.85rem;
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--app-heading-color, #1a1d23);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition: color 0.15s ease;
+}
+
+.rail__channel-now {
+ font-size: 0.72rem;
+ font-weight: 500;
+ line-height: 1.35;
+ color: var(--app-muted-color, #8891a4);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-height: 1.35em;
+}
+
+.rail__channel-progress-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.65rem;
+ color: var(--app-muted-color, #8891a4);
+}
+
+.rail__channel-live-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-family: var(
+ --font-mono,
+ ui-monospace,
+ 'SF Mono',
+ 'JetBrains Mono',
+ Menlo,
+ monospace
+ );
+ font-size: 0.6rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: var(--app-live-color, #ff8f8f);
+ text-transform: uppercase;
+
+ &::before {
+ content: '';
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--app-live-color, #ff8f8f);
+ box-shadow: 0 0 5px var(--app-live-color, #ff8f8f);
+ }
+}
+
+.rail__channel-progress {
+ position: relative;
+ flex: 1;
+ height: 3px;
+ border-radius: 2px;
+ background: color-mix(
+ in srgb,
+ var(--app-on-surface, #e6e1e5) 12%,
+ transparent
+ );
+ overflow: hidden;
+
+ i {
+ display: block;
+ height: 100%;
+ background: var(--app-live-color, #ff8f8f);
+ border-radius: 2px;
+ transition: width 0.4s ease;
+ }
+
+ &--idle i {
+ // No EPG data yet — keep the bar neutral so it doesn't look like
+ // "0% watched".
+ background: color-mix(
+ in srgb,
+ var(--app-on-surface, #e6e1e5) 18%,
+ transparent
+ );
+ }
+}
+
+.rail__channel-time {
+ flex: 0 0 auto;
+ font-variant-numeric: tabular-nums;
+}
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts
index 6c7ac3355..8fa70fdf0 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts
+++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts
@@ -35,8 +35,29 @@ export interface DashboardRailCard {
link: string[];
state?: Record;
actions?: DashboardRailAction[];
+
+ /**
+ * Optional EPG enrichment shown by the 'channel' rail layout. Populated
+ * asynchronously after the card list is computed — when null/undefined
+ * the channel card renders without the program subtitle/progress.
+ */
+ nowPlayingTitle?: string | null;
+ /** Localised time range like "12:15 – 13:40". */
+ nowPlayingTimeRange?: string | null;
+ /** 0–100, % through the current program. */
+ nowPlayingProgress?: number | null;
}
+/**
+ * Visual layout of the rail's cards.
+ * - 'cover': portrait poster (Netflix-style), used for movies/series and
+ * recently-added catalog items.
+ * - 'channel': compact horizontal info row (logo + channel name + current
+ * program + progress), used for live TV. TV station logos are small, so
+ * inflating them into 2:3 posters wastes space the cards never use.
+ */
+export type DashboardRailLayout = 'cover' | 'channel';
+
export interface DashboardRailActionSelection {
action: DashboardRailAction;
card: DashboardRailCard;
@@ -60,6 +81,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
readonly items = input.required();
readonly seeAllLink = input(null);
readonly aspectRatio = input('2 / 3');
+ readonly layout = input('cover');
readonly testId = input(null);
readonly actionSelected = output();
/**
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html
index db4f839ad..de93401f6 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html
+++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html
@@ -114,12 +114,13 @@
}
- @if (liveOnFavoritesCards().length > 0) {
+ @if (liveOnFavoritesCardsEnriched().length > 0) {
}
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts
index abc3f0d0e..0f44ce234 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts
+++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts
@@ -1,6 +1,8 @@
-import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
+import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
buildDashboardSourceActions,
+ calcEpgProgress,
+ formatEpgTimeRange,
resolveDashboardHeroArtwork,
} from './workspace-dashboard-rails.component';
@@ -115,3 +117,84 @@ describe('resolveDashboardHeroArtwork', () => {
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
});
});
+
+describe('EPG enrichment helpers', () => {
+ const baseProgram = (overrides: Partial = {}): EpgProgram =>
+ ({
+ title: 'Tagesschau',
+ desc: null,
+ start: '2026-05-19T12:00:00.000Z',
+ stop: '2026-05-19T12:30:00.000Z',
+ startTimestamp: Date.UTC(2026, 4, 19, 12, 0, 0),
+ stopTimestamp: Date.UTC(2026, 4, 19, 12, 30, 0),
+ ...overrides,
+ }) as EpgProgram;
+
+ describe('formatEpgTimeRange', () => {
+ it('formats a start–stop window using the locale-independent HH:MM range', () => {
+ const range = formatEpgTimeRange(baseProgram());
+ // Format is HH:MM – HH:MM (en dash), in the test runner's local
+ // timezone — assert structure, not exact hours.
+ expect(range).toMatch(/^\d{2}:\d{2} – \d{2}:\d{2}$/);
+ });
+
+ it('returns null when either timestamp is missing or unparseable', () => {
+ expect(
+ formatEpgTimeRange(
+ baseProgram({
+ start: 'not-a-date',
+ startTimestamp: null,
+ })
+ )
+ ).toBeNull();
+
+ expect(
+ formatEpgTimeRange(
+ baseProgram({
+ stop: '',
+ stopTimestamp: null,
+ })
+ )
+ ).toBeNull();
+ });
+ });
+
+ describe('calcEpgProgress', () => {
+ const program = baseProgram();
+ const start = program.startTimestamp as number;
+ const stop = program.stopTimestamp as number;
+
+ it('returns 0 at the start of the window', () => {
+ expect(calcEpgProgress(program, start)).toBe(0);
+ });
+
+ it('returns 100 at the end of the window', () => {
+ expect(calcEpgProgress(program, stop)).toBe(100);
+ });
+
+ it('clamps below 0 and above 100 for out-of-window times', () => {
+ expect(calcEpgProgress(program, start - 60_000)).toBe(0);
+ expect(calcEpgProgress(program, stop + 60_000)).toBe(100);
+ });
+
+ it('interpolates linearly across the window', () => {
+ const mid = start + (stop - start) / 2;
+ expect(calcEpgProgress(program, mid)).toBeCloseTo(50, 5);
+ });
+
+ it('returns null when the window is zero-length or inverted', () => {
+ expect(
+ calcEpgProgress(
+ baseProgram({ stopTimestamp: start }),
+ start + 10
+ )
+ ).toBeNull();
+ expect(
+ calcEpgProgress(
+ baseProgram({ stopTimestamp: start - 1 }),
+ start
+ )
+ ).toBeNull();
+ });
+ });
+});
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts
index af15e1640..82e61cc86 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts
+++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts
@@ -6,6 +6,10 @@ import {
inject,
signal,
} from '@angular/core';
+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 { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
@@ -44,6 +48,13 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
// 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;
@@ -193,6 +204,56 @@ export function buildDashboardSourceActions(
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));
+}
+
function isXtreamAccountPlaylist(
playlist: PlaylistMeta
): playlist is PlaylistMeta & {
@@ -234,6 +295,7 @@ export class WorkspaceDashboardRailsComponent {
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
+ private readonly epgService = inject(EpgService);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
@@ -288,6 +350,64 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toRecentCard(item))
);
+ // Best-effort EPG lookup keyed by the channel's display name. This works
+ // for M3U sources whose XMLTV channel ids resolve through the existing
+ // `resolveChannelEpgLookupKey()` (tvg-id → tvg-name → name) chain.
+ // 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(() => {
+ const seen = new Set();
+ const keys: string[] = [];
+ for (const card of this.liveOnFavoritesCards()) {
+ const key = card.title.trim();
+ if (!key || seen.has(key)) continue;
+ seen.add(key);
+ keys.push(key);
+ }
+ return keys;
+ });
+
+ // Re-fetch on rail change AND on a 30s heartbeat so the progress bar
+ // catches the boundary between programs without a full page revisit.
+ private readonly liveEpgPrograms = toSignal(
+ toObservable(this.liveChannelLookupKeys).pipe(
+ switchMap((keys) =>
+ keys.length === 0
+ ? of(new Map())
+ : interval(LIVE_EPG_TICK_MS).pipe(
+ startWith(0),
+ switchMap(() =>
+ this.epgService.getCurrentProgramsForChannels(
+ keys
+ )
+ )
+ )
+ )
+ ),
+ { initialValue: new Map() }
+ );
+
+ readonly liveOnFavoritesCardsEnriched = computed(
+ () => {
+ 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 = epgMap.get(card.title.trim()) ?? null;
+ if (!program) {
+ return card;
+ }
+ return {
+ ...card,
+ nowPlayingTitle: program.title || null,
+ nowPlayingTimeRange: formatEpgTimeRange(program),
+ nowPlayingProgress: calcEpgProgress(program, now),
+ };
+ });
+ }
+ );
+
readonly favoriteCards = computed(() =>
this.data
.globalFavoriteItems()