From 4e512c1fd9ea03524580b02b50b28ff1d04d5e1a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 08:41:10 +0200 Subject: [PATCH] perf(dashboard): stop idle dashboard ticks that have nothing to update Three timers kept an idle dashboard busy (plan D3 audit): - The portal live-EPG presenter ran a 30 s interval even with no portal card wanted, so every dashboard paid a full change-detection pass per tick. A component-provided DashboardLiveEpgClock now drives both live-EPG presenters and every progress bar. It runs only while one of them has a live card and only while the document is visible, and reads the clock at once when it starts again. - The XMLTV presenter re-asked every scope and emitted a new Map on each tick. It now re-asks only after a programme on air has ended or while a key has no programme, and drops unchanged answers. Progress still moves on each tick because details read the clock signal. - The source-expiry badges polled the clock every minute and rebuilt the sources rail. createSourceExpiryClock arms one timer for the next day boundary (nextSourceExpiryChangeMs), capped at an hour, and none when no badge can change. The rail rebuilt its scroll position and IntersectionObserver targets on every new items array; it now does so only when card ids or order change, which also stops a user-scrolled rail from snapping back each tick. Co-Authored-By: Claude Opus 5.5 --- .changes/dashboard-idle-ticks.md | 8 ++ docs/architecture/workspace-dashboard.md | 20 ++++- .../src/lib/dashboard-source-expiry.spec.ts | 56 ++++++++++++++ .../src/lib/dashboard-source-expiry.util.ts | 39 ++++++++-- .../rails/dashboard-live-epg-clock.spec.ts | 69 +++++++++++++++++ .../src/lib/rails/dashboard-live-epg-clock.ts | 76 +++++++++++++++++++ .../dashboard-live-epg.presenter.spec.ts | 49 +++++++++++- .../lib/rails/dashboard-live-epg.presenter.ts | 71 ++++++++++++----- .../src/lib/rails/dashboard-live-epg.utils.ts | 50 ++++++++++++ ...ashboard-portal-live-epg.presenter.spec.ts | 19 +++++ .../dashboard-portal-live-epg.presenter.ts | 22 ++---- .../rails/dashboard-rail.component.spec.ts | 38 ++++++++++ .../src/lib/rails/dashboard-rail.component.ts | 21 +++-- .../dashboard-source-expiry-clock.spec.ts | 76 +++++++++++++++++++ .../rails/dashboard-source-expiry-clock.ts | 62 +++++++++++++++ .../workspace-dashboard-rails.component.ts | 32 ++++---- 16 files changed, 647 insertions(+), 61 deletions(-) create mode 100644 .changes/dashboard-idle-ticks.md create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts diff --git a/.changes/dashboard-idle-ticks.md b/.changes/dashboard-idle-ticks.md new file mode 100644 index 000000000..db2a9d151 --- /dev/null +++ b/.changes/dashboard-idle-ticks.md @@ -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. diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 9ee6a4444..ca6ff2ec7 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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,14 @@ 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 or + while a key has no programme, and 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 +226,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 +401,8 @@ 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, and re-reads the clock when the page becomes visible. +Facts whose badge can no longer change arm no timer. diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts index cd9f80fb7..b4d0ed280 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.spec.ts @@ -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 }; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts index 20a3e2969..bffe34d19 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.util.ts @@ -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,33 @@ 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 it never will again. 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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.spec.ts new file mode 100644 index 000000000..c9bd33bfb --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.spec.ts @@ -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); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.ts new file mode 100644 index 000000000..0e854101a --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg-clock.ts @@ -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[]>([]); + 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 | null = null; + + /** Wall-clock ms of the latest tick. */ + readonly now: Signal = 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): 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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts index 1296dc64a..f9c04bbce 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -1,4 +1,4 @@ -import { signal } from '@angular/core'; +import { computed, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { EMPTY, of, throwError } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; @@ -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'; @@ -89,6 +90,7 @@ describe('DashboardLiveEpgPresenter', () => { TestBed.configureTestingModule({ providers: [ + DashboardLiveEpgClock, DashboardLiveEpgPresenter, { provide: DashboardPortalLiveEpgPresenter, @@ -345,6 +347,45 @@ describe('DashboardLiveEpgPresenter', () => { ); }); + it('asks a guide again only 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('Tagesschau')]])) + ); + setup([fromA]); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1); + + // 10:30 → 10:59:30, one tick at a time: the 10:00–11:00 programme is + // still on air on every one of them. + for (let tick = 0; tick < 59; tick++) { + jest.advanceTimersByTime(30_000); + TestBed.tick(); + } + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(1); + + // The 11:00 tick sees it ended and asks again. + jest.advanceTimersByTime(30_000); + TestBed.tick(); + expect(getCurrentProgramsForChannels).toHaveBeenCalledTimes(2); + }); + + 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 +425,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' diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts index 019ba3e04..9470b916b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -10,16 +10,19 @@ import { import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { catchError, + combineLatest, defaultIfEmpty, + distinctUntilChanged, + filter, forkJoin, - interval, map, of, - startWith, switchMap, + tap, } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { + epgProviderClockMs, normalizeDashboardRailsSettings, type EpgProgram, type PortalActivityItem, @@ -31,15 +34,17 @@ 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, + sameLiveEpgAnswers, type DashboardLiveEpgDetails, type DashboardLiveEpgLookupGroup, } from './dashboard-live-epg.utils'; @@ -88,6 +93,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 + this.settingsStore.resolvedEpgOffsetMinutes() + ); + /** Created once: `toObservable` owns an effect for the injector's life. */ + private readonly now$ = toObservable(this.clock.now); + + // Asked on rail or offset change, then on clock ticks only once an + // answer can be stale: a programme ended, or a key is still without + // one. A programme that is still on air is not asked for again, and 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()) - : 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()); + } + let answers: ReadonlyMap | null = + null; + return this.now$.pipe( + filter((nowMs) => + liveEpgAnswersNeedRefresh( + answers, + groups, + epgProviderClockMs(nowMs, offsetMinutes) + ) + ), + switchMap(() => + forkJoin( + groups.map((group) => this.askScope(group)) + ).pipe(map((scopes) => mergeAnswers(scopes))) + ), + tap((merged) => (answers = merged)), + distinctUntilChanged(sameLiveEpgAnswers) + ); + }) ), { initialValue: new Map() } ); @@ -183,6 +212,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 +276,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(), diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts index d674e27a9..af10c32ae 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts @@ -250,3 +250,53 @@ 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 | 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 (by time and title). */ +export function sameLiveEpgAnswers( + a: ReadonlyMap, + b: ReadonlyMap +): 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 || + epgTimestampMs(program, 'start') !== epgTimestampMs(other, 'start') || + epgTimestampMs(program, 'stop') !== epgTimestampMs(other, 'stop') + ) { + return false; + } + } + return true; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts index 9ac14530e..8fd1f84b0 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.spec.ts @@ -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([xtreamLive(1)]); presenter.connect(items); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts index 8d883fc96..d9117a14d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts @@ -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(new Map()); private readonly pinnedKeys = signal>(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 >(() => { @@ -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)); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts index f5f748fdb..eaba4900c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -335,6 +335,44 @@ 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('shows the placeholder only while a live card is pending its first answer', async () => { installObservers(false); const { fixture } = await render([ diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 4620a472d..57ac2de5a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -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)); }); } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts new file mode 100644 index 000000000..653fbe5c1 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts @@ -0,0 +1,76 @@ +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>>; + let clock: Signal; + + const expiringIn = (ms: number): SourceExpiryFacts => ({ + expiresAtSeconds: (nowMs + ms) / 1000, + reportedExpired: false, + }); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(nowMs); + facts = signal>(new Map()); + clock = TestBed.runInInjectionContext(() => + createSourceExpiryClock(facts) + ); + TestBed.tick(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + 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); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts new file mode 100644 index 000000000..372be3e64 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts @@ -0,0 +1,62 @@ +import { + DestroyRef, + effect, + inject, + signal, + 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), capped at {@link SOURCE_EXPIRY_MAX_WAIT_MS} + * because timers do not follow system sleep or clock changes. Becoming + * visible re-reads the clock at once for the same reason. Must be created in + * an injection context. + */ +export function createSourceExpiryClock( + facts: Signal> +): Signal { + const document = inject(DOCUMENT); + const now = signal(Date.now()); + + effect((onCleanup) => { + const nowMs = now(); + let next: number | null = null; + for (const entry of facts().values()) { + 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) return; + const delay = 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)); + }); + + const onVisibilityChange = () => { + if (!document.hidden) now.set(Date.now()); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + inject(DestroyRef).onDestroy(() => + document.removeEventListener('visibilitychange', onVisibilityChange) + ); + + return now.asReadonly(); +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 12e4d2c7f..56bda9751 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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,11 @@ 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 ); readonly sourceCards = computed(() => { @@ -611,12 +613,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;