mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(dashboard): compact channel cards with lazy-loaded current EPG
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
a048c18763
commit
bb6d1427fa
6 files changed
+575
-71
No files matched your search
@@ -45,79 +45,165 @@
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
|
||||
<div #track class="rail__track" (scroll)="onScroll()" role="list">
|
||||
<div
|
||||
#track
|
||||
class="rail__track"
|
||||
[attr.data-layout]="layout()"
|
||||
(scroll)="onScroll()"
|
||||
role="list"
|
||||
>
|
||||
@for (card of items(); track card.id) {
|
||||
<div
|
||||
class="rail__card"
|
||||
role="listitem"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="testId() ? testId() + '-card' : null"
|
||||
>
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
@if (layout() === 'channel') {
|
||||
<div
|
||||
class="rail__card rail__card--channel"
|
||||
role="listitem"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card' : null
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="rail__art"
|
||||
[class.rail__art--live]="
|
||||
card.contentType === 'live'
|
||||
"
|
||||
[style.aspect-ratio]="aspectRatio()"
|
||||
<a
|
||||
class="rail__channel-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
>
|
||||
@if (card.imageUrl && !failedImages()[card.id]) {
|
||||
@if (card.contentType === 'live') {
|
||||
<span
|
||||
class="rail__art-bg"
|
||||
aria-hidden="true"
|
||||
[style.background-image]="
|
||||
'url(' + card.imageUrl + ')'
|
||||
"
|
||||
></span>
|
||||
}
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="rail__art-fallback">
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
<div class="rail__channel-row">
|
||||
<div
|
||||
class="rail__channel-logo"
|
||||
[class.rail__channel-logo--fallback]="
|
||||
!card.imageUrl ||
|
||||
failedImages()[card.id]
|
||||
"
|
||||
>
|
||||
@if (
|
||||
card.imageUrl &&
|
||||
!failedImages()[card.id]
|
||||
) {
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rail__meta">
|
||||
<span class="rail__card-title">{{
|
||||
card.title
|
||||
}}</span>
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (card.actions && card.actions.length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="rail__action-trigger"
|
||||
[matMenuTriggerFor]="cardActionMenu"
|
||||
[matMenuTriggerData]="{ card: card }"
|
||||
[attr.aria-label]="'SHOW_MORE' | translate"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-actions' : null
|
||||
"
|
||||
(click)="stopActionEvent($event)"
|
||||
<div class="rail__channel-meta">
|
||||
<span class="rail__channel-name">{{
|
||||
card.title
|
||||
}}</span>
|
||||
<span class="rail__channel-now">
|
||||
@if (card.nowPlayingTitle) {
|
||||
{{ card.nowPlayingTitle }}
|
||||
} @else if (card.subtitle) {
|
||||
{{ card.subtitle }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail__channel-progress-row">
|
||||
<span class="rail__channel-live-chip"
|
||||
>LIVE</span
|
||||
>
|
||||
<span
|
||||
class="rail__channel-progress"
|
||||
[class.rail__channel-progress--idle]="
|
||||
card.nowPlayingProgress == null
|
||||
"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="
|
||||
card.nowPlayingProgress ?? 0
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
@if (card.nowPlayingTimeRange) {
|
||||
<span class="rail__channel-time">{{
|
||||
card.nowPlayingTimeRange
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="rail__card"
|
||||
role="listitem"
|
||||
[attr.title]="card.title"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card' : null
|
||||
"
|
||||
>
|
||||
<a
|
||||
class="rail__card-link"
|
||||
[routerLink]="card.link"
|
||||
[state]="card.state"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="rail__art"
|
||||
[class.rail__art--live]="
|
||||
card.contentType === 'live'
|
||||
"
|
||||
[style.aspect-ratio]="aspectRatio()"
|
||||
>
|
||||
@if (
|
||||
card.imageUrl && !failedImages()[card.id]
|
||||
) {
|
||||
@if (card.contentType === 'live') {
|
||||
<span
|
||||
class="rail__art-bg"
|
||||
aria-hidden="true"
|
||||
[style.background-image]="
|
||||
'url(' + card.imageUrl + ')'
|
||||
"
|
||||
></span>
|
||||
}
|
||||
<img
|
||||
[src]="card.imageUrl"
|
||||
[alt]="card.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markFailed(card.id)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="rail__art-fallback">
|
||||
<mat-icon>{{ card.icon }}</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="rail__meta">
|
||||
<span class="rail__card-title">{{
|
||||
card.title
|
||||
}}</span>
|
||||
@if (card.subtitle) {
|
||||
<span class="rail__card-subtitle">{{
|
||||
card.subtitle
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@if (card.actions && card.actions.length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="rail__action-trigger"
|
||||
[matMenuTriggerFor]="cardActionMenu"
|
||||
[matMenuTriggerData]="{ card: card }"
|
||||
[attr.aria-label]="'SHOW_MORE' | translate"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card-actions' : null
|
||||
"
|
||||
(click)="stopActionEvent($event)"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -35,8 +35,29 @@ export interface DashboardRailCard {
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
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<DashboardRailCard[]>();
|
||||
readonly seeAllLink = input<string[] | null>(null);
|
||||
readonly aspectRatio = input<string>('2 / 3');
|
||||
readonly layout = input<DashboardRailLayout>('cover');
|
||||
readonly testId = input<string | null>(null);
|
||||
readonly actionSelected = output<DashboardRailActionSelection>();
|
||||
/**
|
||||
|
||||
+3
-2
@@ -114,12 +114,13 @@
|
||||
}
|
||||
|
||||
<!-- Live now on your favorites (channels you recently watched) -->
|
||||
@if (liveOnFavoritesCards().length > 0) {
|
||||
@if (liveOnFavoritesCardsEnriched().length > 0) {
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.LIVE_RECENT' | translate"
|
||||
[items]="liveOnFavoritesCards()"
|
||||
[items]="liveOnFavoritesCardsEnriched()"
|
||||
[totalCount]="data.globalRecentLiveItems().length"
|
||||
[seeAllLink]="['/workspace/global-recent']"
|
||||
layout="channel"
|
||||
[testId]="'dashboard-live-recent-rail'"
|
||||
/>
|
||||
}
|
||||
|
||||
+84
-1
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+120
@@ -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<string>();
|
||||
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<string, EpgProgram | null>())
|
||||
: interval(LIVE_EPG_TICK_MS).pipe(
|
||||
startWith(0),
|
||||
switchMap(() =>
|
||||
this.epgService.getCurrentProgramsForChannels(
|
||||
keys
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
{ initialValue: new Map<string, EpgProgram | null>() }
|
||||
);
|
||||
|
||||
readonly liveOnFavoritesCardsEnriched = computed<DashboardRailCard[]>(
|
||||
() => {
|
||||
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<DashboardRailCard[]>(() =>
|
||||
this.data
|
||||
.globalFavoriteItems()
|
||||
|
||||
Reference in new issue
Block a user