mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
fix(dashboard): keep the hero skeleton until every hero source has loaded (#1782)
This commit is contained in:
1 parent
1653ffe9fb
commit
b6357da6af
14 files changed
+392
-46
No files matched your search
@@ -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.
|
||||||
@@ -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 top block (the dashboard hero) keeps its immediate skeleton: it reserves
|
||||||
the space above everything else, where a late insertion would push the whole
|
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
|
page down. For the same reason it stays until every source that can fill it
|
||||||
blocks.
|
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
|
### Reload with content on screen: non-destructive indicator
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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;
|
startup, a flicker #1738 did not cover.
|
||||||
the flicker is a separate fix.
|
|
||||||
|
|
||||||
On the Linux CI runner (`Performance journeys` job of #1756, run
|
On the Linux CI runner (`Performance journeys` job of #1756, run
|
||||||
36618062068) the same flicker is a race: the measured iterations read
|
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
|
after the first card), and the one hit shows the same two 316 px moves of
|
||||||
the recent-sources rail.
|
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
|
#### Idle window
|
||||||
|
|
||||||
After the settle point J1 leaves the dashboard alone for
|
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
|
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
|
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.
|
`getWindowState` call), so take J1 baseline values from the runner only.
|
||||||
`renderer.layoutShiftScoreSettled` has no baseline either: the runner reads
|
`renderer.layoutShiftScoreSettled` has no baseline either: the runner read
|
||||||
it as `stable: false` because the dashboard flicker it reports is a race
|
it as `stable: false` because the dashboard hero flicker it reported was a
|
||||||
there (see [Settle window](#settle-window)). Add the runner's number once
|
race there (see [Settle window](#settle-window)). That flicker is fixed; add
|
||||||
that flicker is fixed and the counter is deterministic.
|
the runner's number once runner runs read it as `stable` too.
|
||||||
|
|
||||||
### Weekly tightening
|
### Weekly tightening
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ Render rules:
|
|||||||
full-bleed. All rails and the hero are skipped.
|
full-bleed. All rails and the hero are skipped.
|
||||||
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
|
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
|
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
|
its stream has really played (see "Recently Viewed Confirmation" in
|
||||||
`embedded-inline-playback.md`), so a channel that failed at once never
|
`embedded-inline-playback.md`), so a channel that failed at once never
|
||||||
becomes a hero slide.
|
becomes a hero slide.
|
||||||
|
|||||||
@@ -167,6 +167,35 @@ describe('DashboardDataService', () => {
|
|||||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
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(() => {
|
beforeEach(() => {
|
||||||
Object.defineProperty(window, 'electron', {
|
Object.defineProperty(window, 'electron', {
|
||||||
value: {
|
value: {
|
||||||
@@ -225,34 +254,7 @@ describe('DashboardDataService', () => {
|
|||||||
playbackPositionsMock.getAllPlaybackPositions.mockClear();
|
playbackPositionsMock.getAllPlaybackPositions.mockClear();
|
||||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]);
|
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule(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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
service = TestBed.inject(DashboardDataService);
|
service = TestBed.inject(DashboardDataService);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,6 +305,43 @@ describe('DashboardDataService', () => {
|
|||||||
expect(service.dashboardReady()).toBe(true);
|
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 () => {
|
it('includes M3U favorites in global favorite items', async () => {
|
||||||
await service.reloadGlobalFavorites();
|
await service.reloadGlobalFavorites();
|
||||||
|
|
||||||
|
|||||||
@@ -452,8 +452,10 @@ export class DashboardDataService {
|
|||||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
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(() => {
|
effect(() => {
|
||||||
if (this.hasXtreamPlaylists()) {
|
if (!this.playlistsLoaded() || this.hasXtreamPlaylists()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,8 @@ describe('DashboardPortalLiveEpgService', () => {
|
|||||||
// PWA: the collection resolver is gated on the desktop XMLTV bridge
|
// PWA: the collection resolver is gated on the desktop XMLTV bridge
|
||||||
// and answers nothing, so no request is worth queuing.
|
// and answers nothing, so no request is worth queuing.
|
||||||
supportsEpgProgramLookup = false;
|
supportsEpgProgramLookup = false;
|
||||||
|
// Callers must not wait for an answer that never comes.
|
||||||
|
expect(service.answersPortals).toBe(false);
|
||||||
|
|
||||||
service.sync([entry(1), entry(2)]);
|
service.sync([entry(1), entry(2)]);
|
||||||
await step(3);
|
await step(3);
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ export class DashboardPortalLiveEpgService implements OnDestroy {
|
|||||||
readonly programs = this.programsState.asReadonly();
|
readonly programs = this.programsState.asReadonly();
|
||||||
/** Keys queued or in flight — the cards that may show a placeholder. */
|
/** Keys queued or in flight — the cards that may show a placeholder. */
|
||||||
readonly pending = this.pendingState.asReadonly();
|
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
|
* Replace the wanted set. Keys without a fresh answer are queued; keys no
|
||||||
|
|||||||
+127
-2
@@ -14,7 +14,10 @@ import {
|
|||||||
DashboardHeroTmdbService,
|
DashboardHeroTmdbService,
|
||||||
type DashboardHeroTmdbExtras,
|
type DashboardHeroTmdbExtras,
|
||||||
} from './dashboard-hero-tmdb.service';
|
} 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 type { DashboardHeroLiveCandidate } from './dashboard-hero-slides.utils';
|
||||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||||
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
|
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
|
||||||
@@ -92,6 +95,10 @@ describe('DashboardHeroSlidesPresenter', () => {
|
|||||||
let tmdbEnabled: ReturnType<typeof signal<boolean>>;
|
let tmdbEnabled: ReturnType<typeof signal<boolean>>;
|
||||||
let tmdbLanguage: ReturnType<typeof signal<string>>;
|
let tmdbLanguage: ReturnType<typeof signal<string>>;
|
||||||
let getExtras: jest.Mock;
|
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>([
|
const positions = new Map<string | number, PlaybackPositionData>([
|
||||||
[
|
[
|
||||||
1,
|
1,
|
||||||
@@ -123,7 +130,9 @@ describe('DashboardHeroSlidesPresenter', () => {
|
|||||||
{
|
{
|
||||||
provide: DashboardDataService,
|
provide: DashboardDataService,
|
||||||
useValue: {
|
useValue: {
|
||||||
globalRecentLoading: signal(false),
|
globalRecentLoading: () => recentLoading(),
|
||||||
|
globalFavoritesLoading: () => favoritesLoading(),
|
||||||
|
xtreamRecentlyAddedLoading: () => addedLoading(),
|
||||||
globalRecentItems: recentItems,
|
globalRecentItems: recentItems,
|
||||||
globalRecentVodItems: () =>
|
globalRecentVodItems: () =>
|
||||||
recentItems().filter((i) => i.type !== 'live'),
|
recentItems().filter((i) => i.type !== 'live'),
|
||||||
@@ -162,6 +171,7 @@ describe('DashboardHeroSlidesPresenter', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
heroLiveCandidates: candidates,
|
heroLiveCandidates: candidates,
|
||||||
heroDetailsFor: liveDetails,
|
heroDetailsFor: liveDetails,
|
||||||
|
heroLiveAwaitingFirstAnswer: () => liveAwaiting(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -195,6 +205,14 @@ describe('DashboardHeroSlidesPresenter', () => {
|
|||||||
tmdbEnabled = signal(false);
|
tmdbEnabled = signal(false);
|
||||||
tmdbLanguage = signal('en-US');
|
tmdbLanguage = signal('en-US');
|
||||||
getExtras = jest.fn().mockResolvedValue(null);
|
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', () => {
|
it('builds the rotation from resume, live, favourite and import slides', () => {
|
||||||
@@ -380,4 +398,111 @@ describe('DashboardHeroSlidesPresenter', () => {
|
|||||||
backdropSource: 'fallback',
|
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 {
|
import {
|
||||||
computed,
|
computed,
|
||||||
|
DestroyRef,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
linkedSignal,
|
||||||
signal,
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -46,6 +48,13 @@ const TYPE_LABEL_KEYS = {
|
|||||||
series: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
|
series: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
|
||||||
} as const;
|
} 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 = {
|
const PROVIDER_LABEL_KEYS = {
|
||||||
xtream: 'WORKSPACE.DASHBOARD.XTREAM',
|
xtream: 'WORKSPACE.DASHBOARD.XTREAM',
|
||||||
stalker: 'WORKSPACE.DASHBOARD.STALKER',
|
stalker: 'WORKSPACE.DASHBOARD.STALKER',
|
||||||
@@ -80,10 +89,28 @@ export class DashboardHeroSlidesPresenter {
|
|||||||
>(new Map());
|
>(new Map());
|
||||||
private readonly requestedTmdbKeys = new Set<string>();
|
private readonly requestedTmdbKeys = new Set<string>();
|
||||||
|
|
||||||
/** First history load still running and nothing to feature yet. */
|
private readonly liveAnswerWaitOver = signal(false);
|
||||||
readonly loading = computed(
|
|
||||||
() => this.data.globalRecentLoading() && this.slides().length === 0
|
/**
|
||||||
);
|
* 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. */
|
/** The first candidate channel with a programme on air right now. */
|
||||||
private readonly liveSlide = computed(() => {
|
private readonly liveSlide = computed(() => {
|
||||||
@@ -127,6 +154,12 @@ export class DashboardHeroSlidesPresenter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
const liveAnswerWait = setTimeout(
|
||||||
|
() => this.liveAnswerWaitOver.set(true),
|
||||||
|
DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS
|
||||||
|
);
|
||||||
|
inject(DestroyRef).onDestroy(() => clearTimeout(liveAnswerWait));
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (!this.heroTmdb.isEnabled()) {
|
if (!this.heroTmdb.isEnabled()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+52
-1
@@ -1,6 +1,6 @@
|
|||||||
import { computed, signal } from '@angular/core';
|
import { computed, signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
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 { EpgService } from '@iptvnator/epg/data-access';
|
||||||
import {
|
import {
|
||||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||||
@@ -57,6 +57,7 @@ describe('DashboardLiveEpgPresenter', () => {
|
|||||||
setVisibleCards: jest.Mock;
|
setVisibleCards: jest.Mock;
|
||||||
programFor: jest.Mock;
|
programFor: jest.Mock;
|
||||||
isPending: jest.Mock;
|
isPending: jest.Mock;
|
||||||
|
awaitsFirstAnswer: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setup = (cards: DashboardRailCard[]) => {
|
const setup = (cards: DashboardRailCard[]) => {
|
||||||
@@ -88,6 +89,7 @@ describe('DashboardLiveEpgPresenter', () => {
|
|||||||
setVisibleCards: jest.fn(),
|
setVisibleCards: jest.fn(),
|
||||||
programFor: jest.fn(() => undefined),
|
programFor: jest.fn(() => undefined),
|
||||||
isPending: jest.fn(() => false),
|
isPending: jest.fn(() => false),
|
||||||
|
awaitsFirstAnswer: jest.fn(() => false),
|
||||||
};
|
};
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
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', () => {
|
it('prefers the portal answer and forwards what the portal presenter owns', () => {
|
||||||
const xmltvCard = card({
|
const xmltvCard = card({
|
||||||
id: 'x',
|
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(() =>
|
private readonly offsetMinutes = computed(() =>
|
||||||
this.settingsStore.resolvedEpgOffsetMinutes()
|
this.settingsStore.resolvedEpgOffsetMinutes()
|
||||||
);
|
);
|
||||||
@@ -174,6 +179,7 @@ export class DashboardLiveEpgPresenter {
|
|||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(([groups, offsetMinutes]) => {
|
switchMap(([groups, offsetMinutes]) => {
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
|
this.answeredLookupGroups.set(groups);
|
||||||
return of(new Map<string, EpgProgram | null>());
|
return of(new Map<string, EpgProgram | null>());
|
||||||
}
|
}
|
||||||
let answers: ReadonlyMap<string, EpgProgram | null> | null =
|
let answers: ReadonlyMap<string, EpgProgram | null> | null =
|
||||||
@@ -200,6 +206,7 @@ export class DashboardLiveEpgPresenter {
|
|||||||
tap((merged) => {
|
tap((merged) => {
|
||||||
answers = merged;
|
answers = merged;
|
||||||
answeredAt = Date.now();
|
answeredAt = Date.now();
|
||||||
|
this.answeredLookupGroups.set(groups);
|
||||||
}),
|
}),
|
||||||
distinctUntilChanged(sameLiveEpgAnswers)
|
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`. */
|
/** Current programme of a hero live candidate, or `null`. */
|
||||||
heroDetailsFor(item: PortalActivityItem): DashboardLiveEpgDetails | null {
|
heroDetailsFor(item: PortalActivityItem): DashboardLiveEpgDetails | null {
|
||||||
return this.detailsFor(buildDashboardLiveEpgCard(item));
|
return this.detailsFor(buildDashboardLiveEpgCard(item));
|
||||||
|
|||||||
+22
-1
@@ -31,6 +31,7 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
|||||||
>;
|
>;
|
||||||
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
|
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
|
||||||
let offsetMinutes: ReturnType<typeof signal<number>>;
|
let offsetMinutes: ReturnType<typeof signal<number>>;
|
||||||
|
let service: { answersPortals: boolean };
|
||||||
|
|
||||||
/** Keys of every sync call, sorted: the wanted set has no order. */
|
/** Keys of every sync call, sorted: the wanted set has no order. */
|
||||||
const wantedKeys = (): string[][] =>
|
const wantedKeys = (): string[][] =>
|
||||||
@@ -43,13 +44,18 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
|||||||
programs = signal<ReadonlyMap<string, EpgProgram | null>>(new Map());
|
programs = signal<ReadonlyMap<string, EpgProgram | null>>(new Map());
|
||||||
pending = signal<ReadonlySet<string>>(new Set());
|
pending = signal<ReadonlySet<string>>(new Set());
|
||||||
offsetMinutes = signal(0);
|
offsetMinutes = signal(0);
|
||||||
|
service = { answersPortals: true };
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
DashboardLiveEpgClock,
|
DashboardLiveEpgClock,
|
||||||
DashboardPortalLiveEpgPresenter,
|
DashboardPortalLiveEpgPresenter,
|
||||||
{
|
{
|
||||||
provide: DashboardPortalLiveEpgService,
|
provide: DashboardPortalLiveEpgService,
|
||||||
useValue: { sync, programs, pending },
|
useValue: Object.assign(service, {
|
||||||
|
sync,
|
||||||
|
programs,
|
||||||
|
pending,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: SettingsStore,
|
provide: SettingsStore,
|
||||||
@@ -182,4 +188,19 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
|||||||
expect(presenter.programFor('xtream::p::1')).toBe(program);
|
expect(presenter.programFor('xtream::p::1')).toBe(program);
|
||||||
expect(presenter.programFor('xtream::p::2')).toBeNull();
|
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 {
|
isPending(key: string | null | undefined): boolean {
|
||||||
return !!key && this.service.pending().has(key);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user