From e6fd0d082552bcbc015398aa697681d6a40e6962 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 2 Jul 2026 00:50:41 +0200 Subject: [PATCH] fix(epg): address list-view review findings from Codex and Greptile - Reset the list view to today when a new channel's programme set arrives while the user is parked on another day (timeline parity): the scroll controller now keys by the full programme-set identity (programsFocusKey) and commits today before focusing, instead of silently stranding the new channel on the stale day. (Codex P2) - Centralise the 'timeline' fallback as a resolvedEpgViewMode computed on SettingsStore; the four live hosts consume the derived signal instead of duplicating the `?? 'timeline'` expression. (Greptile P2) - Extract the component's reactive plumbing into registerEpgListViewEffects(), bringing the component back under the 300-line guideline (290). (Greptile P2) - Controller spec rewritten around programme-set fixtures with new coverage: return-to-today on channel switch, day navigation left alone, no-takeover when today has no data, empty-set no-op. Co-Authored-By: Claude Opus 4.8 --- .../video-player.component.spec.ts | 2 +- .../video-player/video-player.component.ts | 4 +- .../unified-live-tab.component.spec.ts | 2 +- .../unified-live-tab.component.ts | 4 +- ...alker-live-stream-layout.component.spec.ts | 8 +- .../stalker-live-stream-layout.component.ts | 4 +- .../live-stream-layout.component.spec.ts | 8 +- .../live-stream-layout.component.ts | 4 +- .../src/lib/settings-store.service.ts | 14 +- .../epg-list-scroll.controller.spec.ts | 140 +++++++++++------- .../epg-list-scroll.controller.ts | 56 ++++--- .../epg-list-view/epg-list-view.component.ts | 38 ++--- .../epg-list-view/epg-list-view.effects.ts | 52 +++++++ 13 files changed, 210 insertions(+), 126 deletions(-) create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 0ebd8193d..5ddc02e79 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -389,7 +389,7 @@ describe('VideoPlayerComponent', () => { useValue: { player, showCaptions, - epgViewMode, + resolvedEpgViewMode: epgViewMode, }, }, { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 4e8543f92..e1dbf329b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -276,9 +276,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ); readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 980d763c4..84aa3776b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -189,7 +189,7 @@ describe('UnifiedLiveTabComponent', () => { useValue: { openStreamOnDoubleClick: signal(false), player, - epgViewMode, + resolvedEpgViewMode: epgViewMode, }, }, { provide: PORTAL_PLAYER, useValue: portalPlayer }, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index a582efa81..7c518374d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -194,9 +194,7 @@ export class UnifiedLiveTabComponent { () => this.liveEpgPanelState() === 'collapsed' ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly liveEpgPanelSummary = computed(() => { this.progressTick(); return this.getLiveEpgPanelSummary(this.activeDetail()); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 8b92f50d8..e32ac6a29 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -235,14 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { }; const settingsStore = { openStreamOnDoubleClick: signal(false), - epgViewMode: signal<'timeline' | 'list'>('timeline'), + resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'), }; const originalElectron = window.electron; beforeEach(async () => { // The store mock is module-scoped: reset so a failed test can't leak // 'list' into siblings. - settingsStore.epgViewMode.set('timeline'); + settingsStore.resolvedEpgViewMode.set('timeline'); window.electron = { platform: 'darwin', updateRemoteControlStatus: jest.fn(), @@ -397,7 +397,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { }); it('swaps the timeline for the list view when epgViewMode is "list"', () => { - settingsStore.epgViewMode.set('list'); + settingsStore.resolvedEpgViewMode.set('list'); fixture.detectChanges(); @@ -414,7 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { ?.classList.contains('epg--list') ).toBe(true); - settingsStore.epgViewMode.set('timeline'); // restore for sibling tests + settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests }); it('does not request or render EPG in browser/PWA playback', async () => { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 1dee75e43..e50766d38 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -194,9 +194,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { () => this.liveEpgPanelState() === 'collapsed' ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary(this.currentProgram()) diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 3a2919016..6a591be03 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -200,7 +200,7 @@ describe('LiveStreamLayoutComponent', () => { openStreamOnDoubleClick: signal(false), // Reset in beforeEach: the store is module-scoped, so a test failure // before an in-test restore must not leak 'list' into siblings. - epgViewMode: signal<'timeline' | 'list'>('timeline'), + resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'), }; const originalElectron = window.electron; @@ -208,7 +208,7 @@ describe('LiveStreamLayoutComponent', () => { beforeEach(async () => { jest.useFakeTimers(); jest.setSystemTime(fixedNow); - settingsStore.epgViewMode.set('timeline'); + settingsStore.resolvedEpgViewMode.set('timeline'); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY); @@ -368,7 +368,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('swaps the timeline for the list view when epgViewMode is "list"', () => { - settingsStore.epgViewMode.set('list'); + settingsStore.resolvedEpgViewMode.set('list'); component.playLive(sampleChannel); fixture.detectChanges(); @@ -386,7 +386,7 @@ describe('LiveStreamLayoutComponent', () => { ?.classList.contains('epg--list') ).toBe(true); - settingsStore.epgViewMode.set('timeline'); // restore for sibling tests + settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests }); it('hides the EPG panel in browser/PWA playback', () => { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index cd2cbe75c..51986f145 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -211,9 +211,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { () => this.liveEpgPanelState() === 'collapsed' ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary( diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index a0349af47..f65641b00 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -1,7 +1,8 @@ -import { inject } from '@angular/core'; +import { computed, inject } from '@angular/core'; import { patchState, signalStore, + withComputed, withHooks, withMethods, withState, @@ -11,6 +12,7 @@ import { firstValueFrom } from 'rxjs'; import { DEFAULT_DASHBOARD_RAILS_SETTINGS, ElectronBridgeTrustOptions, + EpgViewMode, Language, Settings, StartupBehavior, @@ -92,6 +94,16 @@ function scheduleEmbeddedMpvPrepare(): void { export const SettingsStore = signalStore( { providedIn: 'root' }, withState(DEFAULT_SETTINGS), + withComputed((store) => ({ + /** + * Live EPG panel layout with the `'timeline'` default applied — the + * single source of truth for the four live hosts, so the fallback is + * not duplicated per call-site. + */ + resolvedEpgViewMode: computed( + () => store.epgViewMode?.() ?? 'timeline' + ), + })), withMethods((store, storage = inject(StorageMap)) => ({ async loadSettings() { try { diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts index 056545c61..2e126b3bd 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts @@ -1,24 +1,20 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgListScrollController } from './epg-list-scroll.controller'; -import { EpgListRow } from './epg-list-view.utils'; -function rowAt(when: EpgListRow['when'], key = `${when}-row`): EpgListRow { - const startMs = Date.now(); +function programAt( + startOffsetMin: number, + durationMin: number, + channel = 'ch' +): EpgProgram { + const start = new Date(Date.now() + startOffsetMin * 60_000); + const stop = new Date(start.getTime() + durationMin * 60_000); return { - program: { - start: new Date(startMs).toISOString(), - stop: new Date(startMs + 60 * 60_000).toISOString(), - channel: 'ch', - title: 'P', - desc: null, - category: null, - }, - key, - startMs, - stopMs: startMs + 60 * 60_000, - when, - progress: when === 'now' ? 50 : null, - isActive: false, - canCatchUp: false, + start: start.toISOString(), + stop: stop.toISOString(), + channel, + title: 'P', + desc: null, + category: null, }; } @@ -26,6 +22,8 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => { let controller: EpgListScrollController; let scrollSpy: jest.SpyInstance; let rafSpy: jest.SpyInstance; + let hasProgramsToday: jest.Mock; + let commitToday: jest.Mock; beforeEach(() => { rafSpy = jest @@ -34,14 +32,19 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => { cb(0); return 1; }); + hasProgramsToday = jest.fn(() => true); + commitToday = jest.fn(); controller = new EpgListScrollController({ list: () => undefined, isViewToday: () => true, setNowStripVisible: () => undefined, + hasProgramsToday, + commitToday, }); scrollSpy = jest .spyOn(controller, 'scrollNowIntoView') .mockImplementation(() => undefined); + jest.spyOn(controller, 'focusNowAfterRender'); jest.spyOn(controller, 'updateNowStrip').mockImplementation( () => undefined ); @@ -52,17 +55,25 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => { }); it('scrolls the now row into view instantly on first load', () => { - controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], true, 'ch'); + controller.maybeAutoScroll( + {} as HTMLElement, + [programAt(0, 120)], + true, + 'ch' + ); expect(scrollSpy).toHaveBeenCalledTimes(1); expect(scrollSpy).toHaveBeenCalledWith(false); // instant, no animation }); - it('does not re-scroll while the same channel stays loaded (now-tick / re-emit)', () => { + it('does not re-scroll while the same set stays loaded (now-tick / rollover)', () => { + // The 30s tick reclassifies past/now/future at every programme + // boundary, but the programme SET is unchanged — the viewport must + // stay put. const list = {} as HTMLElement; - const rows = [rowAt('now')]; - controller.maybeAutoScroll(list, rows, true, 'ch'); - controller.maybeAutoScroll(list, rows, true, 'ch'); + const programs = [programAt(-30, 60), programAt(30, 60)]; + controller.maybeAutoScroll(list, programs, true, 'ch'); + controller.maybeAutoScroll(list, programs, true, 'ch'); expect(scrollSpy).toHaveBeenCalledTimes(1); // The dedup path still refreshes the now-strip — layout can change @@ -71,54 +82,67 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => { }); it('restores the now row when the same channel list remounts (collapse then expand)', () => { - const rows = [rowAt('now')]; - controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // mount - controller.maybeAutoScroll(undefined, rows, true, 'ch'); // collapsed - controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // expand + const programs = [programAt(0, 120)]; + controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // mount + controller.maybeAutoScroll(undefined, programs, true, 'ch'); // collapsed + controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // expand expect(scrollSpy).toHaveBeenCalledTimes(2); }); - it('does not re-scroll when the on-air programme rolls over within the same set', () => { - // The 30s tick reclassifies `when` at every programme boundary; the - // programme SET is unchanged, so the viewport must stay put. - const list = {} as HTMLElement; - const a = rowAt('now', 'a'); - const b = { - ...rowAt('future', 'b'), - startMs: a.stopMs, - stopMs: a.stopMs + 3_600_000, - }; - controller.maybeAutoScroll(list, [a, b], true, 'ch'); - - const rolled = [ - { ...a, when: 'past' as const }, - { ...b, when: 'now' as const }, - ]; - controller.maybeAutoScroll(list, rolled, true, 'ch'); - - expect(scrollSpy).toHaveBeenCalledTimes(1); - }); - it('re-scrolls when the channel changes', () => { const list = {} as HTMLElement; - controller.maybeAutoScroll(list, [rowAt('now', 'a')], true, 'alpha'); - controller.maybeAutoScroll(list, [rowAt('now', 'b')], true, 'beta'); + controller.maybeAutoScroll(list, [programAt(0, 120, 'a')], true, 'a'); + controller.maybeAutoScroll(list, [programAt(0, 120, 'b')], true, 'b'); expect(scrollSpy).toHaveBeenCalledTimes(2); }); - it('leaves a non-today day alone (no snap back to now)', () => { - controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], false, 'ch'); + it('returns to today when a new channel arrives while parked on another day', () => { + // Channel switch while the user navigated to yesterday: the new set + // must reset the view to today (when today has data) — otherwise the + // new channel opens on the stale day (timeline parity). + controller.maybeAutoScroll( + {} as HTMLElement, + [programAt(0, 120, 'b')], + false, + 'b' + ); - expect(scrollSpy).not.toHaveBeenCalled(); + expect(commitToday).toHaveBeenCalledTimes(1); + expect(scrollSpy).toHaveBeenCalledTimes(1); }); - it('does nothing without an on-air row or a mounted list', () => { - controller.maybeAutoScroll({} as HTMLElement, [rowAt('past')], true, 'ch'); - controller.maybeAutoScroll(undefined, [rowAt('now')], true, 'ch'); + it('leaves day navigation alone while the set is unchanged', () => { + // Same channel, user steps to yesterday: same set key → no snap back. + const list = {} as HTMLElement; + const programs = [programAt(0, 120)]; + controller.maybeAutoScroll(list, programs, true, 'ch'); + controller.maybeAutoScroll(list, programs, false, 'ch'); + + expect(commitToday).not.toHaveBeenCalled(); + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + + it('does not take over when the new set has nothing airing today', () => { + hasProgramsToday.mockReturnValue(false); + const programs = [programAt(3 * 1440, 60)]; + controller.maybeAutoScroll({} as HTMLElement, programs, false, 'ch'); + + expect(commitToday).not.toHaveBeenCalled(); + expect(scrollSpy).not.toHaveBeenCalled(); + + // The key was not stored — a later, fuller load retries the focus. + hasProgramsToday.mockReturnValue(true); + controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + + it('does nothing for an empty programme set', () => { + controller.maybeAutoScroll({} as HTMLElement, [], true, 'ch'); expect(scrollSpy).not.toHaveBeenCalled(); + expect(commitToday).not.toHaveBeenCalled(); }); }); @@ -129,6 +153,8 @@ describe('EpgListScrollController (now-strip visibility)', () => { list: () => list as HTMLElement, isViewToday: () => true, setNowStripVisible: (value) => (visible = value), + hasProgramsToday: () => true, + commitToday: () => undefined, }); controller.updateNowStrip(); return visible; @@ -180,6 +206,8 @@ describe('EpgListScrollController (scroll target maths)', () => { list: () => list, isViewToday: () => true, setNowStripVisible: () => undefined, + hasProgramsToday: () => true, + commitToday: () => undefined, }); controller.scrollNowIntoView(false); diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts index 6583fe783..9b00c4e68 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts @@ -1,4 +1,5 @@ -import { EpgListRow } from './epg-list-view.utils'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { programsFocusKey } from '../epg-timeline/epg-timeline-scroll.controller'; export interface EpgListScrollDeps { /** The scrollable `.g-list` element (undefined before first render). */ @@ -7,6 +8,10 @@ export interface EpgListScrollDeps { readonly isViewToday: () => boolean; /** Toggle the sticky now-strip's visibility. */ readonly setNowStripVisible: (visible: boolean) => void; + /** Whether the loaded window has any programme airing today. */ + readonly hasProgramsToday: () => boolean; + /** Commit today as the viewed day (emits `selectedDateChange`). */ + readonly commitToday: () => void; } /** @@ -22,34 +27,34 @@ export class EpgListScrollController { constructor(private readonly deps: EpgListScrollDeps) {} /** - * Scroll the on-air row into view once per channel/EPG load, today only. - * A *new* list element under an unchanged key means the body was unmounted - * and remounted (the inline panel was collapsed and re-expanded), which - * resets scrollTop to 0 — restore the now-row instead of stranding the user - * at the top of the day. The same element (data re-emit / 30s now-tick) is - * left alone so the viewport is never yanked out from under the user. + * Scroll the on-air row into view once per channel/EPG (re)load. Keyed by + * the FULL programme-set identity (`programsFocusKey`, like the timeline) — + * stable across day navigation, 30s now-ticks, and programme rollovers, so + * the viewport is never yanked out from under the user. A *new* list + * element under an unchanged key means the body was unmounted and + * remounted (the inline panel was collapsed and re-expanded), which resets + * scrollTop to 0 — restore the now-row instead of stranding the user at + * the top of the day. + * + * When a NEW programme set arrives while the user is parked on another day + * (a channel switch), commit today first — otherwise the new channel opens + * on the stale day, possibly with nothing to show (timeline parity). */ maybeAutoScroll( list: HTMLElement | undefined, - rows: EpgListRow[], + programs: readonly EpgProgram[], today: boolean, channel: string ): void { - if (!list || !today) { + const setKey = programsFocusKey(programs); + if (!setKey) { return; } - const now = rows.find((row) => row.when === 'now'); - if (!now) { - return; - } - // Programme-SET identity (like the timeline's `programsFocusKey`), NOT - // the on-air row's key: the latter changes at every programme rollover - // (the 30s tick reclassifies `when`), which would re-trigger the scroll - // and yank the viewport away from wherever the user scrolled to. - const first = rows[0]; - const last = rows[rows.length - 1]; - const key = `${channel}|${rows.length}|${first.startMs}|${last.stopMs}`; + const key = `${channel}|${setKey}`; if (key === this.autoScrollKey) { + if (!list) { + return; + } if (list !== this.lastList) { this.lastList = list; this.focusNowAfterRender(); @@ -61,8 +66,17 @@ export class EpgListScrollController { } return; } + // New programme set. Only take over when today actually has + // programmes; otherwise leave the user's day navigation alone (and + // don't store the key, so a later fuller load retries). + if (!this.deps.hasProgramsToday()) { + return; + } this.autoScrollKey = key; - this.lastList = list; + this.lastList = list ?? null; + if (!today) { + this.deps.commitToday(); + } this.focusNowAfterRender(); } diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts index e46470475..3bfb41345 100644 --- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts @@ -3,14 +3,12 @@ import { ChangeDetectionStrategy, Component, computed, - effect, ElementRef, inject, input, linkedSignal, output, signal, - untracked, viewChild, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -47,6 +45,7 @@ import { } from '../epg-timeline/epg-timeline.utils'; import { EpgListScrollController } from './epg-list-scroll.controller'; import { EpgListViewRowComponent } from './epg-list-view-row/epg-list-view-row.component'; +import { registerEpgListViewEffects } from './epg-list-view.effects'; import { buildEpgListRows, EpgListRow } from './epg-list-view.utils'; type RenderState = 'loading' | 'list' | EpgTimelineEmptyReason; @@ -191,33 +190,20 @@ export class EpgListViewComponent { list: () => this.list()?.nativeElement, isViewToday: () => this.isViewToday(), setNowStripVisible: (visible) => this.nowStripVisible.set(visible), + hasProgramsToday: () => + hasProgramsForDateKey(this.programs(), getTodayEpgDateKey()), + commitToday: () => this.commitDay(getTodayEpgDateKey()), }); constructor() { - // 30s tick reclassifies past/now/future and refreshes progress. This is - // a controlled component (activeProgram/isLivePlayback come from the - // host), so the tick never clobbers active archive playback. - effect((onCleanup) => { - const intervalId = window.setInterval( - () => this.nowMs.set(Date.now()), - 30_000 - ); - onCleanup(() => clearInterval(intervalId)); - }); - - // Auto-focus the on-air row when a channel's EPG (re)loads or the list - // (re)mounts (collapse → expand) while viewing today — the vertical - // analogue of the ribbon's auto-focus. Tracks the `list` viewChild so a - // remount re-triggers; deduped inside the controller so 30s ticks and - // data re-emits don't re-jump. - effect(() => { - const list = this.list()?.nativeElement; - const rows = this.rows(); - const today = this.isViewToday(); - const channel = this.channelName(); - untracked(() => - this.scroll.maybeAutoScroll(list, rows, today, channel) - ); + registerEpgListViewEffects({ + nowMs: this.nowMs, + list: () => this.list()?.nativeElement, + rows: () => this.rows(), + programs: () => this.programs(), + isViewToday: () => this.isViewToday(), + channelName: () => this.channelName(), + scroll: this.scroll, }); } diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts new file mode 100644 index 000000000..1b11114e4 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts @@ -0,0 +1,52 @@ +import { effect, untracked, WritableSignal } from '@angular/core'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { EpgListScrollController } from './epg-list-scroll.controller'; +import { EpgListRow } from './epg-list-view.utils'; + +export interface EpgListViewEffectsContext { + /** Wall-clock signal driving past/now/future classification + progress. */ + readonly nowMs: WritableSignal; + readonly list: () => HTMLElement | undefined; + readonly rows: () => readonly EpgListRow[]; + readonly programs: () => readonly EpgProgram[]; + readonly isViewToday: () => boolean; + readonly channelName: () => string; + readonly scroll: EpgListScrollController; +} + +/** + * The list view's reactive plumbing, kept out of the component so it stays + * within the file-size guideline. Must be called from the component's + * constructor (an injection context — `effect()` requires one). + */ +export function registerEpgListViewEffects( + ctx: EpgListViewEffectsContext +): void { + // 30s tick reclassifies past/now/future and refreshes progress. The list + // is a controlled component (activeProgram/isLivePlayback come from the + // host), so the tick never clobbers active archive playback. + effect((onCleanup) => { + const intervalId = window.setInterval( + () => ctx.nowMs.set(Date.now()), + 30_000 + ); + onCleanup(() => clearInterval(intervalId)); + }); + + // Auto-focus the on-air row when a channel's EPG (re)loads or the list + // (re)mounts (collapse → expand) — the vertical analogue of the ribbon's + // auto-focus. Tracks the `list` viewChild so a remount re-triggers, and + // `rows` so day changes and 30s ticks refresh the now-strip; the + // controller dedupes by programme-set identity so neither re-jumps the + // viewport. + effect(() => { + const list = ctx.list(); + ctx.rows(); + const programs = ctx.programs(); + const today = ctx.isViewToday(); + const channel = ctx.channelName(); + untracked(() => + ctx.scroll.maybeAutoScroll(list, programs, today, channel) + ); + }); +}