diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index f239b9273..2f9faf810 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts index fa4f72d3a..47bd26b81 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -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>; + /** 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([ + ['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()) + ); + 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', diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts index 236508129..977ed2cfe 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -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() } ); + // 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( + () => { + 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): void { this.cards.set(cards); } - /** The Xtream/Stalker rows whose programmes come from their portal. */ - connectPortalItems(items: Signal): 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) diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 922a69833..6ab998de7 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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( - () => { - 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(() => - this.enrichLiveCards(this.liveFavoriteCards()) + this.liveEpg.enrich(this.liveFavoriteCards()) ); readonly recentLiveCardsEnriched = computed(() => - this.enrichLiveCards(this.recentLiveCards()) + this.liveEpg.enrich(this.recentLiveCards()) ); readonly favoriteMoviesAndSeriesCards = computed(() => @@ -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 {