diff --git a/.changes/dashboard-hero-no-flicker.md b/.changes/dashboard-hero-no-flicker.md new file mode 100644 index 000000000..11520174e --- /dev/null +++ b/.changes/dashboard-hero-no-flicker.md @@ -0,0 +1,8 @@ +--- +type: fix +area: dashboard +--- + +The dashboard hero no longer disappears and comes back while the dashboard +opens. On a profile with no watch history, the banner vanished for a moment +before a new arrival appeared in it, and the rails below jumped up and down. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 877f48b17..d212503a9 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -580,8 +580,9 @@ launch with sources). Rail skeletons are gated per rail The top block (the dashboard hero) keeps its immediate skeleton: it reserves the space above everything else, where a late insertion would push the whole -page down. Use the same rules for any page that stacks independently loading -blocks. +page down. For the same reason it stays until every source that can fill it +has loaded, not only the first one. Use the same rules for any page that +stacks independently loading blocks. ### Reload with content on screen: non-destructive indicator diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index c35bfbf09..0b386a1ba 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -179,8 +179,7 @@ stable in the other two). Every iteration shows the same two shifts of 0.118: about 12 ms after the first card the `dashboard-recent-sources-rail`, which holds the first card, moves up by 316 px, and 12-65 ms later it moves back down. Something 316 px tall above it is removed and inserted again during -startup, a flicker #1738 did not cover. The counter is working as intended; -the flicker is a separate fix. +startup, a flicker #1738 did not cover. On the Linux CI runner (`Performance journeys` job of #1756, run 36618062068) the same flicker is a race: the measured iterations read @@ -188,6 +187,18 @@ On the Linux CI runner (`Performance journeys` job of #1756, run after the first card), and the one hit shows the same two 316 px moves of the recent-sources rail. +The 316 px element was the dashboard hero. The J1 profile has no history +and no favorites; its only slide is an Xtream recently-added title, and that +query waits for the favorites. The hero dropped its skeleton as soon as the +history resolved empty and came back with that slide moments later. It now +keeps the skeleton until every source that can feature a title has loaded, +including a live candidate's first programme answer for at most 2 s +(`DashboardHeroSlidesPresenter.loading`), and +`DashboardDataService.xtreamRecentlyAddedLoading` no longer settles before +the playlist inventory has loaded. After the fix (macOS, 2026-09-30): both +counters were 0 in all 12 iterations of two runs, every window closed on +`quiet` and `lateShifts` was empty. + #### Idle window After the settle point J1 leaves the dashboard alone for @@ -949,10 +960,10 @@ in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from 1,401 to 1,674 ms. All four stay evidence for now. Runner counters also differ from a Mac (12 and 571 there, the fast path without the Linux-only `getWindowState` call), so take J1 baseline values from the runner only. -`renderer.layoutShiftScoreSettled` has no baseline either: the runner reads -it as `stable: false` because the dashboard flicker it reports is a race -there (see [Settle window](#settle-window)). Add the runner's number once -that flicker is fixed and the counter is deterministic. +`renderer.layoutShiftScoreSettled` has no baseline either: the runner read +it as `stable: false` because the dashboard hero flicker it reported was a +race there (see [Settle window](#settle-window)). That flicker is fixed; add +the runner's number once runner runs read it as `stable` too. ### Weekly tightening diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 76c2f6256..db6c22753 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -75,7 +75,13 @@ Render rules: full-bleed. All rails and the hero are skipped. 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 - the first history load runs. An item enters recent history only after + it has no slide and any of its sources (history, favorites, Xtream + 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-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 51dc5212c..ccec0eff5 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -167,6 +167,35 @@ describe('DashboardDataService', () => { clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), }; + const createTestingModuleProviders = () => ({ + providers: [ + DashboardDataService, + { provide: Store, useValue: storeMock }, + { provide: DatabaseService, useValue: dbServiceMock }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSourceMock, + }, + { + provide: PlaylistsService, + useValue: playlistsServiceMock, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + onLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: playbackPositionsMock, + }, + ], + }); + beforeEach(() => { Object.defineProperty(window, 'electron', { value: { @@ -225,34 +254,7 @@ describe('DashboardDataService', () => { playbackPositionsMock.getAllPlaybackPositions.mockClear(); playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]); - TestBed.configureTestingModule({ - providers: [ - DashboardDataService, - { provide: Store, useValue: storeMock }, - { provide: DatabaseService, useValue: dbServiceMock }, - { - provide: XTREAM_DATA_SOURCE, - useValue: xtreamDataSourceMock, - }, - { - provide: PlaylistsService, - useValue: playlistsServiceMock, - }, - { - provide: TranslateService, - useValue: { - instant: (key: string) => key, - onLangChange: of(null), - currentLang: 'en', - defaultLang: 'en', - }, - }, - { - provide: PORTAL_PLAYBACK_POSITIONS, - useValue: playbackPositionsMock, - }, - ], - }); + TestBed.configureTestingModule(createTestingModuleProviders()); service = TestBed.inject(DashboardDataService); }); @@ -303,6 +305,43 @@ describe('DashboardDataService', () => { expect(service.dashboardReady()).toBe(true); }); + it('keeps xtream recently added loading until the playlist inventory has loaded', async () => { + // Startup: the dashboard exists before the inventory, which is empty + // until it loads, so "no Xtream playlists" is not known yet. + TestBed.resetTestingModule(); + playlistsLoadedSignal.set(false); + playlistsSignal.set([]); + TestBed.configureTestingModule(createTestingModuleProviders()); + service = TestBed.inject(DashboardDataService); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(true); + expect(service.xtreamRecentlyAddedLoaded()).toBe(false); + + playlistsSignal.set(createDefaultPlaylists()); + playlistsLoadedSignal.set(true); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(true); + + await service.reloadXtreamRecentlyAddedItems(); + expect(service.xtreamRecentlyAddedLoading()).toBe(false); + expect(service.xtreamRecentlyAddedLoaded()).toBe(true); + }); + + it('settles xtream recently added once the loaded inventory has no xtream playlists', () => { + TestBed.resetTestingModule(); + playlistsLoadedSignal.set(false); + playlistsSignal.set([]); + TestBed.configureTestingModule(createTestingModuleProviders()); + service = TestBed.inject(DashboardDataService); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(true); + + playlistsLoadedSignal.set(true); + TestBed.tick(); + expect(service.xtreamRecentlyAddedLoading()).toBe(false); + expect(service.xtreamRecentlyAddedLoaded()).toBe(true); + }); + it('includes M3U favorites in global favorite items', async () => { await service.reloadGlobalFavorites(); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 4471d1e2b..24b616591 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -452,8 +452,10 @@ export class DashboardDataService { this.finishInitialGlobalFavoritesLoadIfReady(); }); + // Before the inventory has loaded "no Xtream playlists" is not known + // yet; settling here would end the first load before it started. effect(() => { - if (this.hasXtreamPlaylists()) { + if (!this.playlistsLoaded() || this.hasXtreamPlaylists()) { return; } 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 ebb72ab5d..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'; @@ -92,6 +95,10 @@ describe('DashboardHeroSlidesPresenter', () => { let tmdbEnabled: ReturnType>; let tmdbLanguage: ReturnType>; let getExtras: jest.Mock; + let recentLoading: ReturnType>; + let favoritesLoading: ReturnType>; + let addedLoading: ReturnType>; + let liveAwaiting: ReturnType>; const positions = new Map([ [ 1, @@ -123,7 +130,9 @@ describe('DashboardHeroSlidesPresenter', () => { { provide: DashboardDataService, useValue: { - globalRecentLoading: signal(false), + globalRecentLoading: () => recentLoading(), + globalFavoritesLoading: () => favoritesLoading(), + xtreamRecentlyAddedLoading: () => addedLoading(), globalRecentItems: recentItems, globalRecentVodItems: () => recentItems().filter((i) => i.type !== 'live'), @@ -162,6 +171,7 @@ describe('DashboardHeroSlidesPresenter', () => { useValue: { heroLiveCandidates: candidates, heroDetailsFor: liveDetails, + heroLiveAwaitingFirstAnswer: () => liveAwaiting(), }, }, { @@ -195,6 +205,14 @@ describe('DashboardHeroSlidesPresenter', () => { tmdbEnabled = signal(false); tmdbLanguage = signal('en-US'); getExtras = jest.fn().mockResolvedValue(null); + 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', () => { @@ -380,4 +398,111 @@ describe('DashboardHeroSlidesPresenter', () => { backdropSource: 'fallback', }); }); + + it('keeps the skeleton until every source that can feature a title has loaded', () => { + // J1 profile: no history, no favourites; the Xtream recently-added + // query runs after the favourites and features the only slide. + recentItems.set([]); + favorites.set([]); + candidates.set([]); + addedItems.set([]); + recentLoading.set(true); + favoritesLoading.set(true); + addedLoading.set(true); + const presenter = create(); + expect(presenter.loading()).toBe(true); + + recentLoading.set(false); + expect(presenter.loading()).toBe(true); + + favoritesLoading.set(false); + expect(presenter.loading()).toBe(true); + + addedItems.set([import1]); + addedLoading.set(false); + expect(presenter.slides().map((slide) => slide.kind)).toEqual([ + 'added', + ]); + expect(presenter.loading()).toBe(false); + }); + + it('drops the skeleton once every source has loaded with nothing to feature', () => { + recentItems.set([]); + favorites.set([]); + candidates.set([]); + addedItems.set([]); + addedLoading.set(true); + const presenter = create(); + expect(presenter.loading()).toBe(true); + + addedLoading.set(false); + expect(presenter.loading()).toBe(false); + expect(presenter.slides()).toEqual([]); + }); + + it('shows a slide without waiting for the slower sources', () => { + favoritesLoading.set(true); + addedLoading.set(true); + + 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 9321cac0a..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,10 +89,28 @@ export class DashboardHeroSlidesPresenter { >(new Map()); private readonly requestedTmdbKeys = new Set(); - /** First history load still running and nothing to feature yet. */ - readonly loading = computed( - () => this.data.globalRecentLoading() && this.slides().length === 0 - ); + 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, 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 = linkedSignal({ + source: () => + this.slides().length === 0 && + (this.data.globalRecentLoading() || + this.data.globalFavoritesLoading() || + 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(() => { @@ -127,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 + ); + } }