From cea85f50f033ec1b5bbef3d6248e6ea448af6ad8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 1 Oct 2026 21:48:01 +0200 Subject: [PATCH] fix(dashboard): hold the hero skeleton for a live candidate's first programme A live hero slide exists only once its channel has a programme on air. With a live favorite as the only candidate, the skeleton went away when history, favorites and recently added had loaded, and the live slide was inserted when the portal or XMLTV answer arrived, shifting the rails. DashboardLiveEpgPresenter now reports while a hero candidate awaits its first portal or XMLTV answer, and the hero keeps its skeleton for that, at most 2 s from its creation. Once the skeleton has gone it does not come back. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/workspace-dashboard.md | 9 ++- .../dashboard-portal-live-epg.service.spec.ts | 2 + .../lib/dashboard-portal-live-epg.service.ts | 7 ++ .../dashboard-hero-slides.presenter.spec.ts | 71 ++++++++++++++++++- .../rails/dashboard-hero-slides.presenter.ts | 37 ++++++++-- .../dashboard-live-epg.presenter.spec.ts | 53 +++++++++++++- .../lib/rails/dashboard-live-epg.presenter.ts | 27 +++++++ ...ashboard-portal-live-epg.presenter.spec.ts | 23 +++++- .../dashboard-portal-live-epg.presenter.ts | 13 ++++ 9 files changed, 229 insertions(+), 13 deletions(-) diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index d81025839..db6c22753 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -76,9 +76,12 @@ Render rules: 3. The hero (`lib-dashboard-hero`) renders when it has at least one slide; see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while it has no slide and any of its sources (history, favorites, Xtream - recently added) is still on its first load; dropping it earlier removed - the hero and inserted it again when a later source featured a title, - moving every rail below twice. An item enters recent history only after + recently added) is still on its first load, or a live candidate still + waits for its first programme answer (portal or XMLTV, for at most + `DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS`, 2 s, from the hero's creation). + Dropping it earlier removed the hero and inserted it again when a later + source featured a title, moving every rail below twice. Once the + skeleton has gone it does not come back. An item enters recent history only after its stream has really played (see "Recently Viewed Confirmation" in `embedded-inline-playback.md`), so a channel that failed at once never becomes a hero slide. diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts index 9e9df0559..3c4187932 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.spec.ts @@ -313,6 +313,8 @@ describe('DashboardPortalLiveEpgService', () => { // PWA: the collection resolver is gated on the desktop XMLTV bridge // and answers nothing, so no request is worth queuing. supportsEpgProgramLookup = false; + // Callers must not wait for an answer that never comes. + expect(service.answersPortals).toBe(false); service.sync([entry(1), entry(2)]); await step(3); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts index b4a9e158d..e88a47743 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.service.ts @@ -77,6 +77,13 @@ export class DashboardPortalLiveEpgService implements OnDestroy { readonly programs = this.programsState.asReadonly(); /** Keys queued or in flight — the cards that may show a placeholder. */ readonly pending = this.pendingState.asReadonly(); + /** + * False where portals are never asked (no local XMLTV bridge), so no + * card there ever gets an answer to wait for. + */ + get answersPortals(): boolean { + return this.runtime.supportsEpgProgramLookup; + } /** * Replace the wanted set. Keys without a fresh answer are queued; keys no diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts index 6ee2a2ebf..3d99bd59b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -14,7 +14,10 @@ import { DashboardHeroTmdbService, type DashboardHeroTmdbExtras, } from './dashboard-hero-tmdb.service'; -import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter'; +import { + DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS, + DashboardHeroSlidesPresenter, +} from './dashboard-hero-slides.presenter'; import type { DashboardHeroLiveCandidate } from './dashboard-hero-slides.utils'; import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter'; import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils'; @@ -95,6 +98,7 @@ describe('DashboardHeroSlidesPresenter', () => { let recentLoading: ReturnType>; let favoritesLoading: ReturnType>; let addedLoading: ReturnType>; + let liveAwaiting: ReturnType>; const positions = new Map([ [ 1, @@ -167,6 +171,7 @@ describe('DashboardHeroSlidesPresenter', () => { useValue: { heroLiveCandidates: candidates, heroDetailsFor: liveDetails, + heroLiveAwaitingFirstAnswer: () => liveAwaiting(), }, }, { @@ -203,6 +208,11 @@ describe('DashboardHeroSlidesPresenter', () => { recentLoading = signal(false); favoritesLoading = signal(false); addedLoading = signal(false); + liveAwaiting = signal(false); + }); + + afterEach(() => { + jest.useRealTimers(); }); it('builds the rotation from resume, live, favourite and import slides', () => { @@ -436,4 +446,63 @@ describe('DashboardHeroSlidesPresenter', () => { expect(create().loading()).toBe(false); }); + + describe('with a live channel as the only candidate', () => { + let onAirNow: ReturnType>; + + beforeEach(() => { + recentItems.set([]); + favorites.set([]); + addedItems.set([]); + onAirNow = signal(false); + liveDetails = jest.fn(() => (onAirNow() ? onAir : null)); + liveAwaiting.set(true); + }); + + it('keeps the skeleton until the channel has its first programme', () => { + const presenter = create(); + expect(presenter.slides()).toEqual([]); + expect(presenter.loading()).toBe(true); + + onAirNow.set(true); + liveAwaiting.set(false); + expect(presenter.slides().map((slide) => slide.kind)).toEqual([ + 'live', + ]); + expect(presenter.loading()).toBe(false); + }); + + it('drops the skeleton once the channel answered with nothing on air', () => { + const presenter = create(); + expect(presenter.loading()).toBe(true); + + liveAwaiting.set(false); + expect(presenter.loading()).toBe(false); + }); + + it('stops waiting for a programme that does not come', () => { + jest.useFakeTimers(); + const presenter = create(); + expect(presenter.loading()).toBe(true); + + jest.advanceTimersByTime(DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS - 1); + expect(presenter.loading()).toBe(true); + jest.advanceTimersByTime(1); + expect(presenter.loading()).toBe(false); + }); + }); + + it('does not bring the skeleton back once it has gone', () => { + recentItems.set([]); + favorites.set([]); + candidates.set([]); + addedItems.set([]); + const presenter = create(); + expect(presenter.loading()).toBe(false); + + // A later live lookup (a rail card changed the XMLTV batch) must + // not insert the skeleton above content that is already placed. + liveAwaiting.set(true); + expect(presenter.loading()).toBe(false); + }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts index 3404a568c..6c974a67e 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -1,8 +1,10 @@ import { computed, + DestroyRef, effect, inject, Injectable, + linkedSignal, signal, untracked, } from '@angular/core'; @@ -46,6 +48,13 @@ const TYPE_LABEL_KEYS = { series: 'WORKSPACE.DASHBOARD.TYPE_SERIES', } as const; +/** + * How long, from the hero's creation, its skeleton may wait for the first + * programme of a live candidate. Portal answers usually take a few hundred + * milliseconds; an unreachable portal must not hold the skeleton forever. + */ +export const DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS = 2000; + const PROVIDER_LABEL_KEYS = { xtream: 'WORKSPACE.DASHBOARD.XTREAM', stalker: 'WORKSPACE.DASHBOARD.STALKER', @@ -80,20 +89,28 @@ export class DashboardHeroSlidesPresenter { >(new Map()); private readonly requestedTmdbKeys = new Set(); + private readonly liveAnswerWaitOver = signal(false); + /** * Nothing to feature yet and a source that can feature a title is still * on its first load. Every such source counts, not only the history: - * the Xtream recently-added query waits for the favorites, so dropping - * the skeleton when the history resolved empty removed the hero and - * inserted it again moments later, moving every rail below twice. + * the Xtream recently-added query waits for the favorites, and a live + * slide waits for its programme, so dropping the skeleton when the + * history resolved empty removed the hero and inserted it again moments + * later, moving every rail below twice. Once the skeleton has gone it + * does not come back: a later reload must not shift the page either. */ - readonly loading = computed( - () => + readonly loading = linkedSignal({ + source: () => this.slides().length === 0 && (this.data.globalRecentLoading() || this.data.globalFavoritesLoading() || - this.data.xtreamRecentlyAddedLoading()) - ); + this.data.xtreamRecentlyAddedLoading() || + (!this.liveAnswerWaitOver() && + this.liveEpg.heroLiveAwaitingFirstAnswer())), + computation: (loading, previous) => + previous?.value === false ? false : loading, + }).asReadonly(); /** The first candidate channel with a programme on air right now. */ private readonly liveSlide = computed(() => { @@ -137,6 +154,12 @@ export class DashboardHeroSlidesPresenter { }); constructor() { + const liveAnswerWait = setTimeout( + () => this.liveAnswerWaitOver.set(true), + DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS + ); + inject(DestroyRef).onDestroy(() => clearTimeout(liveAnswerWait)); + effect(() => { if (!this.heroTmdb.isEnabled()) { return; 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 2d9ccceba..9dd5f2047 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 @@ -1,6 +1,6 @@ import { computed, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { BehaviorSubject, EMPTY, of, throwError } from 'rxjs'; +import { BehaviorSubject, EMPTY, of, Subject, throwError } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { DEFAULT_DASHBOARD_RAILS_SETTINGS, @@ -57,6 +57,7 @@ describe('DashboardLiveEpgPresenter', () => { setVisibleCards: jest.Mock; programFor: jest.Mock; isPending: jest.Mock; + awaitsFirstAnswer: jest.Mock; }; const setup = (cards: DashboardRailCard[]) => { @@ -88,6 +89,7 @@ describe('DashboardLiveEpgPresenter', () => { setVisibleCards: jest.fn(), programFor: jest.fn(() => undefined), isPending: jest.fn(() => false), + awaitsFirstAnswer: jest.fn(() => false), }; TestBed.configureTestingModule({ @@ -259,6 +261,55 @@ describe('DashboardLiveEpgPresenter', () => { ); }); + it('reports a hero live candidate still waiting for its first programme', () => { + const xmltvAnswer = new Subject>(); + getCurrentProgramsForChannels.mockImplementation(() => xmltvAnswer); + expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(false); + + const m3uChannel = { + id: 'ard-hd', + title: 'Das Erste HD', + type: 'live', + source: 'm3u', + playlist_id: 'a', + category_id: '', + xtream_id: 'ard-hd', + epg_lookup_key: 'ard.de', + } as PortalActivityItem; + favoriteLiveItems.set([m3uChannel]); + setup([]); + // The XMLTV batch for the candidate has not answered yet. + expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(true); + + xmltvAnswer.next(new Map([['ard.de', null]])); + xmltvAnswer.complete(); + expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(false); + + // A portal candidate also waits for its portal's first answer. + portal.awaitsFirstAnswer.mockReturnValue(true); + favoriteLiveItems.set([ + m3uChannel, + { + ...m3uChannel, + id: 7, + source: 'xtream', + playlist_id: 'portal', + xtream_id: 7, + epg_lookup_key: undefined, + } as PortalActivityItem, + ]); + getCurrentProgramsForChannels.mockImplementation(() => + of(new Map()) + ); + TestBed.tick(); + expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(true); + + portal.awaitsFirstAnswer.mockReturnValue(false); + favoriteLiveItems.update((items) => [...items]); + TestBed.tick(); + expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(false); + }); + it('prefers the portal answer and forwards what the portal presenter owns', () => { const xmltvCard = card({ id: 'x', 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 93d553c73..777b09f9a 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 @@ -149,6 +149,11 @@ export class DashboardLiveEpgPresenter { ) ); + /** The lookup groups the XMLTV batch below last answered. */ + private readonly answeredLookupGroups = signal< + readonly DashboardLiveEpgLookupGroup[] | null + >(null); + private readonly offsetMinutes = computed(() => this.settingsStore.resolvedEpgOffsetMinutes() ); @@ -174,6 +179,7 @@ export class DashboardLiveEpgPresenter { ]).pipe( switchMap(([groups, offsetMinutes]) => { if (groups.length === 0) { + this.answeredLookupGroups.set(groups); return of(new Map()); } let answers: ReadonlyMap | null = @@ -200,6 +206,7 @@ export class DashboardLiveEpgPresenter { tap((merged) => { answers = merged; answeredAt = Date.now(); + this.answeredLookupGroups.set(groups); }), distinctUntilChanged(sameLiveEpgAnswers) ); @@ -272,6 +279,26 @@ export class DashboardLiveEpgPresenter { }); } + /** + * True while a hero live candidate may still get its first programme: + * its portal has not answered yet, or the XMLTV batch has not answered + * the current lookups. A live slide exists only once a programme is on + * air, so the hero keeps its skeleton meanwhile instead of inserting + * the slide late. + */ + readonly heroLiveAwaitingFirstAnswer = computed(() => { + const cards = this.heroLiveCards(); + if (cards.length === 0) { + return false; + } + return ( + this.answeredLookupGroups() !== this.lookupGroups() || + cards.some((card) => + this.portal.awaitsFirstAnswer(card.liveEpgSourceKey) + ) + ); + }); + /** Current programme of a hero live candidate, or `null`. */ heroDetailsFor(item: PortalActivityItem): DashboardLiveEpgDetails | null { return this.detailsFor(buildDashboardLiveEpgCard(item)); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts index 8fd1f84b0..d62ca6179 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts @@ -31,6 +31,7 @@ describe('DashboardPortalLiveEpgPresenter', () => { >; let pending: ReturnType>>; let offsetMinutes: ReturnType>; + let service: { answersPortals: boolean }; /** Keys of every sync call, sorted: the wanted set has no order. */ const wantedKeys = (): string[][] => @@ -43,13 +44,18 @@ describe('DashboardPortalLiveEpgPresenter', () => { programs = signal>(new Map()); pending = signal>(new Set()); offsetMinutes = signal(0); + service = { answersPortals: true }; TestBed.configureTestingModule({ providers: [ DashboardLiveEpgClock, DashboardPortalLiveEpgPresenter, { provide: DashboardPortalLiveEpgService, - useValue: { sync, programs, pending }, + useValue: Object.assign(service, { + sync, + programs, + pending, + }), }, { provide: SettingsStore, @@ -182,4 +188,19 @@ describe('DashboardPortalLiveEpgPresenter', () => { expect(presenter.programFor('xtream::p::1')).toBe(program); expect(presenter.programFor('xtream::p::2')).toBeNull(); }); + + it('awaits a first answer until the card has one, also before it is queued', () => { + expect(presenter.awaitsFirstAnswer(null)).toBe(false); + // Not queued yet (the pin reaches the queue through an effect). + expect(presenter.awaitsFirstAnswer('xtream::p::1')).toBe(true); + + programs.set(new Map([['xtream::p::1', null]])); + expect(presenter.awaitsFirstAnswer('xtream::p::1')).toBe(false); + }); + + it('never awaits an answer where portals are not asked', () => { + service.answersPortals = false; + + expect(presenter.awaitsFirstAnswer('xtream::p::1')).toBe(false); + }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts index d9117a14d..b83aacbd4 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts @@ -126,4 +126,17 @@ export class DashboardPortalLiveEpgPresenter { isPending(key: string | null | undefined): boolean { return !!key && this.service.pending().has(key); } + + /** + * True until the first answer for a portal card arrives, including the + * moment before its key reaches the queue; never where portals are not + * asked at all. + */ + awaitsFirstAnswer(key: string | null | undefined): boolean { + return ( + !!key && + this.service.answersPortals && + this.service.programs().get(key) === undefined + ); + } }