mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
c4890748e0
commit
e6fd0d0825
13 files changed
+210
-126
No files matched your search
+1
-1
@@ -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'
|
||||
);
|
||||
|
||||
+1
-1
@@ -189,7 +189,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
player,
|
||||
epgViewMode,
|
||||
resolvedEpgViewMode: epgViewMode,
|
||||
},
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
|
||||
+1
-3
@@ -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());
|
||||
|
||||
+4
-4
@@ -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 () => {
|
||||
|
||||
+1
-3
@@ -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())
|
||||
|
||||
+4
-4
@@ -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', () => {
|
||||
|
||||
+1
-3
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user