fix(dashboard): hold the hero skeleton for a live candidate's first programme

A live hero slide exists only once its channel has a programme on air.
With a live favorite as the only candidate, the skeleton went away when
history, favorites and recently added had loaded, and the live slide was
inserted when the portal or XMLTV answer arrived, shifting the rails.

DashboardLiveEpgPresenter now reports while a hero candidate awaits its
first portal or XMLTV answer, and the hero keeps its skeleton for that,
at most 2 s from its creation. Once the skeleton has gone it does not
come back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 21:48:01 +02:00
1 parent 9128a90cfd
commit cea85f50f0
9 files changed
+229 -13

No files matched your search

+6 -3
View File
@@ -76,9 +76,12 @@ Render rules:
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
it has no slide and any of its sources (history, favorites, Xtream
recently added) is still on its first load; dropping it earlier removed
the hero and inserted it again when a later source featured a title,
moving every rail below twice. An item enters recent history only after
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.
@@ -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
@@ -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';
@@ -95,6 +98,7 @@ describe('DashboardHeroSlidesPresenter', () => {
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,
@@ -167,6 +171,7 @@ describe('DashboardHeroSlidesPresenter', () => {
useValue: {
heroLiveCandidates: candidates,
heroDetailsFor: liveDetails,
heroLiveAwaitingFirstAnswer: () => liveAwaiting(),
},
},
{
@@ -203,6 +208,11 @@ describe('DashboardHeroSlidesPresenter', () => {
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', () => {
@@ -436,4 +446,63 @@ describe('DashboardHeroSlidesPresenter', () => {
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,20 +89,28 @@ export class DashboardHeroSlidesPresenter {
>(new Map());
private readonly requestedTmdbKeys = new Set<string>();
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, so dropping
* the skeleton when the history resolved empty removed the hero and
* inserted it again moments later, moving every rail below twice.
* 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 = computed(
() =>
readonly loading = linkedSignal<boolean, boolean>({
source: () =>
this.slides().length === 0 &&
(this.data.globalRecentLoading() ||
this.data.globalFavoritesLoading() ||
this.data.xtreamRecentlyAddedLoading())
);
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(() => {
@@ -137,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;
@@ -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));
@@ -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
);
}
}