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

Xtream and Stalker live cards on the dashboard carried no XMLTV key, so the
rails never asked anything for them and showed only the LIVE chip. Their
programme now comes from the portal, one card at a time and only once the card
is on screen.

- `lib-dashboard-rail` reports the cards inside its viewport through an
  IntersectionObserver rooted at the track; `DashboardLiveEpgPresenter` unions
  them with the pinned hero row and hands the set to
  `DashboardPortalLiveEpgPresenter`.
- `DashboardPortalLiveEpgService` runs the bounded queue — two requests in
  flight, 200 ms apart, one card each — through
  `StreamResolverService.loadEpgForItems`, publishing every answer the moment
  it lands, so the page never waits and a slow portal delays no other card. A
  card scrolled past before its turn is never requested.
- A programme is trusted for 60 s, an empty answer for 30 s (the resolver
  reports a dead portal and a guide-less channel identically), and a
  completion captures both the display offset and the EPG source revision,
  requeueing itself when either moved.
- The presenter hands its wanted set back on destroy. Desktop only: the shared
  collection resolver is gated on the local XMLTV bridge.
- A shimmer placeholder shows only before a card's first portal answer; M3U
  cards keep the batched XMLTV lookup.
This commit is contained in:
4gray authored and GitHub committed 2026-09-20 22:53:55 +02:00
1 parent 4e575a810e
commit 6578e4073c
19 files changed
+1937 -46

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
---
type: feature
area: dashboard
---
On the desktop app, favourite and recently watched Xtream and Stalker channels
on the dashboard now show what is on air, with the programme's time and
progress. Each card asks its portal only once it scrolls into view and fills in
on its own, so a slow portal never holds up the page or the other cards.
+1
View File
@@ -1607,6 +1607,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- Background parsing in worker thread; HTTP/file gzip compatibility follows `docs/architecture/m3u-playlist-module.md` ("XMLTV response compression").
- Stored in database for quick lookup
- Source scope: batch "now" lookups search the playlist's own XMLTV first, then the Settings-managed global sources, and stop there; only `EpgLookupOptions.anySourceFallback` (renderer-only) retries the still-unresolved keys against every imported source. The dashboard live rails pass it, so a favourite whose guide lives in another playlist's XMLTV gets the same programme its "See all" row already showed; they still issue one lookup per distinct playlist source scope and namespace the answers by it (`liveEpgProgramKey`), since a `tvg-id` is unique inside a guide but not across imports, and only cards carrying a real XMLTV key are widened — a portal card's key is just its title (wiring: `DashboardLiveEpgPresenter`). The channel list keeps the strict scope. Contract: `docs/architecture/m3u-playlist-module.md` (the "Scoped lookups" bullet under playlist-scoped URLs)
- Dashboard live rails, portal side: an Xtream or Stalker card carries no XMLTV key, so its programme comes from the portal instead, lazily and per card — `lib-dashboard-rail` reports the cards inside its viewport (`visibleCardsChanged`, `IntersectionObserver` on the track), `DashboardLiveEpgPresenter` forwards them plus the pinned hero to `DashboardPortalLiveEpgPresenter`, and `DashboardPortalLiveEpgService` (data-access, root) runs the bounded queue (2 in flight / 200 ms, 60 s TTL for a programme, 30 s for an empty answer) through `StreamResolverService.loadEpgForItems`, publishing each answer as it lands. A completion captures the display offset AND `EpgSourceSettingsService.revision()` and requeues itself when either moved; the presenter hands its wanted set back on destroy; desktop only, since the resolver is gated on `supportsProgramLookup`. The one shared facade the rails talk to is `DashboardLiveEpgPresenter`: a portal answer wins, an XMLTV title match is the fallback, and a shimmer placeholder shows only before a card's first portal answer. Contract: `docs/architecture/workspace-dashboard.md` (Data Flow, item 3)
- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset")
- Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the EPG panel's Guide button (timeline or list view) or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide").
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
@@ -18,6 +18,7 @@ import {
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamEpgFixture,
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
@@ -47,6 +48,33 @@ test.describe('Dashboard Activation', () => {
liveFixture.items,
getXtreamTitle
);
// The mock's guide for the channel favourited below (its first live
// stream). The programme on air is read off the full guide at
// assertion time: the mock cuts its slots from the second the guide
// was generated, so the "current" listing of a fixture fetched in
// that same second is the slot that ended just then.
const epgFixture = await fetchXtreamEpgFixture(
request,
xtreamCredentials
);
expect(getXtreamTitle(epgFixture.stream)).toBe(liveTitle);
const liveNowTitle = () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const index = epgFixture.fullEpg.findIndex(
(listing) =>
listing.startTimestamp <= nowSeconds &&
nowSeconds < listing.stopTimestamp
);
expect(index).toBeGreaterThanOrEqual(0);
// Tolerate a slot boundary passing between the app's answer and
// this assertion.
const titles = epgFixture.fullEpg
.slice(index, index + 2)
.map((listing) =>
listing.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
return new RegExp(titles.join('|'));
};
const app = await launchElectronApp(dataDir);
try {
@@ -139,6 +167,15 @@ test.describe('Dashboard Activation', () => {
app.mainWindow,
'dashboard-live-favorites-rail'
);
// An Xtream card has no XMLTV key: its "now on air" line comes
// from the portal, asked for lazily once the card is on screen.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
@@ -167,6 +204,14 @@ test.describe('Dashboard Activation', () => {
app.mainWindow,
'dashboard-recent-live-rail'
);
// Same channel, same key: the recent card shares the answer.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',
+63 -1
View File
@@ -141,7 +141,69 @@ Render rules:
`dashboard-recent-live-rail`); there is no fallback from one to the
other. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
fallback order (`tvg-id` -> `tvg-name` -> channel name); EPG enrichment
must use that key before falling back to the card title.
must use that key before falling back to the card title. Both rails are
enriched by `DashboardLiveEpgPresenter`, the one component-provided
facade for live EPG: it owns the XMLTV lookup described under "Scoped
lookups" in `m3u-playlist-module.md`, forwards everything portal-shaped
to `DashboardPortalLiveEpgPresenter`, and `enrich()` returns the cards
with their "now on air" row filled in.
Xtream and Stalker cards have no XMLTV key of their own; their "now on
air" line comes from the portal, **lazily and per card**:
- `buildDashboardPortalLiveEpgEntry` (dashboard data-access) turns a
live `PortalActivityItem` into the `UnifiedCollectionItem` the
collection pages hand `StreamResolverService.loadEpgForItems`, keyed
by the collection uid — favourites and recent rows of one channel
share the answer. Radio rows and rows without a usable provider id
get no entry. Cards carry that key as `liveEpgSourceKey`.
- `lib-dashboard-rail` reports the cards inside its track viewport
(plus ~one card of `rootMargin`) through `visibleCardsChanged`, from
an `IntersectionObserver` rooted at the track; without the API every
card counts as visible. Cards that leave the list are reported gone
at once.
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
visible keys of both rails with the pinned hero key and calls
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
on every change, on the 30 s tick, and on a display-offset change.
It is reached through `DashboardLiveEpgPresenter`, which derives the
portal rows itself from the enabled rails and pins the hero, so the
page component only forwards what a rail can see. The queue lives in
the root service, so leaving the dashboard hands the wanted set back
(`sync([])` on destroy); otherwise the queue would keep asking for
cards on a page that is gone.
- `DashboardPortalLiveEpgService` (root) owns the queue: at most two
requests in flight, 200 ms between starts (the numbers
`EpgQueueService` proved against real panels), one card per request,
each answer published the moment it lands in `programs`, so the page
never waits and a slow portal delays no other card. Only wanted keys
are dequeued, so a card scrolled past before its turn is never
requested. A programme lives 60 s; a programme that ended is asked
again, but not within 30 s of the last answer (a portal may keep
returning the stale row). An answer with **no** programme lives only
30 s, because the resolver reports a failed portal and a guide-less
channel identically (it files per-channel failures as `null`), so
there is no failure cooldown to keep and the short TTL is what lets
an outage recover on the next tick.
- Every answer is "at the provider clock" and against one XMLTV source
set. A request captures both the display offset and
`EpgSourceSettingsService.revision()` — the same fence
`EpgService.guard()` uses — and a completion whose either fact moved
is discarded and requeued instead of published. That requeue has to
happen in the completion: while the key is in flight the retire pass
cannot queue a replacement, and without it the pre-change answer
would be trusted for a full TTL (the repo's late-result
invalidation contract).
- Desktop only in practice: the shared collection resolver is gated on
the local XMLTV bridge (`supportsProgramLookup`) and answers nothing
without it, so `sync()` returns immediately in the PWA rather than
filing an empty answer for every card. Lifting that gate for portal
lookups would change the collection pages too and is deliberately
out of scope here.
- `DashboardLiveEpgPresenter.enrich()` prefers the portal answer, falls
back to the XMLTV title match when the portal said "nothing on air",
and marks a card
`nowPlayingState: 'pending'` only before its FIRST answer — the
channel layout then shows a shimmer placeholder in the programme
slot; a refresh keeps the previous answer on screen.
4. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail
cards. Aggregates newly added VOD and series across *all* Xtream
playlists via `DashboardDataService.reloadXtreamRecentlyAddedItems()`,
@@ -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,324 @@
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgSourceSettingsService,
RuntimeCapabilitiesService,
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>;
let sourceRevision: number;
let supportsEpgProgramLookup: boolean;
const { delayMs, ttlMs, emptyTtlMs, endedRefetchFloorMs } =
DASHBOARD_PORTAL_LIVE_EPG_TIMING;
/** What a reconciliation does: bump the fence, then announce it. */
const changeEpgSources = () => {
sourceRevision++;
sourceChanged.next();
};
/** 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;
sourceRevision = 0;
supportsEpgProgramLookup = true;
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,
revision: () => sourceRevision,
},
},
{
provide: RuntimeCapabilitiesService,
useValue: {
get supportsEpgProgramLookup() {
return supportsEpgProgramLookup;
},
},
},
],
});
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('expires an answer with no programme sooner than one with a programme', async () => {
// The resolver reports a failed portal and a guide-less channel the
// same way, so the short TTL is what lets an outage recover.
service.sync([entry(1)]);
await settle('xtream::p::1', null);
await step();
expect(service.programs().get('xtream::p::1')).toBeNull();
jest.setSystemTime(Date.now() + emptyTtlMs / 2);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + emptyTtlMs / 2);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
// A programme keeps the full TTL, which outlives the empty one.
await settle('xtream::p::1', program('On air'));
await step();
jest.setSystemTime(Date.now() + emptyTtlMs);
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('treats a rejected resolver call as an answer with no programme', async () => {
service.sync([entry(1)]);
deferred[0].reject(new Error('portal down'));
await jest.advanceTimersByTimeAsync(0);
expect(service.programs().get('xtream::p::1')).toBeNull();
expect(service.pending().size).toBe(0);
jest.setSystemTime(Date.now() + emptyTtlMs);
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();
changeEpgSources();
expect(service.programs().size).toBe(0);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
});
it('discards an answer computed before an EPG source change and asks again', async () => {
// The key is in flight when the sources change, so the retire pass
// cannot requeue it; the completion must not publish the old guide's
// answer and must ask again itself.
service.sync([entry(1)]);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
changeEpgSources();
await settle('xtream::p::1', program('Removed guide'));
expect(service.programs().has('xtream::p::1')).toBe(false);
await step();
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
await settle('xtream::p::1', program('Current guide'));
expect(service.programs().get('xtream::p::1')?.title).toBe(
'Current guide'
);
});
it('does nothing at all without the local EPG program-lookup capability', async () => {
// PWA: the collection resolver is gated on the desktop XMLTV bridge
// and answers nothing, so no request is worth queuing.
supportsEpgProgramLookup = false;
service.sync([entry(1), entry(2)]);
await step(3);
expect(loadEpgForItems).not.toHaveBeenCalled();
expect(service.pending().size).toBe(0);
expect(service.programs().size).toBe(0);
});
});
@@ -0,0 +1,275 @@
import { inject, Injectable, OnDestroy, signal } from '@angular/core';
import {
epgProviderClockMs,
type EpgProgram,
} from '@iptvnator/shared/interfaces';
import {
EpgSourceSettingsService,
RuntimeCapabilitiesService,
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 programme is trusted for a
* minute; a programme that ended is asked again, but never more often than
* every 30 s in case the portal keeps returning the stale row.
*
* An answer with no programme expires sooner (`emptyTtlMs`) because the
* collection resolver reports a failed portal and a channel with no guide
* identically — it catches per-channel failures and files them as `null`.
* There is therefore no failure cooldown to keep: the short TTL is what lets
* a transient outage recover on the next tick, at the price of re-asking a
* genuinely guide-less channel while its card stays on screen.
*/
export const DASHBOARD_PORTAL_LIVE_EPG_TIMING = Object.freeze({
maxConcurrency: 2,
delayMs: 200,
ttlMs: 60_000,
emptyTtlMs: 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 runtime = inject(RuntimeCapabilitiesService);
private readonly sourceSettings = inject(EpgSourceSettingsService);
private readonly sourceSubscription =
this.sourceSettings.changed$.subscribe(() => this.retireAnswers());
private readonly cache = new Map<string, CachedProgram>();
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 {
// The collection resolver this queue asks through is gated on the
// local XMLTV bridge (`supportsProgramLookup`, desktop only) and
// answers nothing without it, so the PWA never queues at all rather
// than filing an empty answer for every card.
if (!this.runtime.supportsEpgProgramLookup) {
return;
}
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)) {
return false;
}
const cached = this.cache.get(key);
if (!cached) {
return true;
}
const ttlMs = cached.program
? DASHBOARD_PORTAL_LIVE_EPG_TIMING.ttlMs
: DASHBOARD_PORTAL_LIVE_EPG_TIMING.emptyTtlMs;
if (now - cached.fetchedAt >= 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 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;
}
// Both facts this answer is evaluated against. The revision is the
// same fence `EpgService.guard()` uses: a reconciliation bumps it, so
// a result computed against the previous XMLTV source set can be told
// apart from one computed against the current one.
const offsetMinutes = this.offsetMinutes();
const revision = this.sourceSettings.revision();
let program: EpgProgram | null = null;
try {
const epgMap = await this.streamResolver.loadEpgForItems([
entry.item,
]);
program = resolveDashboardPortalLiveEpgProgram(epgMap, entry);
} catch {
// The resolver files a failed portal as `null` itself, so this
// only catches a resolver-level throw. Same answer either way,
// and `emptyTtlMs` is what makes it recoverable.
program = null;
}
this.inFlight.delete(key);
// A setting or the source set changed while the request was on the
// wire: this answer belongs to the previous provider clock or the
// previous guide. `retireAnswers()` could not requeue the key while
// it was in flight, so the requeue happens here — otherwise the stale
// answer would be published and trusted for a full TTL.
if (
offsetMinutes !== this.offsetMinutes() ||
revision !== this.sourceSettings.revision()
) {
this.retireStateOfPreviousOffset();
this.requeueIfWanted(key);
return;
}
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.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,
},
};
}
@@ -2,10 +2,16 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { EMPTY, of, throwError } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
type EpgProgram,
type PlaylistMeta,
type PortalActivityItem,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
import type { DashboardRailCard } from './dashboard-rail.component';
const guideA = 'https://a.example/guide.xml';
@@ -36,6 +42,18 @@ describe('DashboardLiveEpgPresenter', () => {
let presenter: DashboardLiveEpgPresenter;
let getCurrentProgramsForChannels: jest.Mock;
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
let dashboardRails: ReturnType<
typeof signal<typeof DEFAULT_DASHBOARD_RAILS_SETTINGS>
>;
/** Portal answers are the sibling presenter's job; stub it out here. */
let portal: {
connect: jest.Mock;
setPinnedKeys: jest.Mock;
setVisibleCards: jest.Mock;
programFor: jest.Mock;
isPending: jest.Mock;
};
const setup = (cards: DashboardRailCard[]) => {
presenter.connect(signal(cards));
@@ -55,12 +73,32 @@ describe('DashboardLiveEpgPresenter', () => {
{ _id: 'portal', serverUrl: 'http://portal' } as PlaylistMeta,
]);
recentItems = signal<PortalActivityItem[]>([]);
dashboardRails = signal({ ...DEFAULT_DASHBOARD_RAILS_SETTINGS });
portal = {
connect: jest.fn(),
setPinnedKeys: jest.fn(),
setVisibleCards: jest.fn(),
programFor: jest.fn(() => undefined),
isPending: jest.fn(() => false),
};
TestBed.configureTestingModule({
providers: [
DashboardLiveEpgPresenter,
{
provide: DashboardPortalLiveEpgPresenter,
useValue: portal,
},
{
provide: DashboardDataService,
useValue: { playlists },
useValue: {
playlists,
// The presenter also derives the portal source list.
globalRecentItems: recentItems,
globalFavoriteLiveItems: signal([]),
globalRecentLiveItems: signal([]),
},
},
{
provide: EpgService,
@@ -68,7 +106,10 @@ describe('DashboardLiveEpgPresenter', () => {
},
{
provide: SettingsStore,
useValue: { resolvedEpgOffsetMinutes: () => 0 },
useValue: {
resolvedEpgOffsetMinutes: () => 0,
dashboardRails,
},
},
],
});
@@ -136,6 +177,89 @@ describe('DashboardLiveEpgPresenter', () => {
);
});
it('pins the hero row only while the hero rail shows one', () => {
const heroLive = {
id: 'x-7',
title: 'Hero channel',
type: 'live',
source: 'xtream',
playlist_id: 'p',
category_id: '1',
xtream_id: 7,
} as PortalActivityItem;
recentItems.set([heroLive]);
TestBed.tick();
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith(['xtream::p::7']);
// With the rail hidden nothing is pinned: the first portal row is
// then a favourite, and pinning it would keep asking for a card
// nobody can see.
dashboardRails.set({
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
hero: false,
});
TestBed.tick();
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([null]);
});
it('prefers the portal answer and forwards what the portal presenter owns', () => {
const xmltvCard = card({
id: 'x',
epgLookupKey: 'ard.de',
epgPlaylistId: 'a',
liveEpgSourceKey: 'xtream::p::7',
});
getCurrentProgramsForChannels.mockImplementation(() =>
of(
new Map<string, EpgProgram | null>([
['ard.de', program('From XMLTV')],
])
)
);
portal.programFor.mockImplementation(
(key: string | null | undefined) =>
key === 'xtream::p::7' ? program('From the portal') : undefined
);
setup([xmltvCard]);
expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe(
'From the portal'
);
// A portal that answered "nothing on air" falls back to XMLTV.
portal.programFor.mockReturnValue(null);
expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe(
'From XMLTV'
);
// The placeholder is only for a card still awaiting its FIRST answer.
getCurrentProgramsForChannels.mockImplementation(() =>
of(new Map<string, EpgProgram | null>())
);
setup([xmltvCard]);
portal.programFor.mockReturnValue(undefined);
portal.isPending.mockReturnValue(true);
expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBe(
'pending'
);
// An answered card keeps what it has instead of flashing.
portal.programFor.mockReturnValue(program('Answered'));
expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBeNull();
expect(presenter.enrich([xmltvCard])[0].nowPlayingTitle).toBe(
'Answered'
);
// The portal source list and the hero pin are the presenter's own
// job; the rails only report what they can see.
expect(portal.connect).toHaveBeenCalled();
presenter.setVisibleCards('favorites', [xmltvCard]);
expect(portal.setVisibleCards).toHaveBeenCalledWith('favorites', [
xmltvCard,
]);
});
it('never hands a card the programme another guide resolved for the same id', () => {
const fromA = card({
id: 'a',
@@ -1,8 +1,10 @@
import {
computed,
effect,
inject,
Injectable,
signal,
untracked,
type Signal,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
@@ -17,11 +19,19 @@ import {
switchMap,
} from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import {
normalizeDashboardRailsSettings,
type EpgProgram,
type 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 {
buildDashboardPortalLiveEpgKey,
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,
@@ -33,6 +43,7 @@ import {
type DashboardLiveEpgDetails,
type DashboardLiveEpgLookupGroup,
} from './dashboard-live-epg.utils';
import { RAIL_ITEM_LIMIT } from './dashboard-rail.utils';
type ScopeAnswer = {
readonly scopeKey: string;
@@ -60,12 +71,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[]
@@ -113,24 +131,99 @@ export class DashboardLiveEpgPresenter {
{ initialValue: new Map<string, EpgProgram | null>() }
);
private readonly rails = computed(() =>
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
);
/** The live row behind the hero panel, when that rail shows one. */
private readonly heroLiveItem = computed<PortalActivityItem | null>(() => {
const hero = this.data.globalRecentItems()[0] ?? null;
return this.rails().hero && hero?.type === 'live' ? hero : null;
});
// 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; M3U rows stay on the XMLTV batch above.
private readonly portalItems = computed<readonly PortalActivityItem[]>(
() => {
const rails = this.rails();
const hero = this.heroLiveItem();
return [
...(hero ? [hero] : []),
...(rails.liveFavorites
? this.data
.globalFavoriteLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
: []),
...(rails.recentlyWatchedLive
? this.data
.globalRecentLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
: []),
];
}
);
constructor() {
this.portal.connect(this.portalItems);
// The hero sits at the top of the page and is never scrolled into
// view, so its key is wanted regardless of what the rails report.
// Only the hero: the first entry of `portalItems` is a favourite
// when that rail is hidden, and pinning it would keep asking for a
// card nobody can see.
effect(() => {
const hero = this.heroLiveItem();
const heroKey = hero ? buildDashboardPortalLiveEpgKey(hero) : null;
untracked(() => this.portal.setPinnedKeys([heroKey]));
});
}
/** The live cards whose rails are enabled, hero included. */
connect(cards: Signal<readonly DashboardRailCard[]>): void {
this.cards.set(cards);
}
/** A rail reported the cards inside its viewport. */
setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void {
this.portal.setVisibleCards(railId, cards);
}
/** The rails' cards with their "now on air" row filled in. */
enrich(cards: readonly DashboardRailCard[]): DashboardRailCard[] {
return cards.map((card) => {
const details = this.detailsFor(card);
// Placeholder only before the FIRST portal answer: a refresh
// keeps the previous answer on screen instead of flashing.
const pending = !details && this.isAwaitingFirstAnswer(card);
if (!details && !pending) {
return card;
}
return {
...card,
...(details ?? {}),
nowPlayingState: pending ? 'pending' : null,
};
});
}
/** `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 +233,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.
*/
private 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,166 @@
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('hands its wanted set back to the root service when the dashboard is destroyed', () => {
presenter.connect(
signal<readonly PortalActivityItem[]>([xtreamLive(1)])
);
presenter.setPinnedKeys(['xtream::p::1']);
TestBed.tick();
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']);
// The queue lives in the root service and would otherwise keep
// asking for cards on a page the user has left.
TestBed.resetTestingModule();
expect(wantedKeys().at(-1)).toEqual([]);
});
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,135 @@
import {
computed,
DestroyRef,
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 queue lives in the root service; this presenter owns what it
// wants. Leaving the dashboard must hand that back, or the queue
// would keep asking for cards on a page that is gone — and a later
// source change would ask for them again.
inject(DestroyRef).onDestroy(() => this.service.sync([]));
}
/** 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"
@@ -538,8 +538,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:
@@ -549,10 +548,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));
@@ -643,6 +639,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;
@@ -163,4 +163,212 @@ describe('DashboardRailComponent', () => {
expect(cards[1].querySelector('.rail__card-meta-row')).toBeNull();
});
});
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';
@@ -47,6 +49,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
@@ -128,6 +142,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.
@@ -136,6 +157,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);
@@ -143,6 +166,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;
@@ -152,6 +178,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 {
@@ -165,9 +199,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"
@@ -45,6 +45,7 @@ import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
buildDashboardPortalLiveEpgKey,
DashboardRecommendationItem,
DashboardRecommendationsService,
DashboardSourceExpiryService,
@@ -63,6 +64,7 @@ import type {
} from './dashboard-rail.component';
import type { PlaylistMeta } 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';
@@ -105,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);
@@ -268,11 +271,11 @@ export class WorkspaceDashboardRailsComponent {
);
readonly liveFavoriteCardsEnriched = computed<DashboardRailCard[]>(() =>
this.enrichLiveCards(this.liveFavoriteCards())
this.liveEpg.enrich(this.liveFavoriteCards())
);
readonly recentLiveCardsEnriched = computed<DashboardRailCard[]>(() =>
this.enrichLiveCards(this.recentLiveCards())
this.liveEpg.enrich(this.recentLiveCards())
);
readonly favoriteMoviesAndSeriesCards = computed<DashboardRailCard[]>(() =>
@@ -563,18 +566,6 @@ export class WorkspaceDashboardRailsComponent {
});
}
private enrichLiveCards(
cards: readonly DashboardRailCard[]
): DashboardRailCard[] {
return cards.map((card) => {
const details = this.liveEpg.detailsFor(card);
if (!details) {
return card;
}
return { ...card, ...details };
});
}
private buildNonLiveSeeAllState(
cards: readonly DashboardRailCard[]
): Record<string, unknown> {
@@ -633,6 +624,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
@@ -672,6 +664,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),
};