fix(dashboard): keep the hero skeleton until every hero source has loaded (#1782)

This commit is contained in:
4gray authored and GitHub committed 2026-10-01 23:41:21 +02:00
1 parent 1653ffe9fb
commit b6357da6af
14 files changed
+392 -46

No files matched your search

+8
View File
@@ -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.
+3 -2
View File
@@ -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
+17 -6
View File
@@ -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
+7 -1
View File
@@ -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.
@@ -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();
@@ -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;
}
@@ -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);
@@ -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
@@ -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<typeof signal<boolean>>;
let tmdbLanguage: ReturnType<typeof signal<string>>;
let getExtras: jest.Mock;
let recentLoading: ReturnType<typeof signal<boolean>>;
let favoritesLoading: ReturnType<typeof signal<boolean>>;
let addedLoading: ReturnType<typeof signal<boolean>>;
let liveAwaiting: ReturnType<typeof signal<boolean>>;
const positions = new Map<string | number, PlaybackPositionData>([
[
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<typeof signal<boolean>>;
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);
});
});
@@ -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<string>();
/** 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<boolean, boolean>({
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;
@@ -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<Map<string, EpgProgram | null>>();
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<string, EpgProgram | null>())
);
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',
@@ -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<string, EpgProgram | null>());
}
let answers: ReadonlyMap<string, EpgProgram | null> | 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));
@@ -31,6 +31,7 @@ describe('DashboardPortalLiveEpgPresenter', () => {
>;
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
let offsetMinutes: ReturnType<typeof signal<number>>;
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<ReadonlyMap<string, EpgProgram | null>>(new Map());
pending = signal<ReadonlySet<string>>(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);
});
});
@@ -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
);
}
}