mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
perf(dashboard): hold rail skeletons back so empty rails stop shifting the page (#1738)
* perf(dashboard): hold rail skeletons back so empty rails stop shifting the page On every launch with sources the dashboard shifted by about 0.23 (the "good" CLS threshold is 0.1). The rails render as soon as their own data arrives, and each loading rail showed a 328 px skeleton immediately. On a normal profile the live-favorites and recent-content sources resolve empty 15-20 ms later, so their skeletons flashed and collapsed and every rail below jumped up by about 360 px. J1 never saw it: its layout-shift window ends at the first card, which is painted just before the collapse. Rail skeletons now wait out a 300 ms grace period (createRailSkeletonGrace) and appear only for a rail still loading after it; the hero keeps its immediate skeleton because it reserves the top of the page. Recorded over three renderer reloads of a seeded profile, the dashboard's layout shift drops from 0.219-0.234 to 0.0004, with the real rails painted at the same time as before. Plan thread C5, journey J1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dashboard): gate rail skeletons per rail, never above visible rails Addresses the Codex and Greptile reviews on #1738. The component-wide grace timer started when the dashboard was created, so a rail that begins loading later (Xtream recently added, TMDB) showed its skeleton at once and could still flash and collapse; and after the grace period a slow rail's skeleton could appear above rails that already showed cards, pushing them down and, if it resolved empty, back up. createRailSkeletonGates now keeps one gate per rail, in template order: the grace period counts from that rail's own loading start, a skeleton is never inserted above a rail that already has cards (the real rail inserts at most once instead), and a shown skeleton stays until its own rail finishes so the first arriving rail does not collapse the others in a cascade. Nine specs cover the fast path, per-rail start, the no-content-below rule, latching, reloading, destroy and a zero grace period. The seeded-profile timeline is unchanged at 0.0004 across three renderer reloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8 files changed
+568
-15
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<DashboardRailsSettings>({
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
continueWatching: true,
|
||||
liveFavorites: true,
|
||||
recentlyWatchedLive: true,
|
||||
favoriteMoviesAndSeries: true,
|
||||
recentSources: true,
|
||||
xtreamRecentlyAdded: true,
|
||||
tmdbRecommendations: true,
|
||||
tmdbTrending: true,
|
||||
}),
|
||||
recentLive: signal<unknown[]>([]),
|
||||
sources: signal<unknown[]>([]),
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<DashboardRailSkeletonKey, Signal<boolean>> {
|
||||
const rails = () => host.dashboardRails();
|
||||
const none = () => false;
|
||||
|
||||
return createRailSkeletonGates<DashboardRailSkeletonKey>([
|
||||
[
|
||||
'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,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<Key extends string>(
|
||||
entries: readonly (readonly [Key, DashboardRailSkeletonEntry])[],
|
||||
graceMs = DASHBOARD_RAIL_SKELETON_GRACE_MS
|
||||
): Record<Key, Signal<boolean>> {
|
||||
const gates = {} as Record<Key, Signal<boolean>>;
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
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<typeof setTimeout> | 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;
|
||||
}
|
||||
+6
-15
@@ -52,7 +52,7 @@
|
||||
liveEpg.setVisibleCards('favorites', $event)
|
||||
"
|
||||
/>
|
||||
} @else if (showLiveFavoritesSkeleton()) {
|
||||
} @else if (railSkeletons.liveFavorites()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -104,7 +104,7 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (showRecentContentSkeleton()) {
|
||||
@if (railSkeletons.recentContent()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -130,7 +130,7 @@
|
||||
[testId]="'dashboard-recent-sources-rail'"
|
||||
(actionSelected)="onSourceActionSelected($event)"
|
||||
/>
|
||||
} @else if (dashboardRails().recentSources && !data.playlistsLoaded()) {
|
||||
} @else if (railSkeletons.sources()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -158,11 +158,7 @@
|
||||
[totalCount]="data.xtreamRecentlyAddedItems().length"
|
||||
[testId]="'dashboard-xtream-recently-added-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
dashboardRails().xtreamRecentlyAdded &&
|
||||
xtreamPlaylistCount() > 0 &&
|
||||
data.xtreamRecentlyAddedLoading()
|
||||
) {
|
||||
} @else if (railSkeletons.xtreamRecentlyAdded()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -188,10 +184,7 @@
|
||||
[totalCount]="recommendationCards().length"
|
||||
[testId]="'dashboard-tmdb-recommendations-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
dashboardRails().tmdbRecommendations &&
|
||||
recommendationsService.loading()
|
||||
) {
|
||||
} @else if (railSkeletons.tmdbRecommendations()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
@@ -214,9 +207,7 @@
|
||||
[totalCount]="trendingCards().length"
|
||||
[testId]="'dashboard-tmdb-trending-rail'"
|
||||
/>
|
||||
} @else if (
|
||||
dashboardRails().tmdbTrending && trendingService.loading()
|
||||
) {
|
||||
} @else if (railSkeletons.tmdbTrending()) {
|
||||
<section
|
||||
class="rails-page__skeleton-rail"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
resolveSourceExpiryBadge,
|
||||
SOURCE_EXPIRY_TICK_MS,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { createDashboardRailSkeletons } from './dashboard-rail-skeletons';
|
||||
import { DashboardRailComponent } from './dashboard-rail.component';
|
||||
import type {
|
||||
DashboardRailCard,
|
||||
@@ -296,6 +297,9 @@ export class WorkspaceDashboardRailsComponent {
|
||||
}));
|
||||
});
|
||||
|
||||
/** Per-rail skeleton gates; see createDashboardRailSkeletons. */
|
||||
readonly railSkeletons = createDashboardRailSkeletons(this);
|
||||
|
||||
constructor() {
|
||||
// Re-entering the dashboard should pick up any DB-backed recent/favorite
|
||||
// changes made while viewing details, including newly backfilled
|
||||
|
||||
Reference in new issue
Block a user