From 97f56e219c878ff31fe73b36a35932ec7521ea62 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:50:02 +0200 Subject: [PATCH] perf(dashboard): stop idle dashboard ticks that have nothing to update (#1722) --- .changes/dashboard-idle-ticks.md | 8 + docs/architecture/workspace-dashboard.md | 25 ++- .../src/lib/dashboard-source-expiry.spec.ts | 56 +++++++ .../src/lib/dashboard-source-expiry.util.ts | 41 ++++- .../rails/dashboard-live-epg-clock.spec.ts | 69 ++++++++ .../src/lib/rails/dashboard-live-epg-clock.ts | 76 +++++++++ .../dashboard-live-epg.presenter.spec.ts | 121 +++++++++++++- .../lib/rails/dashboard-live-epg.presenter.ts | 90 +++++++--- .../rails/dashboard-live-epg.utils.spec.ts | 32 ++++ .../src/lib/rails/dashboard-live-epg.utils.ts | 62 +++++++ ...ashboard-portal-live-epg.presenter.spec.ts | 19 +++ .../dashboard-portal-live-epg.presenter.ts | 22 +-- .../rails/dashboard-rail.component.spec.ts | 40 +++++ .../src/lib/rails/dashboard-rail.component.ts | 21 ++- .../dashboard-source-expiry-clock.spec.ts | 157 ++++++++++++++++++ .../rails/dashboard-source-expiry-clock.ts | 95 +++++++++++ .../workspace-dashboard-rails.component.ts | 33 ++-- 17 files changed, 904 insertions(+), 63 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..a749d9c77 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,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. 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..fc9507174 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,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; +} 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..2d9ccceba 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,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; let playlists: ReturnType>; let recentItems: ReturnType>; let favoriteLiveItems: ReturnType>; @@ -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([ 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' 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..93d553c73 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,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 + 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()) - : 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; + 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() } ); @@ -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(), diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts index 04e9d039b..d7c7ade5d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts @@ -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([['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)) + ) + ).toBe(false); + }); + + it('sees a programme appear or disappear', () => { + expect(sameLiveEpgAnswers(answers(null), answers(program()))).toBe( + false + ); + }); + }); }); 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..bff7d1390 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 @@ -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 | 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, + 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 || + program.desc !== other.desc || + program.category !== other.category || + 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 5d84cb49c..f7920bb90 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,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 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..813804927 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.spec.ts @@ -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> + >; + let clock: Signal; + let active: ReturnType>; + + 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>(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); + }); +}); 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..3a22d481e --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-source-expiry-clock.ts @@ -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>, + /** False while no badge can render (the sources rail is disabled). */ + active: Signal = signal(true) +): Signal { + 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(); +} 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..d9c15f75b 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,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(() => { @@ -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;