feat(dashboard): portal EPG on the live rails, loaded lazily per visible card

Xtream and Stalker live cards on the dashboard showed only the LIVE chip: they carry no XMLTV key and the rails never asked the portal. They now get their programme from the portal one card at a time, only once the card is on screen, with a bounded queue (2 in flight, 200 ms apart, 60 s TTL, 30 s failure cooldown) and every answer published as it lands, so the page never waits and a slow portal delays no other card. The rail reports visible cards through an IntersectionObserver; the presenter turns them plus the pinned hero into the wanted set; the service resolves each through StreamResolverService.loadEpgForItems, the same call the See all pages make. A shimmer placeholder shows only before a card's first answer. Follow-up to #1637.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-20 21:51:52 +02:00
1 parent 954c8ad65e
commit 9fc487dcb3
18 files changed
+1653 -31

No files matched your search

@@ -1,4 +1,6 @@
export * from './lib/dashboard-data.service';
export * from './lib/dashboard-portal-live-epg.service';
export * from './lib/dashboard-portal-live-epg.util';
export * from './lib/dashboard-recommendations.service';
export * from './lib/dashboard-recommendations.util';
export * from './lib/dashboard-source-expiry.service';
@@ -0,0 +1,243 @@
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
import { StreamResolverService } from '@iptvnator/portal/shared/data-access';
import {
DASHBOARD_PORTAL_LIVE_EPG_TIMING,
DashboardPortalLiveEpgService,
} from './dashboard-portal-live-epg.service';
import type { DashboardPortalLiveEpgEntry } from './dashboard-portal-live-epg.util';
interface Deferred {
readonly key: string;
resolve: (program: EpgProgram | null) => void;
reject: (error: unknown) => void;
}
const entry = (id: number): DashboardPortalLiveEpgEntry => ({
key: `xtream::p::${id}`,
item: {
uid: `xtream::p::${id}`,
name: `Channel ${id}`,
contentType: 'live',
sourceType: 'xtream',
playlistId: 'p',
playlistName: 'Portal',
xtreamId: id,
tvgId: String(id),
},
});
const program = (title: string, stopIso?: string): EpgProgram =>
({
channel: 'x',
title,
start: '2026-05-23T10:00:00.000Z',
stop: stopIso ?? '2026-05-23T11:00:00.000Z',
}) as EpgProgram;
describe('DashboardPortalLiveEpgService', () => {
let service: DashboardPortalLiveEpgService;
let loadEpgForItems: jest.Mock;
let deferred: Deferred[];
let offsetMinutes: number;
let sourceChanged: Subject<void>;
const { delayMs, ttlMs, failureCooldownMs, endedRefetchFloorMs } =
DASHBOARD_PORTAL_LIVE_EPG_TIMING;
/** Let the queue loop take its next step (one inter-request delay). */
const step = async (rounds = 1): Promise<void> => {
for (let i = 0; i < rounds; i++) {
await jest.advanceTimersByTimeAsync(delayMs);
}
};
/** Settle the LATEST request for `key` — a key can be asked more than once. */
const settle = async (key: string, value: EpgProgram | null) => {
const request = [...deferred]
.reverse()
.find((request) => request.key === key);
if (!request) throw new Error(`no request in flight for ${key}`);
request.resolve(value);
await jest.advanceTimersByTimeAsync(0);
};
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
deferred = [];
offsetMinutes = 0;
sourceChanged = new Subject<void>();
loadEpgForItems = jest.fn((items: { tvgId?: string }[]) => {
const key = `xtream::p::${items[0].tvgId}`;
return new Promise<Map<string, EpgProgram | null>>(
(resolve, reject) => {
deferred.push({
key,
resolve: (value) =>
resolve(new Map([[String(items[0].tvgId), value]])),
reject,
});
}
);
});
TestBed.configureTestingModule({
providers: [
DashboardPortalLiveEpgService,
{
provide: StreamResolverService,
useValue: { loadEpgForItems },
},
{
provide: SettingsStore,
useValue: {
resolvedEpgOffsetMinutes: () => offsetMinutes,
},
},
{
provide: EpgSourceSettingsService,
useValue: { changed$: sourceChanged },
},
],
});
service = TestBed.inject(DashboardPortalLiveEpgService);
});
afterEach(() => {
jest.useRealTimers();
});
it('answers each card as its own request lands, never waiting for the slowest', async () => {
service.sync([entry(1), entry(2)]);
expect(service.pending()).toEqual(
new Set(['xtream::p::1', 'xtream::p::2'])
);
await step(2);
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await settle('xtream::p::2', program('Fast answer'));
expect(service.programs().get('xtream::p::2')?.title).toBe(
'Fast answer'
);
expect(service.programs().has('xtream::p::1')).toBe(false);
expect(service.pending()).toEqual(new Set(['xtream::p::1']));
await settle('xtream::p::1', null);
expect(service.programs().get('xtream::p::1')).toBeNull();
expect(service.pending().size).toBe(0);
});
it('never has more than two requests in flight and spaces starts by the delay', async () => {
service.sync([entry(1), entry(2), entry(3), entry(4)]);
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await step(3);
// Two in flight: the loop keeps waiting instead of starting a third.
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await settle('xtream::p::1', null);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(3);
});
it('drops a queued card that scrolled away before its turn and keeps the rest', async () => {
service.sync([entry(1), entry(2), entry(3)]);
// 1 started; 2 and 3 still queued.
service.sync([entry(1), entry(3)]);
expect(service.pending()).toEqual(
new Set(['xtream::p::1', 'xtream::p::3'])
);
await step(2);
const requested = loadEpgForItems.mock.calls.map(
([items]) => items[0].tvgId
);
expect(requested).toEqual(['1', '3']);
});
it('serves a fresh answer from cache and asks again once the TTL has passed', async () => {
service.sync([entry(1)]);
await settle('xtream::p::1', program('Cached'));
await step();
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + ttlMs);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('asks again when the programme on air has ended, but not more often than the floor', async () => {
service.sync([entry(1)]);
await settle(
'xtream::p::1',
program('Ending soon', '2026-05-23T10:35:00.000Z')
);
await step();
// Ended 5 min in, but the floor (30 s) already passed → refetch.
jest.setSystemTime(Date.now() + 6 * 60_000);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
// Portal keeps returning the ended row: not re-asked within the floor.
await settle(
'xtream::p::1',
program('Still ended', '2026-05-23T10:35:00.000Z')
);
await step();
jest.setSystemTime(Date.now() + endedRefetchFloorMs / 2);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('leaves a failed portal alone for the cooldown and keeps the card unanswered', async () => {
service.sync([entry(1)]);
deferred[0].reject(new Error('portal down'));
await jest.advanceTimersByTimeAsync(0);
expect(service.programs().has('xtream::p::1')).toBe(false);
expect(service.pending().size).toBe(0);
jest.setSystemTime(Date.now() + failureCooldownMs / 2);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + failureCooldownMs);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('retires an answer evaluated under a previous display offset and asks again', async () => {
service.sync([entry(1)]);
offsetMinutes = 60;
await settle('xtream::p::1', program('Old clock'));
await step();
expect(service.programs().has('xtream::p::1')).toBe(false);
// Still wanted → requeued under the new clock.
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await settle('xtream::p::1', program('New clock'));
expect(service.programs().get('xtream::p::1')?.title).toBe('New clock');
});
it('drops every answer when the EPG sources change and asks the wanted cards again', async () => {
service.sync([entry(1)]);
await settle('xtream::p::1', program('Before import'));
await step();
sourceChanged.next();
expect(service.programs().size).toBe(0);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,263 @@
import { inject, Injectable, OnDestroy, signal } from '@angular/core';
import {
epgProviderClockMs,
type EpgProgram,
} from '@iptvnator/shared/interfaces';
import { EpgSourceSettingsService, SettingsStore } from '@iptvnator/services';
import { StreamResolverService } from '@iptvnator/portal/shared/data-access';
import {
dashboardPortalLiveEpgProgramStopMs,
resolveDashboardPortalLiveEpgProgram,
type DashboardPortalLiveEpgEntry,
} from './dashboard-portal-live-epg.util';
interface CachedProgram {
readonly program: EpgProgram | null;
readonly fetchedAt: number;
}
/**
* Same numbers `EpgQueueService` uses against real Xtream panels: two
* requests in flight, 200 ms between starts. A card's answer is trusted for
* a minute; a portal that failed is left alone for 30 s; a programme that
* ended is asked again, but never more often than every 30 s in case the
* portal keeps returning the stale row.
*/
export const DASHBOARD_PORTAL_LIVE_EPG_TIMING = Object.freeze({
maxConcurrency: 2,
delayMs: 200,
ttlMs: 60_000,
failureCooldownMs: 30_000,
endedRefetchFloorMs: 30_000,
});
/**
* Lazy, per-card "what is on air" for the dashboard's Xtream and Stalker
* live cards. `sync()` receives the cards currently worth asking for — the
* visible ones — and the service answers each through the collection
* resolver one card at a time, publishing every answer the moment it lands
* (`programs`) so a slow portal never delays a fast one. The queue holds
* only wanted keys, so a card scrolled away before its turn is never
* requested. One instance for the app: favourites and recent rows of the
* same channel share the key and therefore the answer.
*/
@Injectable({ providedIn: 'root' })
export class DashboardPortalLiveEpgService implements OnDestroy {
private readonly streamResolver = inject(StreamResolverService);
private readonly settingsStore = inject(SettingsStore);
private readonly sourceSubscription = inject(
EpgSourceSettingsService
).changed$.subscribe(() => this.retireAnswers());
private readonly cache = new Map<string, CachedProgram>();
private readonly failureAt = new Map<string, number>();
private wanted = new Map<string, DashboardPortalLiveEpgEntry>();
private queue: string[] = [];
private readonly inFlight = new Set<string>();
private processing = false;
/** Display offset every cached answer was evaluated under. */
private stateOffsetMinutes = this.offsetMinutes();
private readonly programsState = signal<
ReadonlyMap<string, EpgProgram | null>
>(new Map());
private readonly pendingState = signal<ReadonlySet<string>>(new Set());
/** Answers by entry key; absent = not asked yet, `null` = nothing on air. */
readonly programs = this.programsState.asReadonly();
/** Keys queued or in flight — the cards that may show a placeholder. */
readonly pending = this.pendingState.asReadonly();
/**
* Replace the wanted set. Keys without a fresh answer are queued; keys no
* longer wanted are dropped from the queue (an in-flight request is
* allowed to finish and is cached for when the card scrolls back).
*/
sync(entries: readonly DashboardPortalLiveEpgEntry[]): void {
this.retireStateOfPreviousOffset();
this.wanted = new Map(entries.map((entry) => [entry.key, entry]));
this.queue = this.queue.filter((key) => this.wanted.has(key));
const now = Date.now();
for (const entry of entries) {
if (
this.needsFetch(entry.key, now) &&
!this.queue.includes(entry.key)
) {
this.queue.push(entry.key);
}
}
this.publishPending();
if (!this.processing && this.queue.length > 0) {
void this.processQueue();
}
}
ngOnDestroy(): void {
this.sourceSubscription.unsubscribe();
}
private offsetMinutes(): number {
return this.settingsStore.resolvedEpgOffsetMinutes();
}
private needsFetch(key: string, now: number): boolean {
if (this.inFlight.has(key) || this.isCoolingDown(key, now)) {
return false;
}
const cached = this.cache.get(key);
if (!cached) {
return true;
}
if (now - cached.fetchedAt >= DASHBOARD_PORTAL_LIVE_EPG_TIMING.ttlMs) {
return true;
}
const stopMs = dashboardPortalLiveEpgProgramStopMs(cached.program);
return (
stopMs !== null &&
epgProviderClockMs(now, this.stateOffsetMinutes) >= stopMs &&
now - cached.fetchedAt >=
DASHBOARD_PORTAL_LIVE_EPG_TIMING.endedRefetchFloorMs
);
}
private isCoolingDown(key: string, now: number): boolean {
const failedAt = this.failureAt.get(key);
if (failedAt == null) {
return false;
}
if (
now - failedAt >=
DASHBOARD_PORTAL_LIVE_EPG_TIMING.failureCooldownMs
) {
this.failureAt.delete(key);
return false;
}
return true;
}
private async processQueue(): Promise<void> {
this.processing = true;
try {
while (this.queue.length > 0) {
if (
this.inFlight.size >=
DASHBOARD_PORTAL_LIVE_EPG_TIMING.maxConcurrency
) {
await this.delay();
continue;
}
const key = this.queue.shift();
if (
key == null ||
!this.wanted.has(key) ||
!this.needsFetch(key, Date.now())
) {
this.publishPending();
continue;
}
this.inFlight.add(key);
void this.fetch(key);
await this.delay();
}
} finally {
this.processing = false;
}
}
private async fetch(key: string): Promise<void> {
const entry = this.wanted.get(key);
if (!entry) {
this.inFlight.delete(key);
this.publishPending();
return;
}
const offsetMinutes = this.offsetMinutes();
let program: EpgProgram | null = null;
let failed = false;
try {
const epgMap = await this.streamResolver.loadEpgForItems([
entry.item,
]);
program = resolveDashboardPortalLiveEpgProgram(epgMap, entry);
} catch {
failed = true;
}
this.inFlight.delete(key);
// The setting changed while the request was on the wire: this answer
// belongs to the previous provider clock. Retire it and ask again if
// the card is still wanted.
if (offsetMinutes !== this.offsetMinutes()) {
this.retireStateOfPreviousOffset();
this.requeueIfWanted(key);
return;
}
if (failed) {
this.failureAt.set(key, Date.now());
} else {
this.cache.set(key, { program, fetchedAt: Date.now() });
this.programsState.update((programs) => {
const next = new Map(programs);
next.set(key, program);
return next;
});
}
this.publishPending();
}
private requeueIfWanted(key: string): void {
if (this.wanted.has(key) && !this.queue.includes(key)) {
this.queue.push(key);
}
this.publishPending();
if (!this.processing && this.queue.length > 0) {
void this.processQueue();
}
}
/**
* Every answer here is "what is on at the provider clock", so a changed
* display offset drops all of them. The caller's own `sync` (the
* presenter re-syncs on an offset change) asks the wanted cards again.
*/
private retireStateOfPreviousOffset(): void {
const current = this.offsetMinutes();
if (current === this.stateOffsetMinutes) {
return;
}
this.stateOffsetMinutes = current;
this.dropAnswers();
}
/** Removed or re-imported XMLTV: drop every answer and ask again now. */
private retireAnswers(): void {
this.dropAnswers();
if (this.wanted.size > 0) {
this.sync(Array.from(this.wanted.values()));
}
}
private dropAnswers(): void {
this.cache.clear();
this.failureAt.clear();
this.programsState.set(new Map());
}
private publishPending(): void {
const pending = new Set<string>();
for (const key of this.queue) {
if (this.wanted.has(key)) pending.add(key);
}
for (const key of this.inFlight) {
if (this.wanted.has(key)) pending.add(key);
}
this.pendingState.set(pending);
}
private delay(): Promise<void> {
return new Promise((resolve) =>
setTimeout(resolve, DASHBOARD_PORTAL_LIVE_EPG_TIMING.delayMs)
);
}
}
@@ -0,0 +1,160 @@
import type {
EpgProgram,
PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import {
buildDashboardPortalLiveEpgEntry,
buildDashboardPortalLiveEpgKey,
dashboardPortalLiveEpgProgramStopMs,
resolveDashboardPortalLiveEpgProgram,
} from './dashboard-portal-live-epg.util';
const baseItem = (overrides: Partial<PortalActivityItem>): PortalActivityItem =>
({
id: 'item-1',
title: 'News 24',
type: 'live',
playlist_id: 'playlist-1',
playlist_name: 'My portal',
category_id: '7',
xtream_id: 42,
poster_url: 'https://example.com/logo.png',
...overrides,
}) as PortalActivityItem;
describe('buildDashboardPortalLiveEpgEntry', () => {
it('builds an Xtream live entry keyed by the collection uid with the stream id as tvgId', () => {
const entry = buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id: 42 })
);
expect(entry?.key).toBe('xtream::playlist-1::42');
expect(entry?.item).toMatchObject({
uid: 'xtream::playlist-1::42',
name: 'News 24',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'playlist-1',
playlistName: 'My portal',
logo: 'https://example.com/logo.png',
xtreamId: 42,
tvgId: '42',
categoryId: '7',
});
});
it('accepts a numeric string Xtream id and rejects ids that cannot name a stream', () => {
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id: '17' })
)?.item.xtreamId
).toBe(17);
for (const xtream_id of ['', 'abc', 0, -3, 1.5]) {
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id })
)
).toBeNull();
}
});
it('builds a Stalker live entry from the stored portal item, keyed by the extracted id', () => {
const stalkerItem = {
id: 'ch-9',
cmd: 'ffrt http://portal/9',
radio: false,
};
const entry = buildDashboardPortalLiveEpgEntry(
baseItem({
source: 'stalker',
id: 'ch-9',
xtream_id: 'ch-9',
stalker_item: stalkerItem,
})
);
expect(entry?.key).toBe('stalker::playlist-1::ch-9');
expect(entry?.item).toMatchObject({
sourceType: 'stalker',
stalkerId: 'ch-9',
tvgId: 'ch-9',
stalkerCmd: 'ffrt http://portal/9',
stalkerItem,
});
});
it('skips Stalker radio rows and rows without the stored item, like the collection resolver', () => {
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({
source: 'stalker',
stalker_item: { id: 'r-1', radio: 'true' },
})
)
).toBeNull();
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'stalker', stalker_item: undefined })
)
).toBeNull();
});
it('leaves M3U rows and non-live rows to the XMLTV batch', () => {
expect(
buildDashboardPortalLiveEpgEntry(baseItem({ source: 'm3u' }))
).toBeNull();
expect(
buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', type: 'movie' })
)
).toBeNull();
expect(
buildDashboardPortalLiveEpgKey(baseItem({ source: 'xtream' }))
).toBe('xtream::playlist-1::42');
expect(
buildDashboardPortalLiveEpgKey(baseItem({ source: 'm3u' }))
).toBeNull();
});
});
describe('resolveDashboardPortalLiveEpgProgram', () => {
it('reads the resolver map by the entry tvgId and treats a missing key as nothing on air', () => {
const entry = buildDashboardPortalLiveEpgEntry(
baseItem({ source: 'xtream', xtream_id: 42 })
);
const program = { title: 'Evening news' } as EpgProgram;
if (!entry) throw new Error('expected an entry');
expect(
resolveDashboardPortalLiveEpgProgram(
new Map([['42', program]]),
entry
)
).toBe(program);
expect(
resolveDashboardPortalLiveEpgProgram(new Map(), entry)
).toBeNull();
});
});
describe('dashboardPortalLiveEpgProgramStopMs', () => {
it('prefers the unix-seconds stop, falls back to the ISO string, and reports null otherwise', () => {
expect(
dashboardPortalLiveEpgProgramStopMs({
stopTimestamp: 1_700_000_000,
stop: '2026-01-01T00:00:00.000Z',
} as EpgProgram)
).toBe(1_700_000_000_000);
expect(
dashboardPortalLiveEpgProgramStopMs({
stop: '2026-01-01T00:00:00.000Z',
} as EpgProgram)
).toBe(Date.parse('2026-01-01T00:00:00.000Z'));
expect(
dashboardPortalLiveEpgProgramStopMs({
stop: 'garbage',
} as EpgProgram)
).toBeNull();
expect(dashboardPortalLiveEpgProgramStopMs(null)).toBeNull();
});
});
@@ -0,0 +1,136 @@
import {
isStalkerRadioItem,
type EpgProgram,
type PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import {
buildCollectionUid,
type UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
/**
* One dashboard live card's portal EPG request: the key its answer is filed
* under (the collection uid, stable across favourites and recent rows of the
* same channel) and the item `StreamResolverService.loadEpgForItems` fetches
* for — the same shape the "See all" pages hand it, so the two surfaces
* resolve a channel identically.
*/
export interface DashboardPortalLiveEpgEntry {
readonly key: string;
readonly item: UnifiedCollectionItem;
}
/** The key alone, for cards that only need to find their answer. */
export function buildDashboardPortalLiveEpgKey(
item: PortalActivityItem
): string | null {
return buildDashboardPortalLiveEpgEntry(item)?.key ?? null;
}
/**
* M3U channels keep the dashboard's batched XMLTV lookup; Xtream and Stalker
* live rows have no XMLTV key of their own and are answered by their portal.
* Radio rows are skipped like the collection resolver skips them.
*/
export function buildDashboardPortalLiveEpgEntry(
item: PortalActivityItem
): DashboardPortalLiveEpgEntry | null {
if (item.type !== 'live') {
return null;
}
if (item.source === 'xtream') {
return buildXtreamEntry(item);
}
if (item.source === 'stalker') {
return buildStalkerEntry(item);
}
return null;
}
/**
* The resolver keys its map by the item's `tvgId`; the entry sets it to the
* provider id, so a missing key means "asked, nothing on air".
*/
export function resolveDashboardPortalLiveEpgProgram(
epgMap: ReadonlyMap<string, EpgProgram | null>,
entry: DashboardPortalLiveEpgEntry
): EpgProgram | null {
const key = entry.item.tvgId?.trim();
return key ? (epgMap.get(key) ?? null) : null;
}
/**
* When the programme on air ends, in wall-clock milliseconds; `null` when the
* row states no usable end. Reads the pre-computed unix-seconds field first,
* then the ISO string, like the dashboard's own time-range helper.
*/
export function dashboardPortalLiveEpgProgramStopMs(
program: EpgProgram | null
): number | null {
if (!program) {
return null;
}
const cached = Number(program.stopTimestamp);
if (Number.isFinite(cached) && cached > 0) {
return cached * 1000;
}
const parsed = program.stop ? Date.parse(program.stop) : NaN;
return Number.isFinite(parsed) ? parsed : null;
}
function buildXtreamEntry(
item: PortalActivityItem
): DashboardPortalLiveEpgEntry | null {
const xtreamId = Number(item.xtream_id);
if (!Number.isInteger(xtreamId) || xtreamId <= 0) {
return null;
}
const key = buildCollectionUid('xtream', item.playlist_id, xtreamId);
return {
key,
item: {
uid: key,
name: item.title,
contentType: 'live',
sourceType: 'xtream',
playlistId: item.playlist_id,
playlistName: item.playlist_name ?? 'Xtream',
logo: item.poster_url ?? null,
xtreamId,
tvgId: String(xtreamId),
categoryId: item.category_id,
},
};
}
function buildStalkerEntry(
item: PortalActivityItem
): DashboardPortalLiveEpgEntry | null {
const raw = item.stalker_item;
if (!raw || isStalkerRadioItem(raw)) {
return null;
}
// Both dashboard mappers already store the extracted portal id as `id`.
const stalkerId = String(item.id ?? '').trim();
if (!stalkerId) {
return null;
}
const key = buildCollectionUid('stalker', item.playlist_id, stalkerId);
return {
key,
item: {
uid: key,
name: item.title,
contentType: 'live',
sourceType: 'stalker',
playlistId: item.playlist_id,
playlistName: item.playlist_name ?? 'Stalker',
logo: item.poster_url ?? null,
stalkerId,
tvgId: stalkerId,
stalkerCmd: raw.cmd,
categoryId: item.category_id,
stalkerItem: raw,
},
};
}
@@ -17,11 +17,15 @@ import {
switchMap,
} from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import type {
EpgProgram,
PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardRailCard } from './dashboard-rail.component';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
import {
buildDashboardLiveEpgDetails,
buildLiveEpgLookupGroups,
@@ -60,12 +64,19 @@ const emptyAnswer = (scopeKey: string): ScopeAnswer => ({
* XMLTV, which is what the "See all" collection pages resolve against.
* Without it a channel whose guide only exists in another playlist's XMLTV
* showed no programme here while its "See all" row had one.
*
* It is also the one facade the rails talk to for live EPG: an Xtream or
* Stalker card has no XMLTV key of its own, so its programme comes from
* `DashboardPortalLiveEpgPresenter` and only falls back to the title match
* here.
*/
@Injectable()
export class DashboardLiveEpgPresenter {
private readonly data = inject(DashboardDataService);
private readonly epgService = inject(EpgService);
private readonly settingsStore = inject(SettingsStore);
/** Xtream/Stalker cards are answered by their portal, not by XMLTV. */
private readonly portal = inject(DashboardPortalLiveEpgPresenter);
private readonly cards = signal<Signal<
readonly DashboardRailCard[]
@@ -118,19 +129,39 @@ export class DashboardLiveEpgPresenter {
this.cards.set(cards);
}
/** The Xtream/Stalker rows whose programmes come from their portal. */
connectPortalItems(items: Signal<readonly PortalActivityItem[]>): void {
this.portal.connect(items);
}
/** Portal keys wanted regardless of scrolling (the hero card). */
setPinnedPortalKeys(keys: readonly (string | null | undefined)[]): void {
this.portal.setPinnedKeys(keys);
}
/** A rail reported the cards inside its viewport. */
setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void {
this.portal.setVisibleCards(railId, cards);
}
/** `null` when nothing is known about the card's current programme. */
detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null {
if (!card) {
return null;
}
const program = getLiveEpgProgramForCard(
card,
this.programs(),
liveEpgScopeKey(
this.sourceUrlsForCard(card),
liveEpgAllowsAnySource(card)
)
);
// A portal answer wins. Its `null` ("asked, nothing on air") and
// "not asked yet" both fall back to the XMLTV lookup, which for a
// portal card can only ever be a title match.
const program =
this.portal.programFor(card.liveEpgSourceKey) ??
getLiveEpgProgramForCard(
card,
this.programs(),
liveEpgScopeKey(
this.sourceUrlsForCard(card),
liveEpgAllowsAnySource(card)
)
);
// Recompute the now-window each tick so progress moves between
// 30s ticks even if the program identity is unchanged.
return buildDashboardLiveEpgDetails(
@@ -140,6 +171,17 @@ export class DashboardLiveEpgPresenter {
);
}
/**
* True only before a card's FIRST portal answer, so a refresh keeps the
* previous answer on screen instead of flashing a placeholder.
*/
isAwaitingFirstAnswer(card: DashboardRailCard): boolean {
return (
this.portal.programFor(card.liveEpgSourceKey) === undefined &&
this.portal.isPending(card.liveEpgSourceKey)
);
}
/** The XMLTV scope a live card's programme must be resolved in. */
private sourceUrlsForCard(card: DashboardRailCard): string[] {
const byPlaylistId = this.sourceUrlsByPlaylist();
@@ -0,0 +1,152 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import type {
EpgProgram,
PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import {
DashboardPortalLiveEpgService,
type DashboardPortalLiveEpgEntry,
} from '@iptvnator/workspace/dashboard/data-access';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
const xtreamLive = (id: number, playlist = 'p'): PortalActivityItem =>
({
id: `x-${id}`,
title: `Channel ${id}`,
type: 'live',
source: 'xtream',
playlist_id: playlist,
category_id: '1',
xtream_id: id,
}) as PortalActivityItem;
describe('DashboardPortalLiveEpgPresenter', () => {
let presenter: DashboardPortalLiveEpgPresenter;
let sync: jest.Mock;
let programs: ReturnType<
typeof signal<ReadonlyMap<string, EpgProgram | null>>
>;
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
let offsetMinutes: ReturnType<typeof signal<number>>;
/** Keys of every sync call, sorted: the wanted set has no order. */
const wantedKeys = (): string[][] =>
sync.mock.calls.map(([entries]: [DashboardPortalLiveEpgEntry[]]) =>
entries.map((entry) => entry.key).sort()
);
beforeEach(() => {
sync = jest.fn();
programs = signal<ReadonlyMap<string, EpgProgram | null>>(new Map());
pending = signal<ReadonlySet<string>>(new Set());
offsetMinutes = signal(0);
TestBed.configureTestingModule({
providers: [
DashboardPortalLiveEpgPresenter,
{
provide: DashboardPortalLiveEpgService,
useValue: { sync, programs, pending },
},
{
provide: SettingsStore,
useValue: { resolvedEpgOffsetMinutes: offsetMinutes },
},
],
});
presenter = TestBed.inject(DashboardPortalLiveEpgPresenter);
});
it('asks only for pinned and visible cards, deduplicated across rails', () => {
const items = signal<readonly PortalActivityItem[]>([
xtreamLive(1),
xtreamLive(2),
xtreamLive(3),
// The same channel in the recent rail shares its key.
xtreamLive(2),
]);
presenter.connect(items);
TestBed.tick();
expect(wantedKeys().at(-1)).toEqual([]);
presenter.setPinnedKeys(['xtream::p::1', null, undefined]);
presenter.setVisibleCards('favorites', [
{ id: 'f2', liveEpgSourceKey: 'xtream::p::2' },
{ id: 'f3', liveEpgSourceKey: 'xtream::p::3' },
] as never);
presenter.setVisibleCards('recent', [
{ id: 'r2', liveEpgSourceKey: 'xtream::p::2' },
{ id: 'm3u', liveEpgSourceKey: null },
] as never);
TestBed.tick();
expect(wantedKeys().at(-1)).toEqual([
'xtream::p::1',
'xtream::p::2',
'xtream::p::3',
]);
// Scrolled away: the favourites rail now shows only channel 3.
presenter.setVisibleCards('favorites', [
{ id: 'f3', liveEpgSourceKey: 'xtream::p::3' },
] as never);
TestBed.tick();
expect(wantedKeys().at(-1)).toEqual([
'xtream::p::1',
'xtream::p::2',
'xtream::p::3',
]);
presenter.setVisibleCards('recent', []);
TestBed.tick();
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1', 'xtream::p::3']);
});
it('ignores visible keys whose item is no longer on the dashboard', () => {
const items = signal<readonly PortalActivityItem[]>([xtreamLive(1)]);
presenter.connect(items);
presenter.setVisibleCards('favorites', [
{ id: 'f1', liveEpgSourceKey: 'xtream::p::1' },
{ id: 'gone', liveEpgSourceKey: 'xtream::p::9' },
] as never);
TestBed.tick();
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']);
items.set([]);
TestBed.tick();
expect(wantedKeys().at(-1)).toEqual([]);
});
it('re-syncs the same wanted set when the display offset changes', () => {
presenter.connect(
signal<readonly PortalActivityItem[]>([xtreamLive(1)])
);
presenter.setPinnedKeys(['xtream::p::1']);
TestBed.tick();
const before = sync.mock.calls.length;
offsetMinutes.set(30);
TestBed.tick();
expect(sync.mock.calls.length).toBe(before + 1);
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']);
});
it('answers a card from the service: undefined until asked, null when nothing is on air', () => {
const program = { title: 'Now' } as EpgProgram;
expect(presenter.programFor('xtream::p::1')).toBeUndefined();
expect(presenter.programFor(null)).toBeUndefined();
expect(presenter.isPending('xtream::p::1')).toBe(false);
pending.set(new Set(['xtream::p::1']));
expect(presenter.isPending('xtream::p::1')).toBe(true);
expect(presenter.isPending(undefined)).toBe(false);
programs.set(
new Map([
['xtream::p::1', program],
['xtream::p::2', null],
])
);
expect(presenter.programFor('xtream::p::1')).toBe(program);
expect(presenter.programFor('xtream::p::2')).toBeNull();
});
});
@@ -0,0 +1,129 @@
import {
computed,
effect,
inject,
Injectable,
signal,
untracked,
type Signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval, map } from 'rxjs';
import type {
EpgProgram,
PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import {
buildDashboardPortalLiveEpgEntry,
DashboardPortalLiveEpgService,
type DashboardPortalLiveEpgEntry,
} from '@iptvnator/workspace/dashboard/data-access';
import type { DashboardRailCard } from './dashboard-rail.component';
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
/**
* The rails component's view of `DashboardPortalLiveEpgService`: which
* portal live cards exist, which of them are on screen, and the answer for
* a card. Component-provided, so its wanted set dies with the dashboard.
*
* "On screen" is what the rails report through their visibility output,
* plus the pinned keys (the hero card, always at the top). A card the user
* never scrolls to is never asked for. The 30 s tick re-syncs so a programme
* that ended is asked again and progress bars keep moving; a changed display
* offset re-syncs at once because every cached answer was just retired.
*/
@Injectable()
export class DashboardPortalLiveEpgPresenter {
private readonly service = inject(DashboardPortalLiveEpgService);
private readonly settingsStore = inject(SettingsStore);
private readonly source = signal<Signal<
readonly PortalActivityItem[]
> | null>(null);
private readonly visibleKeysByRail = signal<
ReadonlyMap<string, ReadonlySet<string>>
>(new Map());
private readonly pinnedKeys = signal<ReadonlySet<string>>(new Set());
/** Heartbeat shared with the XMLTV batch: shifted by one so the first
* emission differs from `initialValue` and is not swallowed. */
readonly tick = toSignal(
interval(LIVE_EPG_TICK_MS).pipe(map((tick) => tick + 1)),
{ initialValue: 0 }
);
private readonly entries = computed<
ReadonlyMap<string, DashboardPortalLiveEpgEntry>
>(() => {
const source = this.source();
const entries = new Map<string, DashboardPortalLiveEpgEntry>();
for (const item of source?.() ?? []) {
const entry = buildDashboardPortalLiveEpgEntry(item);
if (entry && !entries.has(entry.key)) {
entries.set(entry.key, entry);
}
}
return entries;
});
private readonly wanted = computed<DashboardPortalLiveEpgEntry[]>(() => {
const entries = this.entries();
const keys = new Set<string>(this.pinnedKeys());
for (const railKeys of this.visibleKeysByRail().values()) {
for (const key of railKeys) keys.add(key);
}
const wanted: DashboardPortalLiveEpgEntry[] = [];
for (const key of keys) {
const entry = entries.get(key);
if (entry) wanted.push(entry);
}
return wanted;
});
constructor() {
effect(() => {
const wanted = this.wanted();
this.tick();
this.settingsStore.resolvedEpgOffsetMinutes();
untracked(() => this.service.sync(wanted));
});
}
/** The live rows every portal card on the dashboard is built from. */
connect(source: Signal<readonly PortalActivityItem[]>): void {
this.source.set(source);
}
/** Keys wanted regardless of scrolling (the hero card). */
setPinnedKeys(keys: readonly (string | null | undefined)[]): void {
this.pinnedKeys.set(
new Set(keys.filter((key): key is string => !!key))
);
}
/** A rail reported the cards inside its viewport. */
setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void {
const keys = new Set<string>();
for (const card of cards) {
if (card.liveEpgSourceKey) keys.add(card.liveEpgSourceKey);
}
this.visibleKeysByRail.update((byRail) => {
const next = new Map(byRail);
next.set(railId, keys);
return next;
});
}
/**
* `undefined` = not asked yet or still in flight, `null` = asked and
* nothing on air, else the programme.
*/
programFor(key: string | null | undefined): EpgProgram | null | undefined {
return key ? this.service.programs().get(key) : undefined;
}
isPending(key: string | null | undefined): boolean {
return !!key && this.service.pending().has(key);
}
}
@@ -21,8 +21,7 @@
'WORKSPACE.DASHBOARD.SEE_ALL_COUNT'
| translate
: {
count:
totalCount() ?? items().length,
count: totalCount() ?? items().length,
}
}}
} @else {
@@ -56,8 +55,10 @@
@for (card of items(); track card.id) {
@if (layout() === 'channel') {
<div
#cardEl
class="rail__card rail__card--channel"
role="listitem"
[attr.data-card-id]="card.id"
[attr.title]="cardTitle(card)"
[attr.data-test-id]="
testId() ? testId() + '-card' : null
@@ -99,6 +100,14 @@
<span class="rail__channel-now">
@if (card.nowPlayingTitle) {
{{ card.nowPlayingTitle }}
} @else if (
card.nowPlayingState === 'pending'
) {
<span
class="rail__channel-now-placeholder"
aria-hidden="true"
data-test-id="rail-now-placeholder"
></span>
} @else if (card.subtitle) {
{{ card.subtitle }}
}
@@ -132,8 +141,10 @@
</div>
} @else {
<div
#cardEl
class="rail__card"
role="listitem"
[attr.data-card-id]="card.id"
[attr.title]="cardTitle(card)"
[attr.data-test-id]="
testId() ? testId() + '-card' : null
@@ -177,7 +188,10 @@
</div>
}
@if (card.watchProgress !== null && card.watchProgress !== undefined) {
@if (
card.watchProgress !== null &&
card.watchProgress !== undefined
) {
<span
class="rail__art-progress"
aria-hidden="true"
@@ -523,8 +523,7 @@
.rail__card--channel {
background: var(--app-widget-bg, #1e2330);
border: 1px solid
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
border-radius: var(--rail-radius);
padding: 10px 12px;
transition:
@@ -534,10 +533,7 @@
&:hover {
transform: translateY(-1px);
border-color: var(
--app-selection-border,
rgba(47, 123, 255, 0.4)
);
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));
@@ -628,6 +624,38 @@
min-height: 1.35em;
}
// Shown while a portal card's first "now on air" answer is on its way: a
// quiet shimmer bar in the programme slot, sized like one line of text.
.rail__channel-now-placeholder {
display: inline-block;
width: 62%;
height: 0.66em;
margin-top: 0.3em;
border-radius: 4px;
vertical-align: top;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--app-muted-color, #8891a4) 18%, transparent) 25%,
color-mix(in srgb, var(--app-muted-color, #8891a4) 34%, transparent) 50%,
color-mix(in srgb, var(--app-muted-color, #8891a4) 18%, transparent) 75%
);
background-size: 200% 100%;
animation: rail-now-placeholder-shimmer 1.4s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@keyframes rail-now-placeholder-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.rail__channel-progress-row {
display: flex;
align-items: center;
@@ -134,4 +134,212 @@ describe('DashboardRailComponent', () => {
).toBe(true);
});
});
describe('visible cards', () => {
type ObserverCallback = (entries: IntersectionObserverEntry[]) => void;
let observers: {
callback: ObserverCallback;
options: IntersectionObserverInit;
observed: Element[];
disconnect: jest.Mock;
}[];
const installObservers = (available: boolean) => {
observers = [];
const scope = globalThis as unknown as {
IntersectionObserver?: unknown;
ResizeObserver: unknown;
};
scope.ResizeObserver = class {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
if (!available) {
delete scope.IntersectionObserver;
return;
}
scope.IntersectionObserver = class {
observed: Element[] = [];
disconnect = jest.fn();
constructor(
callback: ObserverCallback,
options: IntersectionObserverInit
) {
observers.push({
callback,
options,
observed: this.observed,
disconnect: this.disconnect,
});
}
observe(element: Element): void {
this.observed.push(element);
}
unobserve = jest.fn();
};
};
const render = async (
items: DashboardRailCard[],
layout: 'channel' | 'cover' = 'channel'
) => {
await TestBed.configureTestingModule({
imports: [DashboardRailComponent, TranslateModule.forRoot()],
providers: [
provideRouter([]),
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(false) },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(DashboardRailComponent);
const visible: string[][] = [];
fixture.componentInstance.visibleCardsChanged.subscribe((cards) =>
visible.push(cards.map((card) => card.id))
);
fixture.componentRef.setInput('label', 'Live');
fixture.componentRef.setInput('layout', layout);
fixture.componentRef.setInput('items', items);
fixture.detectChanges();
await fixture.whenStable();
return { fixture, visible };
};
const intersect = (
observer: (typeof observers)[number],
states: Record<string, boolean>
) => {
observer.callback(
observer.observed
.filter(
(element) =>
(element as HTMLElement).dataset['cardId']! in
states
)
.map(
(element) =>
({
target: element,
isIntersecting:
states[
(element as HTMLElement).dataset[
'cardId'
]!
],
}) as IntersectionObserverEntry
)
);
};
it('reports only the cards the observer sees, in item order, inside the track viewport', async () => {
installObservers(true);
const { visible } = await render([
card({ id: 'a', contentType: 'live' }),
card({ id: 'b', contentType: 'live' }),
card({ id: 'c', contentType: 'live' }),
]);
expect(observers).toHaveLength(1);
const [observer] = observers;
expect(observer.observed).toHaveLength(3);
expect(
(observer.options.root as HTMLElement).classList.contains(
'rail__track'
)
).toBe(true);
expect(observer.options.rootMargin).toContain('160px');
intersect(observer, { c: true, a: true, b: false });
expect(visible.at(-1)).toEqual(['a', 'c']);
// Scrolling: b enters, a leaves.
intersect(observer, { b: true, a: false });
expect(visible.at(-1)).toEqual(['b', 'c']);
// Same set again does not re-emit.
const emissions = visible.length;
intersect(observer, { b: true });
expect(visible).toHaveLength(emissions);
});
it('re-observes the rendered elements when the item set changes and forgets removed cards', async () => {
installObservers(true);
const { fixture, visible } = await render([
card({ id: 'a', contentType: 'live' }),
card({ id: 'b', contentType: 'live' }),
]);
const [observer] = observers;
intersect(observer, { a: true, b: true });
expect(visible.at(-1)).toEqual(['a', 'b']);
fixture.componentRef.setInput('items', [
card({ id: 'b', contentType: 'live' }),
card({ id: 'c', contentType: 'live' }),
]);
fixture.detectChanges();
await fixture.whenStable();
// One observer, disconnected and re-armed on the new elements.
expect(observers).toHaveLength(1);
expect(observer.disconnect).toHaveBeenCalled();
expect(
observer.observed
.slice(-2)
.map(
(element) => (element as HTMLElement).dataset['cardId']
)
).toEqual(['b', 'c']);
// `a` is gone from the visible set even before c is notified.
expect(visible.at(-1)).toEqual(['b']);
});
it('reports every card when IntersectionObserver is unavailable', async () => {
installObservers(false);
const { visible } = await render(
[
card({ id: 'a', contentType: 'movie' }),
card({ id: 'b', contentType: 'movie' }),
],
'cover'
);
expect(visible.at(-1)).toEqual(['a', 'b']);
});
it('shows the placeholder only while a live card is pending its first answer', async () => {
installObservers(false);
const { fixture } = await render([
card({
id: 'pending',
contentType: 'live',
subtitle: 'Xtream · TV',
nowPlayingState: 'pending',
}),
card({
id: 'answered',
contentType: 'live',
subtitle: 'Xtream · TV',
nowPlayingTitle: 'Evening news',
nowPlayingState: null,
}),
card({ id: 'none', contentType: 'live', subtitle: 'M3U · TV' }),
]);
const rows = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'.rail__channel-now'
)
);
expect(
rows[0].querySelector('.rail__channel-now-placeholder')
).not.toBeNull();
expect(rows[0].textContent?.trim()).toBe('');
expect(rows[1].textContent?.trim()).toBe('Evening news');
expect(rows[2].textContent?.trim()).toBe('M3U · TV');
expect(
(fixture.nativeElement as HTMLElement).querySelectorAll(
'.rail__channel-now-placeholder'
)
).toHaveLength(1);
});
});
});
@@ -9,7 +9,9 @@ import {
input,
output,
signal,
untracked,
viewChild,
viewChildren,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@@ -46,6 +48,18 @@ export interface DashboardRailCard {
* grouped and namespaced by that playlist's source scope.
*/
epgPlaylistId?: string;
/**
* Key of the portal (Xtream/Stalker) EPG answer for a live card, asked
* for lazily once the card is on screen — see
* `DashboardPortalLiveEpgPresenter`. Unset for M3U cards.
*/
liveEpgSourceKey?: string | null;
/**
* `'pending'` while the card's first portal answer is on its way: the
* 'channel' layout shows a placeholder instead of the subtitle. A later
* refresh keeps the previous answer on screen, so it never flashes.
*/
nowPlayingState?: 'pending' | null;
/**
* Optional EPG enrichment shown by the 'channel' rail layout. Populated
@@ -120,6 +134,13 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
readonly layout = input<DashboardRailLayout>('cover');
readonly testId = input<string | null>(null);
readonly actionSelected = output<DashboardRailActionSelection>();
/**
* The cards inside (or just beyond, see `rootMargin`) the rail's
* viewport, in item order; emitted whenever that set changes. Lets the
* host ask for per-card data — portal EPG — only for cards the user can
* see. Environments without `IntersectionObserver` report every card.
*/
readonly visibleCardsChanged = output<DashboardRailCard[]>();
/**
* True total in the underlying dataset. Shown as a count badge next to
* the rail label. Falls back to `items().length` when not supplied.
@@ -128,6 +149,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
private readonly track =
viewChild.required<ElementRef<HTMLDivElement>>('track');
private readonly cardElements =
viewChildren<ElementRef<HTMLElement>>('cardEl');
readonly canScrollLeft = signal(false);
readonly canScrollRight = signal(false);
@@ -135,6 +158,9 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
private readonly viewReady = signal(false);
private resizeObserver?: ResizeObserver;
private intersectionObserver?: IntersectionObserver;
private readonly visibleCardIds = new Set<string>();
private lastVisibleSignature: string | null = null;
private resetFrameId: number | null = null;
private settleFrameId: number | null = null;
@@ -144,6 +170,14 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
if (!this.viewReady()) return;
this.scheduleResetToStart();
});
// The rendered card set changed: watch the new elements. Reading
// `items()` too keeps an id-only change (same elements, new cards)
// from leaving a stale visible set behind.
effect(() => {
const elements = this.cardElements();
this.items();
untracked(() => this.observeCards(elements));
});
}
ngAfterViewInit(): void {
@@ -157,9 +191,73 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.intersectionObserver?.disconnect();
this.cancelPendingReset();
}
private observeCards(elements: readonly ElementRef<HTMLElement>[]): void {
const renderedIds = new Set(
elements.map((element) => element.nativeElement.dataset['cardId'])
);
for (const id of [...this.visibleCardIds]) {
if (!renderedIds.has(id)) this.visibleCardIds.delete(id);
}
if (typeof IntersectionObserver === 'undefined') {
for (const id of renderedIds) {
if (id) this.visibleCardIds.add(id);
}
this.emitVisibleCards();
return;
}
this.intersectionObserver?.disconnect();
// Cards that left the list are reported gone at once; the observer's
// initial notifications then settle the cards that are still here.
this.emitVisibleCards();
if (elements.length === 0) {
return;
}
// Lazily created: the first non-empty card list means the track
// exists. A margin of roughly one card lets the next card's answer
// arrive before the user scrolls to it. Observing fires an initial
// notification for every target, which settles the visible set.
this.intersectionObserver ??= new IntersectionObserver(
(entries) => this.onCardsIntersect(entries),
{
root: this.track().nativeElement,
rootMargin: '0px 160px 0px 160px',
threshold: 0,
}
);
for (const element of elements) {
this.intersectionObserver.observe(element.nativeElement);
}
}
private onCardsIntersect(entries: IntersectionObserverEntry[]): void {
for (const entry of entries) {
const id = (entry.target as HTMLElement).dataset['cardId'];
if (!id) continue;
if (entry.isIntersecting) {
this.visibleCardIds.add(id);
} else {
this.visibleCardIds.delete(id);
}
}
this.emitVisibleCards();
}
private emitVisibleCards(): void {
const visible = this.items().filter((card) =>
this.visibleCardIds.has(card.id)
);
const signature = visible.map((card) => card.id).join('');
if (signature === this.lastVisibleSignature) return;
this.lastVisibleSignature = signature;
this.visibleCardsChanged.emit(visible);
}
onScroll(): void {
this.updateScrollState();
}
@@ -99,9 +99,7 @@
>
}
@for (genre of item.genres; track genre) {
<span class="hero__badge">{{
genre
}}</span>
<span class="hero__badge">{{ genre }}</span>
}
</span>
}
@@ -149,7 +147,10 @@
</span>
}
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
@if (
item.watchProgress !== null &&
item.watchProgress !== undefined
) {
<span class="hero__progress" aria-hidden="true">
<span
class="hero__progress-bar"
@@ -233,6 +234,9 @@
[seeAllState]="liveSeeAllState"
layout="channel"
[testId]="'dashboard-live-favorites-rail'"
(visibleCardsChanged)="
liveEpg.setVisibleCards('favorites', $event)
"
/>
} @else if (showLiveFavoritesSkeleton()) {
<section
@@ -261,6 +265,9 @@
[seeAllState]="liveSeeAllState"
layout="channel"
[testId]="'dashboard-recent-live-rail'"
(visibleCardsChanged)="
liveEpg.setVisibleCards('recent', $event)
"
/>
}
@@ -270,8 +277,7 @@
) {
<lib-dashboard-rail
[label]="
'WORKSPACE.DASHBOARD.FAVORITE_MOVIES_AND_SERIES'
| translate
'WORKSPACE.DASHBOARD.FAVORITE_MOVIES_AND_SERIES' | translate
"
[items]="favoriteMoviesAndSeriesCards()"
[totalCount]="
@@ -340,7 +346,8 @@
/>
} @else if (
dashboardRails().xtreamRecentlyAdded &&
xtreamPlaylistCount() > 0 && data.xtreamRecentlyAddedLoading()
xtreamPlaylistCount() > 0 &&
data.xtreamRecentlyAddedLoading()
) {
<section
class="rails-page__skeleton-rail"
@@ -42,6 +42,7 @@ import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
buildDashboardPortalLiveEpgKey,
DashboardRecommendationItem,
DashboardRecommendationsService,
DashboardSourceExpiryService,
@@ -58,8 +59,12 @@ import type {
DashboardRailCard,
DashboardRailActionSelection,
} from './dashboard-rail.component';
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
import type {
PlaylistMeta,
PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import type { DashboardHeroModel } from './dashboard-hero.utils';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
import { resolveDashboardHeroArtwork } from './dashboard-hero.utils';
import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
@@ -102,11 +107,12 @@ import type {
host: {
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
},
providers: [DashboardLiveEpgPresenter],
providers: [DashboardLiveEpgPresenter, DashboardPortalLiveEpgPresenter],
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
private readonly liveEpg = inject(DashboardLiveEpgPresenter);
/** One facade for both live-EPG sources: uploaded XMLTV and the portal. */
readonly liveEpg = inject(DashboardLiveEpgPresenter);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
@@ -271,6 +277,30 @@ export class WorkspaceDashboardRailsComponent {
)
);
// The Xtream/Stalker live rows behind the hero and the two live rails.
// Their programmes come from the portal, asked for lazily per visible
// card by DashboardPortalLiveEpgPresenter; M3U rows stay on the XMLTV
// batch above.
private readonly portalLiveItems = computed<readonly PortalActivityItem[]>(
() => {
const rails = this.dashboardRails();
const hero = this.heroRecentItem();
return [
...(rails.hero && hero?.type === 'live' ? [hero] : []),
...(rails.liveFavorites
? this.data
.globalFavoriteLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
: []),
...(rails.recentlyWatchedLive
? this.data
.globalRecentLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
: []),
];
}
);
private readonly playbackPositionReloadKey = computed(() =>
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
);
@@ -387,6 +417,17 @@ export class WorkspaceDashboardRailsComponent {
void this.data.reloadGlobalFavorites();
this.liveEpg.connect(this.enabledLiveCards);
// Portal EPG for the live cards: asked only for cards the rails
// report as visible; the hero sits at the top and is pinned so it
// never waits for a scroll.
this.liveEpg.connectPortalItems(this.portalLiveItems);
effect(() => {
this.liveEpg.setPinnedPortalKeys([
this.dashboardRails().hero
? this.heroLiveCard()?.liveEpgSourceKey
: null,
]);
});
// Refresh when Xtream playlist count changes so a newly added provider
// populates the rail without a manual dashboard reload. The Xtream
@@ -576,10 +617,17 @@ export class WorkspaceDashboardRailsComponent {
): DashboardRailCard[] {
return cards.map((card) => {
const details = this.liveEpg.detailsFor(card);
if (!details) {
// Placeholder only before the FIRST portal answer: a refresh
// keeps the previous answer on screen instead of flashing.
const pending = !details && this.liveEpg.isAwaitingFirstAnswer(card);
if (!details && !pending) {
return card;
}
return { ...card, ...details };
return {
...card,
...(details ?? {}),
nowPlayingState: pending ? 'pending' : null,
};
});
}
@@ -626,6 +674,7 @@ export class WorkspaceDashboardRailsComponent {
contentType: item.type,
epgLookupKey: item.epg_lookup_key,
epgPlaylistId: item.playlist_id,
liveEpgSourceKey: buildDashboardPortalLiveEpgKey(item),
link: this.data.getRecentItemLink(item),
// Default click is detail-only for every card — an in-progress
// series no longer auto-plays on click (issue #1441); resuming
@@ -663,6 +712,7 @@ export class WorkspaceDashboardRailsComponent {
contentType: item.type,
epgLookupKey: item.epg_lookup_key,
epgPlaylistId: item.playlist_id,
liveEpgSourceKey: buildDashboardPortalLiveEpgKey(item),
link: this.data.getGlobalFavoriteLink(item),
state: this.data.getGlobalFavoriteNavigationState(item),
};