mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor(dashboard): fold the portal EPG wiring into the live-EPG presenter
Rebase onto #1637, which introduced `DashboardLiveEpgPresenter` for the rails' XMLTV lookup. Rather than wiring a second presenter into the page component, the portal lookup now hangs off that one: - `DashboardLiveEpgPresenter` derives the portal rows from the enabled rails itself, pins the hero key, forwards visible cards, and `enrich()` returns the cards with their "now on air" row filled in — a portal answer first, the XMLTV title match as fallback. - The component only provides both presenters, sets `liveEpgSourceKey` on its live cards, and forwards each rail's visible set. That keeps the page component at 835 lines, seven fewer than before this branch, instead of the 60 it would otherwise have gained on a file the max-lines baseline already covers. The generated baseline is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
15d7709934
commit
9c5c256b0d
4 files changed
+173
-82
No files matched your search
@@ -129,9 +129,12 @@ 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. That XMLTV
|
||||
lookup is one batched local query for every live card and re-runs on
|
||||
the 30 s tick.
|
||||
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
|
||||
@@ -149,9 +152,12 @@ Render rules:
|
||||
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.
|
||||
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.
|
||||
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,
|
||||
@@ -180,8 +186,9 @@ Render rules:
|
||||
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.
|
||||
- `enrichLiveCards` prefers the portal answer, falls back to the XMLTV
|
||||
title match when the portal said "nothing on air", and marks a card
|
||||
- `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.
|
||||
|
||||
+89
-2
@@ -6,6 +6,7 @@ import type { EpgProgram, PlaylistMeta } 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 +37,14 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
let presenter: DashboardLiveEpgPresenter;
|
||||
let getCurrentProgramsForChannels: jest.Mock;
|
||||
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
/** 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 +64,30 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
{ _id: 'portal', serverUrl: 'http://portal' } as PlaylistMeta,
|
||||
]);
|
||||
|
||||
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: signal([]),
|
||||
globalFavoriteLiveItems: signal([]),
|
||||
globalRecentLiveItems: signal([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgService,
|
||||
@@ -68,7 +95,10 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { resolvedEpgOffsetMinutes: () => 0 },
|
||||
useValue: {
|
||||
resolvedEpgOffsetMinutes: () => 0,
|
||||
dashboardRails: signal(undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -136,6 +166,63 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -22,8 +24,12 @@ import type {
|
||||
PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { normalizeDashboardRailsSettings } from '@iptvnator/shared/interfaces';
|
||||
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 {
|
||||
@@ -37,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;
|
||||
@@ -124,26 +131,73 @@ export class DashboardLiveEpgPresenter {
|
||||
{ initialValue: new Map<string, EpgProgram | 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 = normalizeDashboardRailsSettings(
|
||||
this.settingsStore.dashboardRails?.()
|
||||
);
|
||||
const hero = this.data.globalRecentItems()[0] ?? null;
|
||||
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)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
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.
|
||||
effect(() => {
|
||||
const [first] = this.portalItems();
|
||||
const heroKey =
|
||||
first?.type === 'live'
|
||||
? buildDashboardPortalLiveEpgKey(first)
|
||||
: 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);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -175,7 +229,7 @@ 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 {
|
||||
private isAwaitingFirstAnswer(card: DashboardRailCard): boolean {
|
||||
return (
|
||||
this.portal.programFor(card.liveEpgSourceKey) === undefined &&
|
||||
this.portal.isPending(card.liveEpgSourceKey)
|
||||
|
||||
+3
-60
@@ -59,10 +59,7 @@ import type {
|
||||
DashboardRailCard,
|
||||
DashboardRailActionSelection,
|
||||
} from './dashboard-rail.component';
|
||||
import type {
|
||||
PlaylistMeta,
|
||||
PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
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';
|
||||
@@ -277,40 +274,16 @@ 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())
|
||||
);
|
||||
|
||||
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[]>(() =>
|
||||
@@ -417,17 +390,6 @@ 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
|
||||
@@ -612,25 +574,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private enrichLiveCards(
|
||||
cards: readonly DashboardRailCard[]
|
||||
): DashboardRailCard[] {
|
||||
return cards.map((card) => {
|
||||
const details = this.liveEpg.detailsFor(card);
|
||||
// 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 ?? {}),
|
||||
nowPlayingState: pending ? 'pending' : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private buildNonLiveSeeAllState(
|
||||
cards: readonly DashboardRailCard[]
|
||||
): Record<string, unknown> {
|
||||
|
||||
Reference in new issue
Block a user