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()