mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56: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 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
|
||||
|
||||
|
||||
@@ -179,8 +179,7 @@ stable in the other two). Every iteration shows the same two shifts of 0.118:
|
||||
about 12 ms after the first card the `dashboard-recent-sources-rail`, which
|
||||
holds the first card, moves up by 316 px, and 12-65 ms later it moves back
|
||||
down. Something 316 px tall above it is removed and inserted again during
|
||||
startup, a flicker #1738 did not cover. The counter is working as intended;
|
||||
the flicker is a separate fix.
|
||||
startup, a flicker #1738 did not cover.
|
||||
|
||||
On the Linux CI runner (`Performance journeys` job of #1756, run
|
||||
36618062068) the same flicker is a race: the measured iterations read
|
||||
@@ -188,6 +187,18 @@ On the Linux CI runner (`Performance journeys` job of #1756, run
|
||||
after the first card), and the one hit shows the same two 316 px moves of
|
||||
the recent-sources rail.
|
||||
|
||||
The 316 px element was the dashboard hero. The J1 profile has no history
|
||||
and no favorites; its only slide is an Xtream recently-added title, and that
|
||||
query waits for the favorites. The hero dropped its skeleton as soon as the
|
||||
history resolved empty and came back with that slide moments later. It now
|
||||
keeps the skeleton until every source that can feature a title has loaded,
|
||||
including a live candidate's first programme answer for at most 2 s
|
||||
(`DashboardHeroSlidesPresenter.loading`), and
|
||||
`DashboardDataService.xtreamRecentlyAddedLoading` no longer settles before
|
||||
the playlist inventory has loaded. After the fix (macOS, 2026-09-30): both
|
||||
counters were 0 in all 12 iterations of two runs, every window closed on
|
||||
`quiet` and `lateShifts` was empty.
|
||||
|
||||
#### Idle window
|
||||
|
||||
After the settle point J1 leaves the dashboard alone for
|
||||
@@ -949,10 +960,10 @@ in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from
|
||||
1,401 to 1,674 ms. All four stay evidence for now. Runner counters also
|
||||
differ from a Mac (12 and 571 there, the fast path without the Linux-only
|
||||
`getWindowState` call), so take J1 baseline values from the runner only.
|
||||
`renderer.layoutShiftScoreSettled` has no baseline either: the runner reads
|
||||
it as `stable: false` because the dashboard flicker it reports is a race
|
||||
there (see [Settle window](#settle-window)). Add the runner's number once
|
||||
that flicker is fixed and the counter is deterministic.
|
||||
`renderer.layoutShiftScoreSettled` has no baseline either: the runner read
|
||||
it as `stable: false` because the dashboard hero flicker it reported was a
|
||||
race there (see [Settle window](#settle-window)). That flicker is fixed; add
|
||||
the runner's number once runner runs read it as `stable` too.
|
||||
|
||||
### Weekly tightening
|
||||
|
||||
|
||||
@@ -75,7 +75,13 @@ Render rules:
|
||||
full-bleed. All rails and the hero are skipped.
|
||||
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
|
||||
see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while
|
||||
the first history load runs. An item enters recent history only after
|
||||
it has no slide and any of its sources (history, favorites, Xtream
|
||||
recently added) is still on its first load, or a live candidate still
|
||||
waits for its first programme answer (portal or XMLTV, for at most
|
||||
`DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS`, 2 s, from the hero's creation).
|
||||
Dropping it earlier removed the hero and inserted it again when a later
|
||||
source featured a title, moving every rail below twice. Once the
|
||||
skeleton has gone it does not come back. An item enters recent history only after
|
||||
its stream has really played (see "Recently Viewed Confirmation" in
|
||||
`embedded-inline-playback.md`), so a channel that failed at once never
|
||||
becomes a hero slide.
|
||||
|
||||
@@ -167,6 +167,35 @@ describe('DashboardDataService', () => {
|
||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const createTestingModuleProviders = () => ({
|
||||
providers: [
|
||||
DashboardDataService,
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{ provide: DatabaseService, useValue: dbServiceMock },
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: xtreamDataSourceMock,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsServiceMock,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: playbackPositionsMock,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {
|
||||
@@ -225,34 +254,7 @@ describe('DashboardDataService', () => {
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockClear();
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardDataService,
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{ provide: DatabaseService, useValue: dbServiceMock },
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: xtreamDataSourceMock,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsServiceMock,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: playbackPositionsMock,
|
||||
},
|
||||
],
|
||||
});
|
||||
TestBed.configureTestingModule(createTestingModuleProviders());
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
});
|
||||
|
||||
@@ -303,6 +305,43 @@ describe('DashboardDataService', () => {
|
||||
expect(service.dashboardReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps xtream recently added loading until the playlist inventory has loaded', async () => {
|
||||
// Startup: the dashboard exists before the inventory, which is empty
|
||||
// until it loads, so "no Xtream playlists" is not known yet.
|
||||
TestBed.resetTestingModule();
|
||||
playlistsLoadedSignal.set(false);
|
||||
playlistsSignal.set([]);
|
||||
TestBed.configureTestingModule(createTestingModuleProviders());
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
TestBed.tick();
|
||||
expect(service.xtreamRecentlyAddedLoading()).toBe(true);
|
||||
expect(service.xtreamRecentlyAddedLoaded()).toBe(false);
|
||||
|
||||
playlistsSignal.set(createDefaultPlaylists());
|
||||
playlistsLoadedSignal.set(true);
|
||||
TestBed.tick();
|
||||
expect(service.xtreamRecentlyAddedLoading()).toBe(true);
|
||||
|
||||
await service.reloadXtreamRecentlyAddedItems();
|
||||
expect(service.xtreamRecentlyAddedLoading()).toBe(false);
|
||||
expect(service.xtreamRecentlyAddedLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it('settles xtream recently added once the loaded inventory has no xtream playlists', () => {
|
||||
TestBed.resetTestingModule();
|
||||
playlistsLoadedSignal.set(false);
|
||||
playlistsSignal.set([]);
|
||||
TestBed.configureTestingModule(createTestingModuleProviders());
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
TestBed.tick();
|
||||
expect(service.xtreamRecentlyAddedLoading()).toBe(true);
|
||||
|
||||
playlistsLoadedSignal.set(true);
|
||||
TestBed.tick();
|
||||
expect(service.xtreamRecentlyAddedLoading()).toBe(false);
|
||||
expect(service.xtreamRecentlyAddedLoaded()).toBe(true);
|
||||
});
|
||||
|
||||
it('includes M3U favorites in global favorite items', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
|
||||
|
||||
@@ -452,8 +452,10 @@ export class DashboardDataService {
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
});
|
||||
|
||||
// Before the inventory has loaded "no Xtream playlists" is not known
|
||||
// yet; settling here would end the first load before it started.
|
||||
effect(() => {
|
||||
if (this.hasXtreamPlaylists()) {
|
||||
if (!this.playlistsLoaded() || this.hasXtreamPlaylists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -313,6 +313,8 @@ describe('DashboardPortalLiveEpgService', () => {
|
||||
// PWA: the collection resolver is gated on the desktop XMLTV bridge
|
||||
// and answers nothing, so no request is worth queuing.
|
||||
supportsEpgProgramLookup = false;
|
||||
// Callers must not wait for an answer that never comes.
|
||||
expect(service.answersPortals).toBe(false);
|
||||
|
||||
service.sync([entry(1), entry(2)]);
|
||||
await step(3);
|
||||
|
||||
@@ -77,6 +77,13 @@ export class DashboardPortalLiveEpgService implements OnDestroy {
|
||||
readonly programs = this.programsState.asReadonly();
|
||||
/** Keys queued or in flight — the cards that may show a placeholder. */
|
||||
readonly pending = this.pendingState.asReadonly();
|
||||
/**
|
||||
* False where portals are never asked (no local XMLTV bridge), so no
|
||||
* card there ever gets an answer to wait for.
|
||||
*/
|
||||
get answersPortals(): boolean {
|
||||
return this.runtime.supportsEpgProgramLookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the wanted set. Keys without a fresh answer are queued; keys no
|
||||
|
||||
+127
-2
@@ -14,7 +14,10 @@ import {
|
||||
DashboardHeroTmdbService,
|
||||
type DashboardHeroTmdbExtras,
|
||||
} from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
|
||||
import {
|
||||
DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS,
|
||||
DashboardHeroSlidesPresenter,
|
||||
} from './dashboard-hero-slides.presenter';
|
||||
import type { DashboardHeroLiveCandidate } from './dashboard-hero-slides.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
|
||||
@@ -92,6 +95,10 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
let tmdbEnabled: ReturnType<typeof signal<boolean>>;
|
||||
let tmdbLanguage: ReturnType<typeof signal<string>>;
|
||||
let getExtras: jest.Mock;
|
||||
let recentLoading: ReturnType<typeof signal<boolean>>;
|
||||
let favoritesLoading: ReturnType<typeof signal<boolean>>;
|
||||
let addedLoading: ReturnType<typeof signal<boolean>>;
|
||||
let liveAwaiting: ReturnType<typeof signal<boolean>>;
|
||||
const positions = new Map<string | number, PlaybackPositionData>([
|
||||
[
|
||||
1,
|
||||
@@ -123,7 +130,9 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: {
|
||||
globalRecentLoading: signal(false),
|
||||
globalRecentLoading: () => recentLoading(),
|
||||
globalFavoritesLoading: () => favoritesLoading(),
|
||||
xtreamRecentlyAddedLoading: () => addedLoading(),
|
||||
globalRecentItems: recentItems,
|
||||
globalRecentVodItems: () =>
|
||||
recentItems().filter((i) => i.type !== 'live'),
|
||||
@@ -162,6 +171,7 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
useValue: {
|
||||
heroLiveCandidates: candidates,
|
||||
heroDetailsFor: liveDetails,
|
||||
heroLiveAwaitingFirstAnswer: () => liveAwaiting(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -195,6 +205,14 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
tmdbEnabled = signal(false);
|
||||
tmdbLanguage = signal('en-US');
|
||||
getExtras = jest.fn().mockResolvedValue(null);
|
||||
recentLoading = signal(false);
|
||||
favoritesLoading = signal(false);
|
||||
addedLoading = signal(false);
|
||||
liveAwaiting = signal(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('builds the rotation from resume, live, favourite and import slides', () => {
|
||||
@@ -380,4 +398,111 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
backdropSource: 'fallback',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the skeleton until every source that can feature a title has loaded', () => {
|
||||
// J1 profile: no history, no favourites; the Xtream recently-added
|
||||
// query runs after the favourites and features the only slide.
|
||||
recentItems.set([]);
|
||||
favorites.set([]);
|
||||
candidates.set([]);
|
||||
addedItems.set([]);
|
||||
recentLoading.set(true);
|
||||
favoritesLoading.set(true);
|
||||
addedLoading.set(true);
|
||||
const presenter = create();
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
recentLoading.set(false);
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
favoritesLoading.set(false);
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
addedItems.set([import1]);
|
||||
addedLoading.set(false);
|
||||
expect(presenter.slides().map((slide) => slide.kind)).toEqual([
|
||||
'added',
|
||||
]);
|
||||
expect(presenter.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the skeleton once every source has loaded with nothing to feature', () => {
|
||||
recentItems.set([]);
|
||||
favorites.set([]);
|
||||
candidates.set([]);
|
||||
addedItems.set([]);
|
||||
addedLoading.set(true);
|
||||
const presenter = create();
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
addedLoading.set(false);
|
||||
expect(presenter.loading()).toBe(false);
|
||||
expect(presenter.slides()).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows a slide without waiting for the slower sources', () => {
|
||||
favoritesLoading.set(true);
|
||||
addedLoading.set(true);
|
||||
|
||||
expect(create().loading()).toBe(false);
|
||||
});
|
||||
|
||||
describe('with a live channel as the only candidate', () => {
|
||||
let onAirNow: ReturnType<typeof signal<boolean>>;
|
||||
|
||||
beforeEach(() => {
|
||||
recentItems.set([]);
|
||||
favorites.set([]);
|
||||
addedItems.set([]);
|
||||
onAirNow = signal(false);
|
||||
liveDetails = jest.fn(() => (onAirNow() ? onAir : null));
|
||||
liveAwaiting.set(true);
|
||||
});
|
||||
|
||||
it('keeps the skeleton until the channel has its first programme', () => {
|
||||
const presenter = create();
|
||||
expect(presenter.slides()).toEqual([]);
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
onAirNow.set(true);
|
||||
liveAwaiting.set(false);
|
||||
expect(presenter.slides().map((slide) => slide.kind)).toEqual([
|
||||
'live',
|
||||
]);
|
||||
expect(presenter.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the skeleton once the channel answered with nothing on air', () => {
|
||||
const presenter = create();
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
liveAwaiting.set(false);
|
||||
expect(presenter.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('stops waiting for a programme that does not come', () => {
|
||||
jest.useFakeTimers();
|
||||
const presenter = create();
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
jest.advanceTimersByTime(DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS - 1);
|
||||
expect(presenter.loading()).toBe(true);
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(presenter.loading()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not bring the skeleton back once it has gone', () => {
|
||||
recentItems.set([]);
|
||||
favorites.set([]);
|
||||
candidates.set([]);
|
||||
addedItems.set([]);
|
||||
const presenter = create();
|
||||
expect(presenter.loading()).toBe(false);
|
||||
|
||||
// A later live lookup (a rail card changed the XMLTV batch) must
|
||||
// not insert the skeleton above content that is already placed.
|
||||
liveAwaiting.set(true);
|
||||
expect(presenter.loading()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
@@ -46,6 +48,13 @@ const TYPE_LABEL_KEYS = {
|
||||
series: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* How long, from the hero's creation, its skeleton may wait for the first
|
||||
* programme of a live candidate. Portal answers usually take a few hundred
|
||||
* milliseconds; an unreachable portal must not hold the skeleton forever.
|
||||
*/
|
||||
export const DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS = 2000;
|
||||
|
||||
const PROVIDER_LABEL_KEYS = {
|
||||
xtream: 'WORKSPACE.DASHBOARD.XTREAM',
|
||||
stalker: 'WORKSPACE.DASHBOARD.STALKER',
|
||||
@@ -80,10 +89,28 @@ export class DashboardHeroSlidesPresenter {
|
||||
>(new Map());
|
||||
private readonly requestedTmdbKeys = new Set<string>();
|
||||
|
||||
/** First history load still running and nothing to feature yet. */
|
||||
readonly loading = computed(
|
||||
() => this.data.globalRecentLoading() && this.slides().length === 0
|
||||
);
|
||||
private readonly liveAnswerWaitOver = signal(false);
|
||||
|
||||
/**
|
||||
* Nothing to feature yet and a source that can feature a title is still
|
||||
* on its first load. Every such source counts, not only the history:
|
||||
* the Xtream recently-added query waits for the favorites, and a live
|
||||
* slide waits for its programme, so dropping the skeleton when the
|
||||
* history resolved empty removed the hero and inserted it again moments
|
||||
* later, moving every rail below twice. Once the skeleton has gone it
|
||||
* does not come back: a later reload must not shift the page either.
|
||||
*/
|
||||
readonly loading = linkedSignal<boolean, boolean>({
|
||||
source: () =>
|
||||
this.slides().length === 0 &&
|
||||
(this.data.globalRecentLoading() ||
|
||||
this.data.globalFavoritesLoading() ||
|
||||
this.data.xtreamRecentlyAddedLoading() ||
|
||||
(!this.liveAnswerWaitOver() &&
|
||||
this.liveEpg.heroLiveAwaitingFirstAnswer())),
|
||||
computation: (loading, previous) =>
|
||||
previous?.value === false ? false : loading,
|
||||
}).asReadonly();
|
||||
|
||||
/** The first candidate channel with a programme on air right now. */
|
||||
private readonly liveSlide = computed(() => {
|
||||
@@ -127,6 +154,12 @@ export class DashboardHeroSlidesPresenter {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const liveAnswerWait = setTimeout(
|
||||
() => this.liveAnswerWaitOver.set(true),
|
||||
DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS
|
||||
);
|
||||
inject(DestroyRef).onDestroy(() => clearTimeout(liveAnswerWait));
|
||||
|
||||
effect(() => {
|
||||
if (!this.heroTmdb.isEnabled()) {
|
||||
return;
|
||||
|
||||
+52
-1
@@ -1,6 +1,6 @@
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { BehaviorSubject, EMPTY, of, throwError } from 'rxjs';
|
||||
import { BehaviorSubject, EMPTY, of, Subject, throwError } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
@@ -57,6 +57,7 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
setVisibleCards: jest.Mock;
|
||||
programFor: jest.Mock;
|
||||
isPending: jest.Mock;
|
||||
awaitsFirstAnswer: jest.Mock;
|
||||
};
|
||||
|
||||
const setup = (cards: DashboardRailCard[]) => {
|
||||
@@ -88,6 +89,7 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
setVisibleCards: jest.fn(),
|
||||
programFor: jest.fn(() => undefined),
|
||||
isPending: jest.fn(() => false),
|
||||
awaitsFirstAnswer: jest.fn(() => false),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -259,6 +261,55 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a hero live candidate still waiting for its first programme', () => {
|
||||
const xmltvAnswer = new Subject<Map<string, EpgProgram | null>>();
|
||||
getCurrentProgramsForChannels.mockImplementation(() => xmltvAnswer);
|
||||
expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(false);
|
||||
|
||||
const m3uChannel = {
|
||||
id: 'ard-hd',
|
||||
title: 'Das Erste HD',
|
||||
type: 'live',
|
||||
source: 'm3u',
|
||||
playlist_id: 'a',
|
||||
category_id: '',
|
||||
xtream_id: 'ard-hd',
|
||||
epg_lookup_key: 'ard.de',
|
||||
} as PortalActivityItem;
|
||||
favoriteLiveItems.set([m3uChannel]);
|
||||
setup([]);
|
||||
// The XMLTV batch for the candidate has not answered yet.
|
||||
expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(true);
|
||||
|
||||
xmltvAnswer.next(new Map([['ard.de', null]]));
|
||||
xmltvAnswer.complete();
|
||||
expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(false);
|
||||
|
||||
// A portal candidate also waits for its portal's first answer.
|
||||
portal.awaitsFirstAnswer.mockReturnValue(true);
|
||||
favoriteLiveItems.set([
|
||||
m3uChannel,
|
||||
{
|
||||
...m3uChannel,
|
||||
id: 7,
|
||||
source: 'xtream',
|
||||
playlist_id: 'portal',
|
||||
xtream_id: 7,
|
||||
epg_lookup_key: undefined,
|
||||
} as PortalActivityItem,
|
||||
]);
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(new Map<string, EpgProgram | null>())
|
||||
);
|
||||
TestBed.tick();
|
||||
expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(true);
|
||||
|
||||
portal.awaitsFirstAnswer.mockReturnValue(false);
|
||||
favoriteLiveItems.update((items) => [...items]);
|
||||
TestBed.tick();
|
||||
expect(presenter.heroLiveAwaitingFirstAnswer()).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the portal answer and forwards what the portal presenter owns', () => {
|
||||
const xmltvCard = card({
|
||||
id: 'x',
|
||||
|
||||
@@ -149,6 +149,11 @@ export class DashboardLiveEpgPresenter {
|
||||
)
|
||||
);
|
||||
|
||||
/** The lookup groups the XMLTV batch below last answered. */
|
||||
private readonly answeredLookupGroups = signal<
|
||||
readonly DashboardLiveEpgLookupGroup[] | null
|
||||
>(null);
|
||||
|
||||
private readonly offsetMinutes = computed(() =>
|
||||
this.settingsStore.resolvedEpgOffsetMinutes()
|
||||
);
|
||||
@@ -174,6 +179,7 @@ export class DashboardLiveEpgPresenter {
|
||||
]).pipe(
|
||||
switchMap(([groups, offsetMinutes]) => {
|
||||
if (groups.length === 0) {
|
||||
this.answeredLookupGroups.set(groups);
|
||||
return of(new Map<string, EpgProgram | null>());
|
||||
}
|
||||
let answers: ReadonlyMap<string, EpgProgram | null> | null =
|
||||
@@ -200,6 +206,7 @@ export class DashboardLiveEpgPresenter {
|
||||
tap((merged) => {
|
||||
answers = merged;
|
||||
answeredAt = Date.now();
|
||||
this.answeredLookupGroups.set(groups);
|
||||
}),
|
||||
distinctUntilChanged(sameLiveEpgAnswers)
|
||||
);
|
||||
@@ -272,6 +279,26 @@ export class DashboardLiveEpgPresenter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a hero live candidate may still get its first programme:
|
||||
* its portal has not answered yet, or the XMLTV batch has not answered
|
||||
* the current lookups. A live slide exists only once a programme is on
|
||||
* air, so the hero keeps its skeleton meanwhile instead of inserting
|
||||
* the slide late.
|
||||
*/
|
||||
readonly heroLiveAwaitingFirstAnswer = computed(() => {
|
||||
const cards = this.heroLiveCards();
|
||||
if (cards.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.answeredLookupGroups() !== this.lookupGroups() ||
|
||||
cards.some((card) =>
|
||||
this.portal.awaitsFirstAnswer(card.liveEpgSourceKey)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
/** Current programme of a hero live candidate, or `null`. */
|
||||
heroDetailsFor(item: PortalActivityItem): DashboardLiveEpgDetails | null {
|
||||
return this.detailsFor(buildDashboardLiveEpgCard(item));
|
||||
|
||||
+22
-1
@@ -31,6 +31,7 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
||||
>;
|
||||
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
|
||||
let offsetMinutes: ReturnType<typeof signal<number>>;
|
||||
let service: { answersPortals: boolean };
|
||||
|
||||
/** Keys of every sync call, sorted: the wanted set has no order. */
|
||||
const wantedKeys = (): string[][] =>
|
||||
@@ -43,13 +44,18 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
||||
programs = signal<ReadonlyMap<string, EpgProgram | null>>(new Map());
|
||||
pending = signal<ReadonlySet<string>>(new Set());
|
||||
offsetMinutes = signal(0);
|
||||
service = { answersPortals: true };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardLiveEpgClock,
|
||||
DashboardPortalLiveEpgPresenter,
|
||||
{
|
||||
provide: DashboardPortalLiveEpgService,
|
||||
useValue: { sync, programs, pending },
|
||||
useValue: Object.assign(service, {
|
||||
sync,
|
||||
programs,
|
||||
pending,
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
@@ -182,4 +188,19 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
||||
expect(presenter.programFor('xtream::p::1')).toBe(program);
|
||||
expect(presenter.programFor('xtream::p::2')).toBeNull();
|
||||
});
|
||||
|
||||
it('awaits a first answer until the card has one, also before it is queued', () => {
|
||||
expect(presenter.awaitsFirstAnswer(null)).toBe(false);
|
||||
// Not queued yet (the pin reaches the queue through an effect).
|
||||
expect(presenter.awaitsFirstAnswer('xtream::p::1')).toBe(true);
|
||||
|
||||
programs.set(new Map([['xtream::p::1', null]]));
|
||||
expect(presenter.awaitsFirstAnswer('xtream::p::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('never awaits an answer where portals are not asked', () => {
|
||||
service.answersPortals = false;
|
||||
|
||||
expect(presenter.awaitsFirstAnswer('xtream::p::1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -126,4 +126,17 @@ export class DashboardPortalLiveEpgPresenter {
|
||||
isPending(key: string | null | undefined): boolean {
|
||||
return !!key && this.service.pending().has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* True until the first answer for a portal card arrives, including the
|
||||
* moment before its key reaches the queue; never where portals are not
|
||||
* asked at all.
|
||||
*/
|
||||
awaitsFirstAnswer(key: string | null | undefined): boolean {
|
||||
return (
|
||||
!!key &&
|
||||
this.service.answersPortals &&
|
||||
this.service.programs().get(key) === undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user