diff --git a/.changes/dashboard-no-rail-jump.md b/.changes/dashboard-no-rail-jump.md new file mode 100644 index 000000000..95223a4df --- /dev/null +++ b/.changes/dashboard-no-rail-jump.md @@ -0,0 +1,9 @@ +--- +type: perf +area: dashboard +--- + +The dashboard no longer jumps when it opens: placeholder rails that turned out +to be empty used to flash and collapse, pushing the rails below them up the +page. Rails now appear once, in place, and a placeholder shows only when a rail +takes noticeably long to load. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 7ab521618..9397e23f7 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -543,6 +543,30 @@ mirrors the row/card geometry it precedes (see Channel List Item and Cover Grids). The unified Favorites/Recent page gates this on `isLoading`, set only while its item list is empty. +### Pages of independently loading blocks: delayed skeletons + +The dashboard renders each rail as soon as its own data arrives, and several +rails resolve empty on a normal profile. A per-rail skeleton shown +immediately therefore flashed for a few tens of milliseconds and collapsed, +pulling every rail below it upwards (a layout shift of about 0.23 on each +launch with sources). Rail skeletons are gated per rail +(`createRailSkeletonGates` in +`libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts`): + +- a skeleton waits out a grace period (`DASHBOARD_RAIL_SKELETON_GRACE_MS`, + 300 ms) counted from when *that* rail started loading, since Xtream and + TMDB rails start after the local ones; +- it never appears above a rail that already shows cards: the placeholder + would push visible content down, and back up if the rail resolves empty, + while the real rail inserts at most once; +- once shown, it stays until its own rail finishes, so skeletons do not + vanish in a cascade when the first real rail arrives. + +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. + ### Reload with content on screen: non-destructive indicator A reload of a list that is already rendered (the collection page's diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.spec.ts new file mode 100644 index 000000000..2e2b3814a --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.spec.ts @@ -0,0 +1,128 @@ +import { + EnvironmentInjector, + createEnvironmentInjector, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + DEFAULT_DASHBOARD_RAILS_SETTINGS, + type DashboardRailsSettings, +} from '@iptvnator/shared/interfaces'; +import { + createDashboardRailSkeletons, + type DashboardRailSkeletonHost, +} from './dashboard-rail-skeletons'; +import { DASHBOARD_RAIL_SKELETON_GRACE_MS as GRACE } from './dashboard-skeleton-grace'; + +function fakeHost() { + const s = { + rails: signal({ + ...DEFAULT_DASHBOARD_RAILS_SETTINGS, + continueWatching: true, + liveFavorites: true, + recentlyWatchedLive: true, + favoriteMoviesAndSeries: true, + recentSources: true, + xtreamRecentlyAdded: true, + tmdbRecommendations: true, + tmdbTrending: true, + }), + recentLive: signal([]), + sources: signal([]), + liveFavoritesLoading: signal(false), + playlistsLoaded: signal(true), + xtreamPlaylists: signal(0), + xtreamLoading: signal(false), + trendingLoading: signal(false), + }; + const empty = () => []; + const host: DashboardRailSkeletonHost = { + dashboardRails: () => s.rails(), + continueWatchingCards: empty, + liveFavoriteCards: empty, + recentLiveCards: () => s.recentLive(), + favoriteMoviesAndSeriesCards: empty, + sourceCards: () => s.sources(), + xtreamRecentlyAddedCards: empty, + recommendationCards: empty, + trendingCards: empty, + showLiveFavoritesSkeleton: () => s.liveFavoritesLoading(), + showRecentContentSkeleton: () => false, + xtreamPlaylistCount: () => s.xtreamPlaylists(), + data: { + playlistsLoaded: () => s.playlistsLoaded(), + xtreamRecentlyAddedLoading: () => s.xtreamLoading(), + }, + recommendationsService: { loading: () => false }, + trendingService: { loading: () => s.trendingLoading() }, + }; + const injector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const gates = injector.runInContext(() => + createDashboardRailSkeletons(host) + ); + const settle = (ms = 0) => { + TestBed.tick(); + jest.advanceTimersByTime(ms); + TestBed.tick(); + }; + return { s, gates, settle }; +} + +describe('createDashboardRailSkeletons', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('suppresses the live favorites skeleton once recently watched live, below it, has cards', () => { + const { s, gates, settle } = fakeHost(); + s.recentLive.set([{}]); + s.liveFavoritesLoading.set(true); + settle(GRACE); + + expect(gates.liveFavorites()).toBe(false); + }); + + it('shows the live favorites skeleton on a slow load when nothing below has cards', () => { + const { s, gates, settle } = fakeHost(); + s.liveFavoritesLoading.set(true); + settle(GRACE); + + expect(gates.liveFavorites()).toBe(true); + }); + + it('treats sources as loading until playlists have loaded', () => { + const { s, gates, settle } = fakeHost(); + s.playlistsLoaded.set(false); + settle(GRACE); + expect(gates.sources()).toBe(true); + + s.playlistsLoaded.set(true); + settle(); + expect(gates.sources()).toBe(false); + }); + + it('never shows the Xtream skeleton without an Xtream playlist', () => { + const { s, gates, settle } = fakeHost(); + s.xtreamLoading.set(true); + settle(GRACE); + expect(gates.xtreamRecentlyAdded()).toBe(false); + + s.xtreamLoading.set(false); + settle(); + s.xtreamPlaylists.set(1); + s.xtreamLoading.set(true); + settle(GRACE); + expect(gates.xtreamRecentlyAdded()).toBe(true); + }); + + it('ignores loading rails whose rail setting is disabled', () => { + const { s, gates, settle } = fakeHost(); + s.rails.update((rails) => ({ ...rails, tmdbTrending: false })); + s.trendingLoading.set(true); + settle(GRACE); + + expect(gates.tmdbTrending()).toBe(false); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.ts new file mode 100644 index 000000000..f7d1513a0 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.ts @@ -0,0 +1,141 @@ +import type { Signal } from '@angular/core'; +import type { DashboardRailsSettings } from '@iptvnator/shared/interfaces'; +import { createRailSkeletonGates } from './dashboard-skeleton-grace'; + +type Count = () => { readonly length: number }; + +/** + * What the dashboard exposes for skeleton decisions; the rails component + * satisfies it structurally, so the rail order and conditions live here + * rather than in that (already oversized) component. + */ +export interface DashboardRailSkeletonHost { + readonly dashboardRails: () => DashboardRailsSettings; + readonly continueWatchingCards: Count; + readonly liveFavoriteCards: Count; + readonly recentLiveCards: Count; + readonly favoriteMoviesAndSeriesCards: Count; + readonly sourceCards: Count; + readonly xtreamRecentlyAddedCards: Count; + readonly recommendationCards: Count; + readonly trendingCards: Count; + readonly showLiveFavoritesSkeleton: () => boolean; + readonly showRecentContentSkeleton: () => boolean; + readonly xtreamPlaylistCount: () => number; + readonly data: { + readonly playlistsLoaded: () => boolean; + readonly xtreamRecentlyAddedLoading: () => boolean; + }; + readonly recommendationsService: { readonly loading: () => boolean }; + readonly trendingService: { readonly loading: () => boolean }; +} + +export type DashboardRailSkeletonKey = + | 'continueWatching' + | 'liveFavorites' + | 'recentLive' + | 'favoriteVod' + | 'recentContent' + | 'sources' + | 'xtreamRecentlyAdded' + | 'tmdbRecommendations' + | 'tmdbTrending'; + +/** + * Skeleton gates for the dashboard rails, listed in template order (the + * no-skeleton-above-visible-cards rule depends on it). Rails without their + * own skeleton are listed with `loading: false` so they still count as + * "rendered below" for the rails above them. Must be created in an + * injection context. + */ +export function createDashboardRailSkeletons( + host: DashboardRailSkeletonHost +): Record> { + const rails = () => host.dashboardRails(); + const none = () => false; + + return createRailSkeletonGates([ + [ + 'continueWatching', + { + loading: none, + rendered: () => + rails().continueWatching && + host.continueWatchingCards().length > 0, + }, + ], + [ + 'liveFavorites', + { + loading: () => host.showLiveFavoritesSkeleton(), + rendered: () => + rails().liveFavorites && + !host.showLiveFavoritesSkeleton() && + host.liveFavoriteCards().length > 0, + }, + ], + [ + 'recentLive', + { + loading: none, + rendered: () => + rails().recentlyWatchedLive && + host.recentLiveCards().length > 0, + }, + ], + [ + 'favoriteVod', + { + loading: none, + rendered: () => + rails().favoriteMoviesAndSeries && + host.favoriteMoviesAndSeriesCards().length > 0, + }, + ], + [ + 'recentContent', + { loading: () => host.showRecentContentSkeleton(), rendered: none }, + ], + [ + 'sources', + { + loading: () => + rails().recentSources && !host.data.playlistsLoaded(), + rendered: () => + rails().recentSources && host.sourceCards().length > 0, + }, + ], + [ + 'xtreamRecentlyAdded', + { + loading: () => + rails().xtreamRecentlyAdded && + host.xtreamPlaylistCount() > 0 && + host.data.xtreamRecentlyAddedLoading(), + rendered: () => + rails().xtreamRecentlyAdded && + host.xtreamRecentlyAddedCards().length > 0, + }, + ], + [ + 'tmdbRecommendations', + { + loading: () => + rails().tmdbRecommendations && + host.recommendationsService.loading(), + rendered: () => + rails().tmdbRecommendations && + host.recommendationCards().length > 0, + }, + ], + [ + 'tmdbTrending', + { + loading: () => + rails().tmdbTrending && host.trendingService.loading(), + rendered: () => + rails().tmdbTrending && host.trendingCards().length > 0, + }, + ], + ]); +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts new file mode 100644 index 000000000..69c9bcc86 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts @@ -0,0 +1,156 @@ +import { + EnvironmentInjector, + createEnvironmentInjector, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + DASHBOARD_RAIL_SKELETON_GRACE_MS as GRACE, + createRailSkeletonGates, +} from './dashboard-skeleton-grace'; + +function rail(loading = false, rendered = false) { + const state = { loading: signal(loading), rendered: signal(rendered) }; + return { + state, + entry: { + loading: () => state.loading(), + rendered: () => state.rendered(), + }, + }; +} + +describe('createRailSkeletonGates', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + /** Top rail `upper`, then `lower` below it, like two dashboard rails. */ + function setup(graceMs?: number) { + const upper = rail(); + const lower = rail(); + const injector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const gates = injector.runInContext(() => + createRailSkeletonGates( + [ + ['upper', upper.entry], + ['lower', lower.entry], + ] as const, + graceMs + ) + ); + const settle = (ms = 0) => { + TestBed.tick(); + jest.advanceTimersByTime(ms); + TestBed.tick(); + }; + return { upper, lower, gates, injector, settle }; + } + + it('never shows a skeleton for a rail that resolves within the grace period', () => { + const { upper, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE - 1); + upper.state.loading.set(false); + settle(GRACE); + + expect(gates.upper()).toBe(false); + }); + + it('shows the skeleton for a rail still loading after the grace period, while nothing below has cards', () => { + const { upper, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE); + + expect(gates.upper()).toBe(true); + + upper.state.loading.set(false); + settle(); + expect(gates.upper()).toBe(false); + }); + + it('measures the grace period from each rail’s own loading start', () => { + const { lower, gates, settle } = setup(); + // The page has been up for a while before this rail starts loading. + settle(GRACE * 3); + lower.state.loading.set(true); + settle(GRACE - 1); + + expect(gates.lower()).toBe(false); + + lower.state.loading.set(false); + settle(GRACE); + expect(gates.lower()).toBe(false); + }); + + it('does not insert a skeleton above a rail that already shows cards', () => { + const { upper, lower, gates, settle } = setup(); + lower.state.rendered.set(true); + upper.state.loading.set(true); + settle(GRACE * 2); + + expect(gates.upper()).toBe(false); + }); + + it('keeps a shown skeleton until its own rail finishes, even when a rail below renders', () => { + const { upper, lower, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE); + expect(gates.upper()).toBe(true); + + lower.state.rendered.set(true); + settle(); + expect(gates.upper()).toBe(true); + + upper.state.loading.set(false); + settle(); + expect(gates.upper()).toBe(false); + }); + + it('starts a fresh grace period when a rail loads again', () => { + const { upper, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE); + upper.state.loading.set(false); + settle(); + upper.state.loading.set(true); + settle(GRACE - 1); + + expect(gates.upper()).toBe(false); + settle(1); + expect(gates.upper()).toBe(true); + }); + + it('clears pending timers when the dashboard is destroyed', () => { + const { upper, gates, injector, settle } = setup(); + upper.state.loading.set(true); + settle(); + expect(jest.getTimerCount()).toBe(1); + + injector.destroy(); + + expect(jest.getTimerCount()).toBe(0); + jest.advanceTimersByTime(GRACE * 2); + expect(gates.upper()).toBe(false); + }); + + it('shows immediately with a zero grace period, still respecting rails below', () => { + const { upper, lower, gates, settle } = setup(0); + upper.state.loading.set(true); + settle(); + expect(gates.upper()).toBe(true); + + const second = setup(0); + second.lower.state.rendered.set(true); + second.upper.state.loading.set(true); + second.settle(); + expect(second.gates.upper()).toBe(false); + expect(lower.state.rendered()).toBe(false); + }); + + it('needs an injection context', () => { + expect(() => createRailSkeletonGates([])).toThrow(); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts new file mode 100644 index 000000000..264cf652d --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts @@ -0,0 +1,100 @@ +import { + DestroyRef, + effect, + inject, + signal, + untracked, + type Signal, +} from '@angular/core'; + +/** + * How long a dashboard rail may keep loading, counted from the moment that + * rail started loading, before its skeleton may appear. + * + * The rails render as soon as their own data arrives, and on a normal profile + * several resolve empty within a few tens of milliseconds. An immediate + * skeleton per loading rail therefore flashed and collapsed, pulling every + * rail below it upwards: a layout shift of about 0.23 on each launch with + * sources (the "good" CLS threshold is 0.1). The hero keeps its immediate + * skeleton: it reserves the top of the page. + */ +export const DASHBOARD_RAIL_SKELETON_GRACE_MS = 300; + +/** One rail, in template order. */ +export interface DashboardRailSkeletonEntry { + /** True while this rail's data is loading and a skeleton would help. */ + readonly loading: () => boolean; + /** True once this rail renders real cards. */ + readonly rendered: () => boolean; +} + +/** + * Per-rail skeleton visibility. A rail's skeleton appears only when + * + * 1. the rail has been loading for the grace period, measured from when + * *this* rail started loading (rails such as Xtream or TMDB begin their + * requests after the local ones), and + * 2. no rail below it already shows real cards: inserting a placeholder + * above visible content would push it down, and pull it back up if the + * rail resolves empty, whereas the real rail inserts at most once. + * + * Once shown, a skeleton stays until its rail stops loading, so skeletons do + * not disappear in a cascade when the first real rail arrives. + * + * Must be created in an injection context; timers are cleared on destroy. + */ +export function createRailSkeletonGates( + entries: readonly (readonly [Key, DashboardRailSkeletonEntry])[], + graceMs = DASHBOARD_RAIL_SKELETON_GRACE_MS +): Record> { + const gates = {} as Record>; + const timers = new Set>(); + inject(DestroyRef).onDestroy(() => { + timers.forEach(clearTimeout); + timers.clear(); + }); + + entries.forEach(([key, entry], index) => { + const below = entries.slice(index + 1).map(([, other]) => other); + const renderedBelow = () => below.some((other) => other.rendered()); + const shown = signal(false); + let timer: ReturnType | null = null; + const stopTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timers.delete(timer); + timer = null; + } + }; + + effect(() => { + const loading = entry.loading(); + untracked(() => { + if (!loading) { + stopTimer(); + shown.set(false); + return; + } + if (shown() || timer !== null) { + return; + } + if (graceMs <= 0) { + shown.set(!renderedBelow()); + return; + } + timer = setTimeout(() => { + if (timer !== null) timers.delete(timer); + timer = null; + if (entry.loading() && !renderedBelow()) { + shown.set(true); + } + }, graceMs); + timers.add(timer); + }); + }); + + gates[key] = shown.asReadonly(); + }); + + return gates; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 3e4c2f682..a842cb511 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -52,7 +52,7 @@ liveEpg.setVisibleCards('favorites', $event) " /> - } @else if (showLiveFavoritesSkeleton()) { + } @else if (railSkeletons.liveFavorites()) {