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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-07-02 00:50:41 +02:00
1 parent c4890748e0
commit e6fd0d0825
13 files changed
+210 -126

No files matched your search

@@ -389,7 +389,7 @@ describe('VideoPlayerComponent', () => {
useValue: {
player,
showCaptions,
epgViewMode,
resolvedEpgViewMode: epgViewMode,
},
},
{
@@ -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'
);
@@ -189,7 +189,7 @@ describe('UnifiedLiveTabComponent', () => {
useValue: {
openStreamOnDoubleClick: signal(false),
player,
epgViewMode,
resolvedEpgViewMode: epgViewMode,
},
},
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
@@ -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());
@@ -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 () => {
@@ -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())
@@ -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', () => {
@@ -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(
@@ -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<Settings>(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<EpgViewMode>(
() => store.epgViewMode?.() ?? 'timeline'
),
})),
withMethods((store, storage = inject(StorageMap)) => ({
async loadSettings() {
try {
@@ -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);
@@ -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();
}
@@ -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,
});
}
@@ -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<number>;
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)
);
});
}