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;