From bb6d1427faa2657e8c5462c7ad5e3f38e04db38c Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 19 May 2026 00:34:43 +0200 Subject: [PATCH] feat(dashboard): compact channel cards with lazy-loaded current EPG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Live now on your favorites" rail no longer reuses the 2:3 poster card built for movies. TV station logos are typically 100–256px square, so inflating them into portrait cards wastes most of the height and hides the data users actually care about (what's airing right now). Introduce a `layout: 'cover' | 'channel'` input on DashboardRailComponent. The new channel layout is a wider, much shorter card: logo + channel name + current program title + LIVE chip + progress bar + time range. Current programs are lazy-loaded via EpgService.getCurrentProgramsForChannels (already batched + 60s-cached at the service), keyed by the recent item's display name — works out of the box for M3U sources whose XMLTV channels resolve through the tvg-id → tvg-name → name fallback chain. A 30s tick keeps the progress bar fresh between program boundaries. The card renders gracefully without EPG enrichment (idle progress bar, no program title) so Xtream/Stalker live items without an XMLTV side channel still look fine. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/rails/dashboard-rail.component.html | 222 ++++++++++++------ .../lib/rails/dashboard-rail.component.scss | 192 +++++++++++++++ .../src/lib/rails/dashboard-rail.component.ts | 22 ++ .../workspace-dashboard-rails.component.html | 5 +- ...orkspace-dashboard-rails.component.spec.ts | 85 ++++++- .../workspace-dashboard-rails.component.ts | 120 ++++++++++ 6 files changed, 575 insertions(+), 71 deletions(-) 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) { -
- - +
+ LIVE + + + + @if (card.nowPlayingTimeRange) { + {{ + card.nowPlayingTimeRange + }} + } +
+ +
+ } @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()