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 20ebe310c..d5a883a13 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -568,8 +568,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 66fc6c15d..74a7a659e 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -176,8 +176,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 @@ -185,6 +184,17 @@ 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 +(`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. + #### Main-process counters With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets, @@ -658,10 +668,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..d81025839 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -75,7 +75,10 @@ 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; 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 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/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..6ee2a2ebf 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 @@ -92,6 +92,9 @@ describe('DashboardHeroSlidesPresenter', () => { let tmdbEnabled: ReturnType>; let tmdbLanguage: ReturnType>; let getExtras: jest.Mock; + let recentLoading: ReturnType>; + let favoritesLoading: ReturnType>; + let addedLoading: ReturnType>; const positions = new Map([ [ 1, @@ -123,7 +126,9 @@ describe('DashboardHeroSlidesPresenter', () => { { provide: DashboardDataService, useValue: { - globalRecentLoading: signal(false), + globalRecentLoading: () => recentLoading(), + globalFavoritesLoading: () => favoritesLoading(), + xtreamRecentlyAddedLoading: () => addedLoading(), globalRecentItems: recentItems, globalRecentVodItems: () => recentItems().filter((i) => i.type !== 'live'), @@ -195,6 +200,9 @@ describe('DashboardHeroSlidesPresenter', () => { tmdbEnabled = signal(false); tmdbLanguage = signal('en-US'); getExtras = jest.fn().mockResolvedValue(null); + recentLoading = signal(false); + favoritesLoading = signal(false); + addedLoading = signal(false); }); it('builds the rotation from resume, live, favourite and import slides', () => { @@ -380,4 +388,52 @@ 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); + }); }); 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..3404a568c 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 @@ -80,9 +80,19 @@ export class DashboardHeroSlidesPresenter { >(new Map()); private readonly requestedTmdbKeys = new Set(); - /** First history load still running and nothing to feature yet. */ + /** + * 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. + */ readonly loading = computed( - () => this.data.globalRecentLoading() && this.slides().length === 0 + () => + this.slides().length === 0 && + (this.data.globalRecentLoading() || + this.data.globalFavoritesLoading() || + this.data.xtreamRecentlyAddedLoading()) ); /** The first candidate channel with a programme on air right now. */