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 + ); + } }