mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
perf(dashboard): stop idle dashboard ticks that have nothing to update (#1722)
This commit is contained in:
1 parent
9d3e71a952
commit
97f56e219c
17 files changed
+904
-63
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
A dashboard left open does less work: it only refreshes live programmes while
|
||||
live channels are shown and the window is visible, asks the guide again only
|
||||
when a programme ends, and no longer snaps a scrolled rail back to the start.
|
||||
@@ -142,6 +142,10 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||||
1. Inputs: `label`, `items: DashboardRailCard[]`, optional `seeAllLink`,
|
||||
optional `aspectRatio` (default `'2 / 3'`), optional `testId`.
|
||||
2. Behavior: horizontal flex track with `scroll-snap-type: x mandatory`.
|
||||
The track scrolls back to the start and re-observes its cards only when
|
||||
the ids or order of `items` change. Hosts rebuild card objects on every
|
||||
clock tick (live progress, expiry badges), and such a rebuild must not
|
||||
move a rail the user scrolled.
|
||||
3. Chevron buttons fade in on hover (desktop only via `@media (hover: none)`).
|
||||
Edge fades follow the chevrons' visibility. The track bleeds
|
||||
`--rail-bleed` past the viewport on every side so card focus rings and
|
||||
@@ -198,6 +202,17 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||||
lookups" in `m3u-playlist-module.md`, forwards everything portal-shaped
|
||||
to `DashboardPortalLiveEpgPresenter`, and `enrich()` returns the cards
|
||||
with their "now on air" row filled in.
|
||||
One component-provided `DashboardLiveEpgClock` drives every live
|
||||
refresh and progress bar on the page. It ticks every 30 s only while
|
||||
the XMLTV lookup has cards or the portal presenter wants one, and
|
||||
only while the document is visible; it reads the clock at once when
|
||||
it starts again. A tick re-reads progress for every live card. It
|
||||
re-asks an XMLTV scope only after one of its programmes has ended,
|
||||
while a key has no programme, or once the answer is five minutes old
|
||||
(`LIVE_EPG_MAX_ANSWER_AGE_MS`), because a guide refreshed elsewhere
|
||||
can correct a programme still on air. A guide import or source change
|
||||
(`EpgService.epgAvailable$`) re-asks at once. An unchanged answer is
|
||||
not re-emitted.
|
||||
Xtream and Stalker cards have no XMLTV key of their own; their "now on
|
||||
air" line comes from the portal, **lazily and per card**:
|
||||
- `buildDashboardPortalLiveEpgEntry` (dashboard data-access) turns a
|
||||
@@ -214,7 +229,8 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||||
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
|
||||
visible keys of both rails with the pinned hero keys and calls
|
||||
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
|
||||
on every change, on the 30 s tick, and on a display-offset change.
|
||||
on every change, on each tick of the shared live-EPG clock, and on
|
||||
a display-offset change.
|
||||
It is reached through `DashboardLiveEpgPresenter`, which derives the
|
||||
portal rows itself from the enabled rails and pins the hero's live
|
||||
candidates, so the page component only forwards what a rail can see. The queue lives in
|
||||
@@ -388,3 +404,10 @@ Xtream expiry from cached `PortalStatusService.checkPortalStatusDetails()`
|
||||
(`exp_date`). Stalker uses the persisted `stalkerAccountInfo` snapshot from the
|
||||
playlist payload, not the metadata row; each source therefore needs one memoized
|
||||
full-playlist read. The chip is not a separate account-refresh request.
|
||||
The badge only changes at day boundaries, so the rails do not poll the clock:
|
||||
`createSourceExpiryClock` arms one timer for the earliest boundary among the
|
||||
known facts (`nextSourceExpiryChangeMs`), capped at an hour because timers do
|
||||
not follow system sleep. It arms no timer while the page is hidden or the
|
||||
sources rail is disabled, and re-reads the clock when the page becomes visible. It schedules from the real
|
||||
time, so facts that arrive long after the last tick are not scheduled late.
|
||||
Facts whose badge can no longer change arm no timer.
|
||||
@@ -5,6 +5,7 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { DashboardSourceExpiryService } from './dashboard-source-expiry.service';
|
||||
import {
|
||||
SOURCE_EXPIRY_WARNING_DAYS,
|
||||
nextSourceExpiryChangeMs,
|
||||
resolveSourceExpiryBadge,
|
||||
} from './dashboard-source-expiry.util';
|
||||
|
||||
@@ -91,6 +92,61 @@ describe('resolveSourceExpiryBadge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextSourceExpiryChangeMs', () => {
|
||||
const nowMs = Date.UTC(2026, 7, 1, 12, 0, 0);
|
||||
const badgeAt = (expiresAtSeconds: number, atMs: number) =>
|
||||
JSON.stringify(
|
||||
resolveSourceExpiryBadge(
|
||||
{ expiresAtSeconds, reportedExpired: false },
|
||||
atMs
|
||||
)
|
||||
);
|
||||
|
||||
it.each([
|
||||
['far outside the warning window', 30 * DAY_SECONDS + 5],
|
||||
['one day past the warning window', 8 * DAY_SECONDS - 60],
|
||||
['inside the warning window', 3 * DAY_SECONDS + 3_600],
|
||||
['on the last day', 7_200],
|
||||
['exactly on a day boundary', 2 * DAY_SECONDS],
|
||||
])('returns the first instant the badge changes (%s)', (_, secondsLeft) => {
|
||||
const expiresAt = nowMs / 1000 + secondsLeft;
|
||||
const next = nextSourceExpiryChangeMs(
|
||||
{ expiresAtSeconds: expiresAt, reportedExpired: false },
|
||||
nowMs
|
||||
);
|
||||
|
||||
expect(next).not.toBeNull();
|
||||
const boundary = next as number;
|
||||
expect(boundary).toBeGreaterThan(nowMs);
|
||||
// Unchanged up to the boundary, different from it on.
|
||||
expect(badgeAt(expiresAt, boundary - 1)).toBe(badgeAt(expiresAt, nowMs));
|
||||
expect(badgeAt(expiresAt, boundary)).not.toBe(badgeAt(expiresAt, nowMs));
|
||||
});
|
||||
|
||||
it('never schedules for facts whose badge cannot change any more', () => {
|
||||
const nowSeconds = nowMs / 1000;
|
||||
expect(nextSourceExpiryChangeMs(null, nowMs)).toBeNull();
|
||||
expect(
|
||||
nextSourceExpiryChangeMs(
|
||||
{ expiresAtSeconds: null, reportedExpired: true },
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
nextSourceExpiryChangeMs(
|
||||
{ expiresAtSeconds: nowSeconds - 1, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
nextSourceExpiryChangeMs(
|
||||
{ expiresAtSeconds: 0, reportedExpired: false },
|
||||
nowMs
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardSourceExpiryService', () => {
|
||||
let service: DashboardSourceExpiryService;
|
||||
let portalStatusService: { checkPortalStatusDetails: jest.Mock };
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
export const SOURCE_EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
/**
|
||||
* How often badge consumers should re-evaluate {@link resolveSourceExpiryBadge}
|
||||
* against the wall clock. The badge state only moves at day granularity, but a
|
||||
* dashboard left open must still cross day-countdown and expiration
|
||||
* boundaries without a remount; a minute tick is imperceptibly cheap.
|
||||
* Longest wait before badge consumers re-check the wall clock even when no
|
||||
* badge boundary is due. A timer does not track wall-clock jumps (system
|
||||
* sleep, a changed clock), so it is re-armed at least this often.
|
||||
*/
|
||||
export const SOURCE_EXPIRY_TICK_MS = 60_000;
|
||||
export const SOURCE_EXPIRY_MAX_WAIT_MS = 60 * 60_000;
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
@@ -55,3 +54,35 @@ export function resolveSourceExpiryBadge(
|
||||
const daysLeft = Math.ceil(secondsLeft / SECONDS_PER_DAY);
|
||||
return daysLeft <= warningDays ? { kind: 'expiring', daysLeft } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next instant (ms) at which {@link resolveSourceExpiryBadge} would answer
|
||||
* differently for these facts, or null when no boundary lies ahead: a
|
||||
* portal-reported expiry, or a timestamp already in the past (for as long as
|
||||
* the system clock only moves forward). The badge moves only at day
|
||||
* granularity: it appears `warningDays` days before expiry, counts down once
|
||||
* per day and turns into "expired" at expiry, so a consumer can wait for that
|
||||
* boundary instead of polling the clock.
|
||||
*/
|
||||
export function nextSourceExpiryChangeMs(
|
||||
facts: SourceExpiryFacts | null | undefined,
|
||||
nowMs: number,
|
||||
warningDays: number = SOURCE_EXPIRY_WARNING_DAYS
|
||||
): number | null {
|
||||
if (!facts || facts.reportedExpired) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = facts.expiresAtSeconds;
|
||||
if (expiresAt === null || expiresAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
const secondsLeft = expiresAt - nowMs / 1000;
|
||||
if (secondsLeft <= 0) {
|
||||
return null;
|
||||
}
|
||||
const daysLeft = Math.ceil(secondsLeft / SECONDS_PER_DAY);
|
||||
// The count drops by one each time another whole day has passed; above
|
||||
// the warning window only the day the badge appears matters.
|
||||
const nextDaysLeft = Math.min(daysLeft, warningDays + 1) - 1;
|
||||
return (expiresAt - nextDaysLeft * SECONDS_PER_DAY) * 1000;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
|
||||
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
|
||||
|
||||
describe('DashboardLiveEpgClock', () => {
|
||||
let hidden: boolean;
|
||||
let clock: DashboardLiveEpgClock;
|
||||
|
||||
const setHidden = (value: boolean) => {
|
||||
hidden = value;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
TestBed.tick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-05-23T10:00:00.000Z'));
|
||||
hidden = false;
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => hidden,
|
||||
});
|
||||
TestBed.configureTestingModule({ providers: [DashboardLiveEpgClock] });
|
||||
clock = TestBed.inject(DashboardLiveEpgClock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
delete (document as { hidden?: boolean }).hidden;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('schedules nothing while no consumer has a live card', () => {
|
||||
const active = signal(false);
|
||||
clock.demand(active);
|
||||
TestBed.tick();
|
||||
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('ticks on the live-EPG period while demanded and stops when the demand ends', () => {
|
||||
const active = signal(true);
|
||||
clock.demand(active);
|
||||
TestBed.tick();
|
||||
const started = clock.now();
|
||||
|
||||
jest.advanceTimersByTime(LIVE_EPG_TICK_MS);
|
||||
expect(clock.now()).toBe(started + LIVE_EPG_TICK_MS);
|
||||
|
||||
active.set(false);
|
||||
TestBed.tick();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('pauses while the document is hidden and catches up as it returns', () => {
|
||||
clock.demand(signal(true));
|
||||
TestBed.tick();
|
||||
|
||||
setHidden(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
jest.advanceTimersByTime(5 * 60_000);
|
||||
setHidden(false);
|
||||
|
||||
expect(clock.now()).toBe(Date.now());
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
|
||||
|
||||
/**
|
||||
* The dashboard's live-EPG wall clock: one heartbeat shared by the XMLTV
|
||||
* batch, the portal queue and every progress bar on the page.
|
||||
*
|
||||
* It only runs while a consumer has a live card to keep current and while
|
||||
* the document is visible. A dashboard without live cards, or one behind a
|
||||
* hidden tab or window, therefore schedules nothing. Starting again reads
|
||||
* the clock at once, so progress and "now on air" catch up the moment the
|
||||
* page is back in view instead of up to one period later.
|
||||
*
|
||||
* Component-provided, so the heartbeat dies with the dashboard.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardLiveEpgClock {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly demands = signal<readonly Signal<boolean>[]>([]);
|
||||
private readonly visible = signal(!this.document.hidden);
|
||||
private readonly nowMs = signal(Date.now());
|
||||
private readonly running = computed(
|
||||
() => this.visible() && this.demands().some((demand) => demand())
|
||||
);
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Wall-clock ms of the latest tick. */
|
||||
readonly now: Signal<number> = this.nowMs.asReadonly();
|
||||
|
||||
constructor() {
|
||||
const onVisibilityChange = () =>
|
||||
this.visible.set(!this.document.hidden);
|
||||
this.document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
effect(() => {
|
||||
const running = this.running();
|
||||
untracked(() => (running ? this.start() : this.stop()));
|
||||
});
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
this.stop();
|
||||
this.document.removeEventListener(
|
||||
'visibilitychange',
|
||||
onVisibilityChange
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps the clock running for as long as `active` reads true. */
|
||||
demand(active: Signal<boolean>): void {
|
||||
this.demands.update((demands) => [...demands, active]);
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
if (this.timer !== null) return;
|
||||
this.nowMs.set(Date.now());
|
||||
this.timer = setInterval(
|
||||
() => this.nowMs.set(Date.now()),
|
||||
LIVE_EPG_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.timer === null) return;
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
+118
-3
@@ -1,6 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EMPTY, of, throwError } from 'rxjs';
|
||||
import { BehaviorSubject, EMPTY, of, throwError } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
@@ -12,6 +12,7 @@ import { SettingsStore } from '@iptvnator/services';
|
||||
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
|
||||
const guideA = 'https://a.example/guide.xml';
|
||||
@@ -41,6 +42,7 @@ const program = (title: string): EpgProgram =>
|
||||
describe('DashboardLiveEpgPresenter', () => {
|
||||
let presenter: DashboardLiveEpgPresenter;
|
||||
let getCurrentProgramsForChannels: jest.Mock;
|
||||
let epgAvailable: BehaviorSubject<boolean>;
|
||||
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let favoriteLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
@@ -68,6 +70,7 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
|
||||
getCurrentProgramsForChannels = jest.fn(() => of(new Map()));
|
||||
epgAvailable = new BehaviorSubject(false);
|
||||
playlists = signal<PlaylistMeta[]>([
|
||||
m3uPlaylist('a', [guideA]),
|
||||
m3uPlaylist('a2', [guideA]),
|
||||
@@ -89,6 +92,7 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardLiveEpgClock,
|
||||
DashboardLiveEpgPresenter,
|
||||
{
|
||||
provide: DashboardPortalLiveEpgPresenter,
|
||||
@@ -106,7 +110,10 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
},
|
||||
{
|
||||
provide: EpgService,
|
||||
useValue: { getCurrentProgramsForChannels },
|
||||
useValue: {
|
||||
getCurrentProgramsForChannels,
|
||||
epgAvailable$: epgAvailable,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
@@ -345,6 +352,108 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
/** One clock tick: the interval fires, then effects flush. */
|
||||
const tick = (count = 1) => {
|
||||
for (let index = 0; index < count; index++) {
|
||||
jest.advanceTimersByTime(30_000);
|
||||
TestBed.tick();
|
||||
}
|
||||
};
|
||||
|
||||
it('asks a guide again once a programme on air has ended', () => {
|
||||
const fromA = card({
|
||||
id: 'a',
|
||||
epgLookupKey: 'ard.de',
|
||||
epgPlaylistId: 'a',
|
||||
});
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(
|
||||
new Map([
|
||||
[
|
||||
'ard.de',
|
||||
{
|
||||
...program('Short'),
|
||||
stop: '2026-05-23T10:32:00.000Z',
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
);
|
||||
setup([fromA]);
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 10:30 → 10:31:30: still on air.
|
||||
tick(3);
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The 10:32 tick sees it ended and asks again.
|
||||
tick();
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('asks again at least every five minutes while a programme is on air', () => {
|
||||
const fromA = card({
|
||||
id: 'a',
|
||||
epgLookupKey: 'ard.de',
|
||||
epgPlaylistId: 'a',
|
||||
});
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(new Map([['ard.de', program('Tagesschau')]]))
|
||||
);
|
||||
setup([fromA]);
|
||||
|
||||
// 10:30 → 10:34:30: the 10:00–11:00 programme is on air and fresh.
|
||||
tick(9);
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 10:35: the answer is five minutes old; a guide may have changed.
|
||||
tick();
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('asks again at once when a guide import or source change lands', () => {
|
||||
const fromA = card({
|
||||
id: 'a',
|
||||
epgLookupKey: 'ard.de',
|
||||
epgPlaylistId: 'a',
|
||||
});
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(new Map([['ard.de', program('Tagesschau')]]))
|
||||
);
|
||||
setup([fromA]);
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1);
|
||||
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(new Map([['ard.de', program('Corrected')]]))
|
||||
);
|
||||
epgAvailable.next(true);
|
||||
TestBed.tick();
|
||||
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2);
|
||||
expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe('Corrected');
|
||||
});
|
||||
|
||||
it('moves progress on every clock tick while the programme is unchanged', () => {
|
||||
const fromA = card({
|
||||
id: 'a',
|
||||
epgLookupKey: 'ard.de',
|
||||
epgPlaylistId: 'a',
|
||||
});
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(new Map([['ard.de', program('Tagesschau')]]))
|
||||
);
|
||||
setup([fromA]);
|
||||
const progress = TestBed.runInInjectionContext(() =>
|
||||
computed(() => presenter.detailsFor(fromA)?.nowPlayingProgress)
|
||||
);
|
||||
expect(progress()).toBe(50);
|
||||
|
||||
jest.advanceTimersByTime(6 * 60_000);
|
||||
TestBed.tick();
|
||||
|
||||
expect(progress()).toBe(60);
|
||||
});
|
||||
|
||||
it('keeps the other guides when one lookup is retired or fails mid-tick', () => {
|
||||
const fromA = card({
|
||||
id: 'a',
|
||||
@@ -384,7 +493,13 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
)
|
||||
: throwError(() => new Error('lookup failed'))
|
||||
);
|
||||
const callsBeforeTick = getCurrentProgramsForChannels.mock.calls.length;
|
||||
jest.advanceTimersByTime(30_000);
|
||||
TestBed.tick();
|
||||
// Guide B never answered, so the tick asks both guides again.
|
||||
expect(getCurrentProgramsForChannels.mock.calls.length).toBe(
|
||||
callsBeforeTick + 2
|
||||
);
|
||||
|
||||
expect(presenter.detailsFor(fromA)?.nowPlayingTitle).toBe(
|
||||
'Guide A bulletin'
|
||||
|
||||
@@ -10,16 +10,21 @@ import {
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
defaultIfEmpty,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
forkJoin,
|
||||
interval,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
startWith,
|
||||
skip,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
epgProviderClockMs,
|
||||
normalizeDashboardRailsSettings,
|
||||
type EpgProgram,
|
||||
type PortalActivityItem,
|
||||
@@ -31,15 +36,18 @@ import {
|
||||
DashboardDataService,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
import {
|
||||
buildDashboardLiveEpgDetails,
|
||||
buildLiveEpgLookupGroups,
|
||||
getLiveEpgProgramForCard,
|
||||
liveEpgAllowsAnySource,
|
||||
liveEpgAnswersNeedRefresh,
|
||||
liveEpgProgramKey,
|
||||
liveEpgScopeKey,
|
||||
LIVE_EPG_TICK_MS,
|
||||
LIVE_EPG_MAX_ANSWER_AGE_MS,
|
||||
sameLiveEpgAnswers,
|
||||
type DashboardLiveEpgDetails,
|
||||
type DashboardLiveEpgLookupGroup,
|
||||
} from './dashboard-live-epg.utils';
|
||||
@@ -88,6 +96,7 @@ export class DashboardLiveEpgPresenter {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
/** Xtream/Stalker cards are answered by their portal, not by XMLTV. */
|
||||
private readonly portal = inject(DashboardPortalLiveEpgPresenter);
|
||||
private readonly clock = inject(DashboardLiveEpgClock);
|
||||
|
||||
private readonly cards = signal<Signal<
|
||||
readonly DashboardRailCard[]
|
||||
@@ -140,22 +149,61 @@ export class DashboardLiveEpgPresenter {
|
||||
)
|
||||
);
|
||||
|
||||
// Re-fetch on rail change AND on a 30s heartbeat so the progress bar
|
||||
// catches the boundary between programs without a full page revisit.
|
||||
private readonly offsetMinutes = computed(() =>
|
||||
this.settingsStore.resolvedEpgOffsetMinutes()
|
||||
);
|
||||
/** Created once: `toObservable` owns an effect for the injector's life. */
|
||||
private readonly now$ = toObservable(this.clock.now);
|
||||
|
||||
/** A guide import or source change can replace a programme on air. */
|
||||
private readonly guideChanged$ = this.epgService.epgAvailable$.pipe(
|
||||
skip(1),
|
||||
filter(Boolean),
|
||||
map(() => Date.now())
|
||||
);
|
||||
|
||||
// Asked on rail or offset change and whenever the guide changes. On
|
||||
// clock ticks it is asked again only once an answer can be stale: a
|
||||
// programme ended, a key is still without one, or the answer is older
|
||||
// than LIVE_EPG_MAX_ANSWER_AGE_MS. An unchanged answer is not
|
||||
// re-emitted, so the rails rebuild on a tick only for the progress bars.
|
||||
private readonly programs = toSignal(
|
||||
toObservable(this.lookupGroups).pipe(
|
||||
switchMap((groups) =>
|
||||
groups.length === 0
|
||||
? of(new Map<string, EpgProgram | null>())
|
||||
: interval(LIVE_EPG_TICK_MS).pipe(
|
||||
startWith(0),
|
||||
switchMap(() =>
|
||||
forkJoin(
|
||||
groups.map((group) => this.askScope(group))
|
||||
).pipe(map((answers) => mergeAnswers(answers)))
|
||||
)
|
||||
)
|
||||
)
|
||||
combineLatest([
|
||||
toObservable(this.lookupGroups),
|
||||
toObservable(this.offsetMinutes),
|
||||
]).pipe(
|
||||
switchMap(([groups, offsetMinutes]) => {
|
||||
if (groups.length === 0) {
|
||||
return of(new Map<string, EpgProgram | null>());
|
||||
}
|
||||
let answers: ReadonlyMap<string, EpgProgram | null> | null =
|
||||
null;
|
||||
let answeredAt = 0;
|
||||
return merge(
|
||||
this.now$,
|
||||
this.guideChanged$.pipe(tap(() => (answers = null)))
|
||||
).pipe(
|
||||
filter(
|
||||
(nowMs) =>
|
||||
nowMs - answeredAt >= LIVE_EPG_MAX_ANSWER_AGE_MS ||
|
||||
liveEpgAnswersNeedRefresh(
|
||||
answers,
|
||||
groups,
|
||||
epgProviderClockMs(nowMs, offsetMinutes)
|
||||
)
|
||||
),
|
||||
switchMap(() =>
|
||||
forkJoin(
|
||||
groups.map((group) => this.askScope(group))
|
||||
).pipe(map((scopes) => mergeAnswers(scopes)))
|
||||
),
|
||||
tap((merged) => {
|
||||
answers = merged;
|
||||
answeredAt = Date.now();
|
||||
}),
|
||||
distinctUntilChanged(sameLiveEpgAnswers)
|
||||
);
|
||||
})
|
||||
),
|
||||
{ initialValue: new Map<string, EpgProgram | null>() }
|
||||
);
|
||||
@@ -183,6 +231,7 @@ export class DashboardLiveEpgPresenter {
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.clock.demand(computed(() => this.lookupGroups().length > 0));
|
||||
this.portal.connect(this.portalItems);
|
||||
// The hero sits at the top of the page and is never scrolled into
|
||||
// view, so its candidates are wanted regardless of what the rails
|
||||
@@ -246,8 +295,9 @@ export class DashboardLiveEpgPresenter {
|
||||
liveEpgAllowsAnySource(card)
|
||||
)
|
||||
);
|
||||
// Recompute the now-window each tick so progress moves between
|
||||
// 30s ticks even if the program identity is unchanged.
|
||||
// Read the clock so progress moves on every tick even while the
|
||||
// programme itself is unchanged.
|
||||
this.clock.now();
|
||||
return buildDashboardLiveEpgDetails(
|
||||
program,
|
||||
Date.now(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildDashboardLiveEpgDetails,
|
||||
calcEpgProgress,
|
||||
formatEpgTimeRange,
|
||||
sameLiveEpgAnswers,
|
||||
} from './dashboard-live-epg.utils';
|
||||
|
||||
// Local-time wall clock so the formatted range is stable in every timezone
|
||||
@@ -143,4 +144,35 @@ describe('dashboard-live-epg.utils', () => {
|
||||
expect(buildDashboardLiveEpgDetails(row, MIDPOINT_MS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameLiveEpgAnswers', () => {
|
||||
const answers = (row: EpgProgram | null) =>
|
||||
new Map<string, EpgProgram | null>([['key', row]]);
|
||||
|
||||
it('treats fresh objects with the same rendered fields as unchanged', () => {
|
||||
expect(
|
||||
sameLiveEpgAnswers(answers(program()), answers(program()))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['title', { title: 'Late News' }],
|
||||
['description', { desc: 'Corrected summary' }],
|
||||
['category', { category: 'Sport' }],
|
||||
['stop', { stop: new Date(2026, 0, 15, 21, 30).toISOString() }],
|
||||
])('sees a guide correction to the %s', (_, change) => {
|
||||
expect(
|
||||
sameLiveEpgAnswers(
|
||||
answers(program()),
|
||||
answers(program(change as Partial<EpgProgram>))
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('sees a programme appear or disappear', () => {
|
||||
expect(sameLiveEpgAnswers(answers(null), answers(program()))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,11 @@ import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
// the SQLite backend with a batched IPC every animation frame.
|
||||
export const LIVE_EPG_TICK_MS = 30_000;
|
||||
|
||||
// A programme still on air is asked for again at least this often: a guide
|
||||
// refreshed outside this page's view can correct or replace it, and nothing
|
||||
// else tells the dashboard.
|
||||
export const LIVE_EPG_MAX_ANSWER_AGE_MS = 5 * 60_000;
|
||||
|
||||
// Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp`
|
||||
// when present. The parsed XMLTV pipeline populates both, but legacy rows
|
||||
// only carry the strings. `startTimestamp`/`stopTimestamp` are unix SECONDS
|
||||
@@ -250,3 +255,60 @@ export function getLiveEpgProgramForCard(
|
||||
? (epgMap.get(liveEpgProgramKey(scopeKey, titleKey)) ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the XMLTV answers for these groups can have gone stale by
|
||||
* `providerClockMs` (the raw EPG clock, see `epgProviderClockMs`). A known
|
||||
* programme stays correct until it ends, so a lookup is only repeated once
|
||||
* one of them has ended, or while a key is still unanswered or answered
|
||||
* with nothing on air: a guide imported meanwhile may know it now.
|
||||
*/
|
||||
export function liveEpgAnswersNeedRefresh(
|
||||
answers: ReadonlyMap<string, EpgProgram | null> | null,
|
||||
groups: readonly DashboardLiveEpgLookupGroup[],
|
||||
providerClockMs: number
|
||||
): boolean {
|
||||
if (!answers) return true;
|
||||
for (const group of groups) {
|
||||
for (const lookupKey of group.lookupKeys) {
|
||||
const program = answers.get(
|
||||
liveEpgProgramKey(group.scopeKey, lookupKey)
|
||||
);
|
||||
if (!program) return true;
|
||||
const stop = epgTimestampMs(program, 'stop');
|
||||
if (stop === null || stop <= providerClockMs) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same keys answered with the same programmes: everything the dashboard
|
||||
* renders from one (title, times, description, category) must match, or a
|
||||
* guide correction to any of them would be dropped as "unchanged".
|
||||
*/
|
||||
export function sameLiveEpgAnswers(
|
||||
a: ReadonlyMap<string, EpgProgram | null>,
|
||||
b: ReadonlyMap<string, EpgProgram | null>
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [key, program] of a) {
|
||||
if (!b.has(key)) return false;
|
||||
const other = b.get(key) ?? null;
|
||||
if (program === other) continue;
|
||||
if (
|
||||
!program ||
|
||||
!other ||
|
||||
program.title !== other.title ||
|
||||
program.desc !== other.desc ||
|
||||
program.category !== other.category ||
|
||||
epgTimestampMs(program, 'start') !==
|
||||
epgTimestampMs(other, 'start') ||
|
||||
epgTimestampMs(program, 'stop') !== epgTimestampMs(other, 'stop')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+19
@@ -9,6 +9,7 @@ import {
|
||||
DashboardPortalLiveEpgService,
|
||||
type DashboardPortalLiveEpgEntry,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
|
||||
const xtreamLive = (id: number, playlist = 'p'): PortalActivityItem =>
|
||||
@@ -44,6 +45,7 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
||||
offsetMinutes = signal(0);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardLiveEpgClock,
|
||||
DashboardPortalLiveEpgPresenter,
|
||||
{
|
||||
provide: DashboardPortalLiveEpgService,
|
||||
@@ -101,6 +103,23 @@ describe('DashboardPortalLiveEpgPresenter', () => {
|
||||
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1', 'xtream::p::3']);
|
||||
});
|
||||
|
||||
it('keeps the live-EPG clock stopped while no portal card is wanted', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
presenter.connect(signal([xtreamLive(1)]));
|
||||
TestBed.tick();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
presenter.setVisibleCards('recent', [
|
||||
{ id: 'r1', liveEpgSourceKey: 'xtream::p::1' },
|
||||
] as never);
|
||||
TestBed.tick();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores visible keys whose item is no longer on the dashboard', () => {
|
||||
const items = signal<readonly PortalActivityItem[]>([xtreamLive(1)]);
|
||||
presenter.connect(items);
|
||||
|
||||
+8
-14
@@ -8,8 +8,6 @@ import {
|
||||
untracked,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, map } from 'rxjs';
|
||||
import type {
|
||||
EpgProgram,
|
||||
PortalActivityItem,
|
||||
@@ -21,7 +19,7 @@ import {
|
||||
type DashboardPortalLiveEpgEntry,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
|
||||
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
|
||||
|
||||
/**
|
||||
* The rails component's view of `DashboardPortalLiveEpgService`: which
|
||||
@@ -30,14 +28,16 @@ import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
|
||||
*
|
||||
* "On screen" is what the rails report through their visibility output,
|
||||
* plus the pinned keys (the hero card, always at the top). A card the user
|
||||
* never scrolls to is never asked for. The 30 s tick re-syncs so a programme
|
||||
* that ended is asked again and progress bars keep moving; a changed display
|
||||
* offset re-syncs at once because every cached answer was just retired.
|
||||
* never scrolls to is never asked for. The shared live-EPG clock re-syncs so
|
||||
* a programme that ended is asked again; it only runs while some card is
|
||||
* wanted. A changed display offset re-syncs at once because every cached
|
||||
* answer was just retired.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardPortalLiveEpgPresenter {
|
||||
private readonly service = inject(DashboardPortalLiveEpgService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly clock = inject(DashboardLiveEpgClock);
|
||||
|
||||
private readonly source = signal<Signal<
|
||||
readonly PortalActivityItem[]
|
||||
@@ -47,13 +47,6 @@ export class DashboardPortalLiveEpgPresenter {
|
||||
>(new Map());
|
||||
private readonly pinnedKeys = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** Heartbeat shared with the XMLTV batch: shifted by one so the first
|
||||
* emission differs from `initialValue` and is not swallowed. */
|
||||
readonly tick = toSignal(
|
||||
interval(LIVE_EPG_TICK_MS).pipe(map((tick) => tick + 1)),
|
||||
{ initialValue: 0 }
|
||||
);
|
||||
|
||||
private readonly entries = computed<
|
||||
ReadonlyMap<string, DashboardPortalLiveEpgEntry>
|
||||
>(() => {
|
||||
@@ -83,9 +76,10 @@ export class DashboardPortalLiveEpgPresenter {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.clock.demand(computed(() => this.wanted().length > 0));
|
||||
effect(() => {
|
||||
const wanted = this.wanted();
|
||||
this.tick();
|
||||
this.clock.now();
|
||||
this.settingsStore.resolvedEpgOffsetMinutes();
|
||||
untracked(() => this.service.sync(wanted));
|
||||
});
|
||||
|
||||
@@ -335,6 +335,46 @@ describe('DashboardRailComponent', () => {
|
||||
expect(visible.at(-1)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('scrolls back and re-observes only when the card set changes, not on every rebuild', async () => {
|
||||
installObservers(true);
|
||||
const { fixture } = await render([
|
||||
card({ id: 'a', contentType: 'live', nowPlayingProgress: 10 }),
|
||||
card({ id: 'b', contentType: 'live', nowPlayingProgress: 20 }),
|
||||
]);
|
||||
const reset = jest.spyOn(
|
||||
fixture.componentInstance as unknown as {
|
||||
scheduleResetToStart: () => void;
|
||||
},
|
||||
'scheduleResetToStart'
|
||||
);
|
||||
const observedAfterFirstRender = observers[0].observed.length;
|
||||
const rerender = async (items: DashboardRailCard[]) => {
|
||||
fixture.componentRef.setInput('items', items);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
};
|
||||
|
||||
// A clock tick rebuilds every card object with new progress.
|
||||
await rerender([
|
||||
card({ id: 'a', contentType: 'live', nowPlayingProgress: 11 }),
|
||||
card({ id: 'b', contentType: 'live', nowPlayingProgress: 21 }),
|
||||
]);
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
expect(observers[0].observed).toHaveLength(
|
||||
observedAfterFirstRender
|
||||
);
|
||||
|
||||
// A newly watched channel moves to the front.
|
||||
await rerender([
|
||||
card({ id: 'b', contentType: 'live' }),
|
||||
card({ id: 'a', contentType: 'live' }),
|
||||
]);
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(observers[0].observed.length).toBeGreaterThan(
|
||||
observedAfterFirstRender
|
||||
);
|
||||
});
|
||||
|
||||
it('positions the live progress fill through a custom property, never its width', async () => {
|
||||
// Animating width re-lays out the page for every frame of the
|
||||
// transition on each EPG tick; the stylesheet slides the fill with
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
effect,
|
||||
@@ -172,18 +173,28 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
private resetFrameId: number | null = null;
|
||||
private settleFrameId: number | null = null;
|
||||
|
||||
/**
|
||||
* Which cards the rail shows, in order. Hosts rebuild their card objects
|
||||
* on every clock tick (live progress, expiry badges); only a change of
|
||||
* this identity is a new rail worth scrolling back to the start for or
|
||||
* re-observing.
|
||||
*/
|
||||
private readonly cardIds = computed(() =>
|
||||
JSON.stringify(this.items().map((card) => card.id))
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.items();
|
||||
this.cardIds();
|
||||
if (!this.viewReady()) return;
|
||||
this.scheduleResetToStart();
|
||||
});
|
||||
// The rendered card set changed: watch the new elements. Reading
|
||||
// `items()` too keeps an id-only change (same elements, new cards)
|
||||
// from leaving a stale visible set behind.
|
||||
// The rendered card set changed: watch the new elements. Reading the
|
||||
// ids too keeps an id-only change (same elements, new cards) from
|
||||
// leaving a stale visible set behind.
|
||||
effect(() => {
|
||||
const elements = this.cardElements();
|
||||
this.items();
|
||||
this.cardIds();
|
||||
untracked(() => this.observeCards(elements));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { signal, type Signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
SOURCE_EXPIRY_MAX_WAIT_MS,
|
||||
type SourceExpiryFacts,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { createSourceExpiryClock } from './dashboard-source-expiry-clock';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
describe('createSourceExpiryClock', () => {
|
||||
const nowMs = Date.UTC(2026, 7, 1, 12, 0, 0);
|
||||
let facts: ReturnType<
|
||||
typeof signal<ReadonlyMap<string, SourceExpiryFacts>>
|
||||
>;
|
||||
let clock: Signal<number>;
|
||||
let active: ReturnType<typeof signal<boolean>>;
|
||||
|
||||
const expiringIn = (ms: number): SourceExpiryFacts => ({
|
||||
expiresAtSeconds: (nowMs + ms) / 1000,
|
||||
reportedExpired: false,
|
||||
});
|
||||
|
||||
let hidden: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(nowMs);
|
||||
hidden = false;
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => hidden,
|
||||
});
|
||||
facts = signal<ReadonlyMap<string, SourceExpiryFacts>>(new Map());
|
||||
active = signal(true);
|
||||
clock = TestBed.runInInjectionContext(() =>
|
||||
createSourceExpiryClock(facts, active)
|
||||
);
|
||||
TestBed.tick();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
delete (document as { hidden?: boolean }).hidden;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('arms no timer when no badge can change', () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
facts.set(
|
||||
new Map([
|
||||
['m3u', { expiresAtSeconds: null, reportedExpired: false }],
|
||||
['gone', { expiresAtSeconds: null, reportedExpired: true }],
|
||||
])
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('moves at the next badge boundary instead of every minute', () => {
|
||||
// 2 days and 20 minutes left: the countdown drops to "2 days" in 20
|
||||
// minutes, and nothing needs the clock before that.
|
||||
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 20 * 60_000)]]));
|
||||
TestBed.tick();
|
||||
const before = clock();
|
||||
|
||||
jest.advanceTimersByTime(19 * 60_000);
|
||||
TestBed.tick();
|
||||
expect(clock()).toBe(before);
|
||||
|
||||
jest.advanceTimersByTime(2 * 60_000);
|
||||
TestBed.tick();
|
||||
expect(clock()).toBe(before + 20 * 60_000 + 1_000);
|
||||
});
|
||||
|
||||
it('re-reads the clock at least hourly while a boundary is far away', () => {
|
||||
facts.set(new Map([['xtream', expiringIn(30 * DAY_MS)]]));
|
||||
TestBed.tick();
|
||||
const before = clock();
|
||||
|
||||
jest.advanceTimersByTime(SOURCE_EXPIRY_MAX_WAIT_MS);
|
||||
TestBed.tick();
|
||||
|
||||
expect(clock()).toBe(before + SOURCE_EXPIRY_MAX_WAIT_MS);
|
||||
});
|
||||
|
||||
it('schedules from the real time when facts arrive long after the last tick', () => {
|
||||
// No facts yet, so no timer: the clock stays at its start value.
|
||||
jest.advanceTimersByTime(50 * 60_000);
|
||||
const before = clock();
|
||||
|
||||
// The boundary is 5 minutes from the real time, 55 from the clock.
|
||||
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 55 * 60_000)]]));
|
||||
TestBed.tick();
|
||||
|
||||
jest.advanceTimersByTime(6 * 60_000);
|
||||
TestBed.tick();
|
||||
expect(clock()).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('arms no timer while hidden and catches up as the page returns', () => {
|
||||
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 20 * 60_000)]]));
|
||||
hidden = true;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
TestBed.tick();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
jest.advanceTimersByTime(3 * 60 * 60_000);
|
||||
hidden = false;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
TestBed.tick();
|
||||
|
||||
expect(clock()).toBe(Date.now());
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('arms no timer while the sources rail is disabled', () => {
|
||||
active.set(false);
|
||||
facts.set(new Map([['xtream', expiringIn(2 * DAY_MS + 20 * 60_000)]]));
|
||||
TestBed.tick();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
active.set(true);
|
||||
TestBed.tick();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('reads the clock at once when the sources rail is re-enabled', () => {
|
||||
active.set(false);
|
||||
TestBed.tick();
|
||||
// Past the expiry while the rail is off: no boundary is left to wait
|
||||
// for, so only the re-enable can refresh the cached badge.
|
||||
facts.set(new Map([['xtream', expiringIn(60_000)]]));
|
||||
TestBed.tick();
|
||||
jest.advanceTimersByTime(3 * 60_000);
|
||||
const before = clock();
|
||||
|
||||
active.set(true);
|
||||
TestBed.tick();
|
||||
|
||||
expect(clock()).toBe(Date.now());
|
||||
expect(clock()).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('keeps rechecking an expired timestamp hourly in case the clock is corrected', () => {
|
||||
facts.set(new Map([['xtream', expiringIn(-60_000)]]));
|
||||
TestBed.tick();
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
const before = clock();
|
||||
|
||||
jest.advanceTimersByTime(SOURCE_EXPIRY_MAX_WAIT_MS);
|
||||
TestBed.tick();
|
||||
expect(clock()).toBe(before + SOURCE_EXPIRY_MAX_WAIT_MS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
nextSourceExpiryChangeMs,
|
||||
SOURCE_EXPIRY_MAX_WAIT_MS,
|
||||
type SourceExpiryFacts,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
|
||||
/** A boundary timer can fire a hair early; land safely past it. */
|
||||
const BOUNDARY_SLACK_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Wall-clock ms that moves only when a source-expiry badge can change.
|
||||
*
|
||||
* Badges move at day granularity, so instead of polling the clock this arms
|
||||
* one timer for the earliest badge boundary among the known facts (none at
|
||||
* all when no badge can change; an hourly recheck for a timestamp that has
|
||||
* already passed, in case the system clock is corrected backward), capped at {@link SOURCE_EXPIRY_MAX_WAIT_MS}
|
||||
* because timers do not follow system sleep or clock changes. No timer is
|
||||
* armed while the document is hidden or `active` is false; becoming visible
|
||||
* re-reads the clock at once. Must be created in
|
||||
* an injection context.
|
||||
*/
|
||||
export function createSourceExpiryClock(
|
||||
facts: Signal<ReadonlyMap<string, SourceExpiryFacts>>,
|
||||
/** False while no badge can render (the sources rail is disabled). */
|
||||
active: Signal<boolean> = signal(true)
|
||||
): Signal<number> {
|
||||
const document = inject(DOCUMENT);
|
||||
const now = signal(Date.now());
|
||||
const visible = signal(!document.hidden);
|
||||
|
||||
effect((onCleanup) => {
|
||||
// Read for the dependency; schedule from the real time, because the
|
||||
// facts can change long after the clock last moved.
|
||||
now();
|
||||
// Hidden, or the sources rail is off: no badge is on screen. Becoming
|
||||
// visible moves the clock and re-runs this effect; so does `active`.
|
||||
if (!visible() || !active()) return;
|
||||
const nowMs = Date.now();
|
||||
let next: number | null = null;
|
||||
// A badge derived from a timestamp can still change if the system
|
||||
// clock is corrected backward, so it keeps the hourly recheck even
|
||||
// with no boundary ahead. A portal-reported expiry is terminal.
|
||||
let clockDependent = false;
|
||||
for (const entry of facts().values()) {
|
||||
if (!entry.reportedExpired && (entry.expiresAtSeconds ?? 0) > 0) {
|
||||
clockDependent = true;
|
||||
}
|
||||
const change = nextSourceExpiryChangeMs(entry, nowMs);
|
||||
if (change !== null && (next === null || change < next)) {
|
||||
next = change;
|
||||
}
|
||||
}
|
||||
// No badge can change any more: new facts re-run this effect.
|
||||
if (next === null && !clockDependent) return;
|
||||
const delay =
|
||||
next === null
|
||||
? SOURCE_EXPIRY_MAX_WAIT_MS
|
||||
: Math.min(
|
||||
Math.max(next - nowMs, 0) + BOUNDARY_SLACK_MS,
|
||||
SOURCE_EXPIRY_MAX_WAIT_MS
|
||||
);
|
||||
const timer = setTimeout(() => now.set(Date.now()), delay);
|
||||
onCleanup(() => clearTimeout(timer));
|
||||
});
|
||||
|
||||
// Re-enabling the rail must not show a badge cached while it was off: a
|
||||
// boundary may have passed (an expired source has no next boundary to
|
||||
// wait for), so read the clock at once, as becoming visible does.
|
||||
let wasActive = untracked(active);
|
||||
effect(() => {
|
||||
const isActive = active();
|
||||
if (isActive && !wasActive) untracked(() => now.set(Date.now()));
|
||||
wasActive = isActive;
|
||||
});
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
visible.set(!document.hidden);
|
||||
if (!document.hidden) now.set(Date.now());
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
inject(DestroyRef).onDestroy(() =>
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
);
|
||||
|
||||
return now.asReadonly();
|
||||
}
|
||||
+18
-15
@@ -7,7 +7,7 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, map, startWith } from 'rxjs';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
DashboardTrendingService,
|
||||
GlobalRecentItem,
|
||||
resolveSourceExpiryBadge,
|
||||
SOURCE_EXPIRY_TICK_MS,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { createDashboardRailSkeletons } from './dashboard-rail-skeletons';
|
||||
import { DashboardRailComponent } from './dashboard-rail.component';
|
||||
@@ -61,6 +60,8 @@ import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.pre
|
||||
import { DashboardHeroComponent } from './dashboard-hero.component';
|
||||
import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import { DashboardLiveEpgClock } from './dashboard-live-epg-clock';
|
||||
import { createSourceExpiryClock } from './dashboard-source-expiry-clock';
|
||||
import {
|
||||
buildDashboardEpisodeBadge,
|
||||
buildPlaybackPositionReloadKey,
|
||||
@@ -99,7 +100,11 @@ import type {
|
||||
host: {
|
||||
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
|
||||
},
|
||||
providers: [DashboardLiveEpgPresenter, DashboardPortalLiveEpgPresenter],
|
||||
providers: [
|
||||
DashboardLiveEpgClock,
|
||||
DashboardLiveEpgPresenter,
|
||||
DashboardPortalLiveEpgPresenter,
|
||||
],
|
||||
})
|
||||
export class WorkspaceDashboardRailsComponent {
|
||||
readonly data = inject(DashboardDataService);
|
||||
@@ -260,14 +265,12 @@ export class WorkspaceDashboardRailsComponent {
|
||||
: this.t('WORKSPACE.DASHBOARD.TMDB_RECOMMENDED');
|
||||
});
|
||||
|
||||
// Minute heartbeat for the expiry badges: resolveSourceExpiryBadge reads
|
||||
// the wall clock, so without a reactive tick a dashboard left open would
|
||||
// never cross a day-countdown or expiration boundary. interval() emits
|
||||
// 0 first — shifted by one so it differs from initialValue, otherwise
|
||||
// the signal's equality check would swallow the first tick.
|
||||
private readonly sourceExpiryTick = toSignal(
|
||||
interval(SOURCE_EXPIRY_TICK_MS).pipe(map((tick) => tick + 1)),
|
||||
{ initialValue: 0 }
|
||||
// resolveSourceExpiryBadge reads the wall clock, so a dashboard left
|
||||
// open needs a reactive clock to cross a day-countdown or expiration
|
||||
// boundary. It moves only at those boundaries, not on a polling tick.
|
||||
private readonly sourceExpiryNow = createSourceExpiryClock(
|
||||
this.sourceExpiry.facts,
|
||||
computed(() => this.dashboardRails().recentSources)
|
||||
);
|
||||
|
||||
readonly sourceCards = computed<DashboardRailCard[]>(() => {
|
||||
@@ -611,12 +614,12 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private buildSourceExpiryBadge(
|
||||
playlistId: string
|
||||
): DashboardRailCard['expiryBadge'] {
|
||||
// Reactive read: ties the wall-clock evaluation below to the minute
|
||||
// tick (this method only runs inside the sourceCards computed).
|
||||
this.sourceExpiryTick();
|
||||
// Reactive read: ties the evaluation below to the expiry clock (this
|
||||
// method only runs inside the sourceCards computed). Date.now() keeps
|
||||
// a recompute for any other reason on the real time.
|
||||
const badge = resolveSourceExpiryBadge(
|
||||
this.sourceExpiry.facts().get(playlistId),
|
||||
Date.now()
|
||||
Math.max(this.sourceExpiryNow(), Date.now())
|
||||
);
|
||||
if (!badge) {
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user