mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36: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: {
|
useValue: {
|
||||||
player,
|
player,
|
||||||
showCaptions,
|
showCaptions,
|
||||||
epgViewMode,
|
resolvedEpgViewMode: epgViewMode,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -276,9 +276,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
|
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
|
||||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||||
readonly epgViewMode = computed(
|
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||||
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
|
|
||||||
);
|
|
||||||
readonly isLiveEpgPanelCollapsed = computed(
|
readonly isLiveEpgPanelCollapsed = computed(
|
||||||
() => this.liveEpgPanelState() === 'collapsed'
|
() => this.liveEpgPanelState() === 'collapsed'
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -189,7 +189,7 @@ describe('UnifiedLiveTabComponent', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
openStreamOnDoubleClick: signal(false),
|
openStreamOnDoubleClick: signal(false),
|
||||||
player,
|
player,
|
||||||
epgViewMode,
|
resolvedEpgViewMode: epgViewMode,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||||
|
|||||||
+1
-3
@@ -194,9 +194,7 @@ export class UnifiedLiveTabComponent {
|
|||||||
() => this.liveEpgPanelState() === 'collapsed'
|
() => this.liveEpgPanelState() === 'collapsed'
|
||||||
);
|
);
|
||||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||||
readonly epgViewMode = computed(
|
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||||
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
|
|
||||||
);
|
|
||||||
readonly liveEpgPanelSummary = computed(() => {
|
readonly liveEpgPanelSummary = computed(() => {
|
||||||
this.progressTick();
|
this.progressTick();
|
||||||
return this.getLiveEpgPanelSummary(this.activeDetail());
|
return this.getLiveEpgPanelSummary(this.activeDetail());
|
||||||
|
|||||||
+4
-4
@@ -235,14 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
};
|
};
|
||||||
const settingsStore = {
|
const settingsStore = {
|
||||||
openStreamOnDoubleClick: signal(false),
|
openStreamOnDoubleClick: signal(false),
|
||||||
epgViewMode: signal<'timeline' | 'list'>('timeline'),
|
resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'),
|
||||||
};
|
};
|
||||||
const originalElectron = window.electron;
|
const originalElectron = window.electron;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// The store mock is module-scoped: reset so a failed test can't leak
|
// The store mock is module-scoped: reset so a failed test can't leak
|
||||||
// 'list' into siblings.
|
// 'list' into siblings.
|
||||||
settingsStore.epgViewMode.set('timeline');
|
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||||
window.electron = {
|
window.electron = {
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
updateRemoteControlStatus: jest.fn(),
|
updateRemoteControlStatus: jest.fn(),
|
||||||
@@ -397,7 +397,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||||
settingsStore.epgViewMode.set('list');
|
settingsStore.resolvedEpgViewMode.set('list');
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
|||||||
?.classList.contains('epg--list')
|
?.classList.contains('epg--list')
|
||||||
).toBe(true);
|
).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 () => {
|
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'
|
() => this.liveEpgPanelState() === 'collapsed'
|
||||||
);
|
);
|
||||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||||
readonly epgViewMode = computed(
|
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||||
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
|
|
||||||
);
|
|
||||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||||
readonly liveEpgPanelSummary = computed(() =>
|
readonly liveEpgPanelSummary = computed(() =>
|
||||||
this.toLiveEpgPanelSummary(this.currentProgram())
|
this.toLiveEpgPanelSummary(this.currentProgram())
|
||||||
|
|||||||
+4
-4
@@ -200,7 +200,7 @@ describe('LiveStreamLayoutComponent', () => {
|
|||||||
openStreamOnDoubleClick: signal(false),
|
openStreamOnDoubleClick: signal(false),
|
||||||
// Reset in beforeEach: the store is module-scoped, so a test failure
|
// Reset in beforeEach: the store is module-scoped, so a test failure
|
||||||
// before an in-test restore must not leak 'list' into siblings.
|
// 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;
|
const originalElectron = window.electron;
|
||||||
@@ -208,7 +208,7 @@ describe('LiveStreamLayoutComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
jest.setSystemTime(fixedNow);
|
jest.setSystemTime(fixedNow);
|
||||||
settingsStore.epgViewMode.set('timeline');
|
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||||
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
|
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
|
||||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||||
localStorage.removeItem(LIVE_SIDEBAR_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"', () => {
|
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||||
settingsStore.epgViewMode.set('list');
|
settingsStore.resolvedEpgViewMode.set('list');
|
||||||
|
|
||||||
component.playLive(sampleChannel);
|
component.playLive(sampleChannel);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -386,7 +386,7 @@ describe('LiveStreamLayoutComponent', () => {
|
|||||||
?.classList.contains('epg--list')
|
?.classList.contains('epg--list')
|
||||||
).toBe(true);
|
).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', () => {
|
it('hides the EPG panel in browser/PWA playback', () => {
|
||||||
|
|||||||
+1
-3
@@ -211,9 +211,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
|||||||
() => this.liveEpgPanelState() === 'collapsed'
|
() => this.liveEpgPanelState() === 'collapsed'
|
||||||
);
|
);
|
||||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||||
readonly epgViewMode = computed(
|
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||||
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
|
|
||||||
);
|
|
||||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||||
readonly liveEpgPanelSummary = computed(() =>
|
readonly liveEpgPanelSummary = computed(() =>
|
||||||
this.toLiveEpgPanelSummary(
|
this.toLiveEpgPanelSummary(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { inject } from '@angular/core';
|
import { computed, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
patchState,
|
patchState,
|
||||||
signalStore,
|
signalStore,
|
||||||
|
withComputed,
|
||||||
withHooks,
|
withHooks,
|
||||||
withMethods,
|
withMethods,
|
||||||
withState,
|
withState,
|
||||||
@@ -11,6 +12,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||||
ElectronBridgeTrustOptions,
|
ElectronBridgeTrustOptions,
|
||||||
|
EpgViewMode,
|
||||||
Language,
|
Language,
|
||||||
Settings,
|
Settings,
|
||||||
StartupBehavior,
|
StartupBehavior,
|
||||||
@@ -92,6 +94,16 @@ function scheduleEmbeddedMpvPrepare(): void {
|
|||||||
export const SettingsStore = signalStore(
|
export const SettingsStore = signalStore(
|
||||||
{ providedIn: 'root' },
|
{ providedIn: 'root' },
|
||||||
withState<Settings>(DEFAULT_SETTINGS),
|
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)) => ({
|
withMethods((store, storage = inject(StorageMap)) => ({
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
|
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||||
import { EpgListRow } from './epg-list-view.utils';
|
|
||||||
|
|
||||||
function rowAt(when: EpgListRow['when'], key = `${when}-row`): EpgListRow {
|
function programAt(
|
||||||
const startMs = Date.now();
|
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 {
|
return {
|
||||||
program: {
|
start: start.toISOString(),
|
||||||
start: new Date(startMs).toISOString(),
|
stop: stop.toISOString(),
|
||||||
stop: new Date(startMs + 60 * 60_000).toISOString(),
|
channel,
|
||||||
channel: 'ch',
|
title: 'P',
|
||||||
title: 'P',
|
desc: null,
|
||||||
desc: null,
|
category: null,
|
||||||
category: null,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
startMs,
|
|
||||||
stopMs: startMs + 60 * 60_000,
|
|
||||||
when,
|
|
||||||
progress: when === 'now' ? 50 : null,
|
|
||||||
isActive: false,
|
|
||||||
canCatchUp: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +22,8 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => {
|
|||||||
let controller: EpgListScrollController;
|
let controller: EpgListScrollController;
|
||||||
let scrollSpy: jest.SpyInstance;
|
let scrollSpy: jest.SpyInstance;
|
||||||
let rafSpy: jest.SpyInstance;
|
let rafSpy: jest.SpyInstance;
|
||||||
|
let hasProgramsToday: jest.Mock;
|
||||||
|
let commitToday: jest.Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rafSpy = jest
|
rafSpy = jest
|
||||||
@@ -34,14 +32,19 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => {
|
|||||||
cb(0);
|
cb(0);
|
||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
|
hasProgramsToday = jest.fn(() => true);
|
||||||
|
commitToday = jest.fn();
|
||||||
controller = new EpgListScrollController({
|
controller = new EpgListScrollController({
|
||||||
list: () => undefined,
|
list: () => undefined,
|
||||||
isViewToday: () => true,
|
isViewToday: () => true,
|
||||||
setNowStripVisible: () => undefined,
|
setNowStripVisible: () => undefined,
|
||||||
|
hasProgramsToday,
|
||||||
|
commitToday,
|
||||||
});
|
});
|
||||||
scrollSpy = jest
|
scrollSpy = jest
|
||||||
.spyOn(controller, 'scrollNowIntoView')
|
.spyOn(controller, 'scrollNowIntoView')
|
||||||
.mockImplementation(() => undefined);
|
.mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(controller, 'focusNowAfterRender');
|
||||||
jest.spyOn(controller, 'updateNowStrip').mockImplementation(
|
jest.spyOn(controller, 'updateNowStrip').mockImplementation(
|
||||||
() => undefined
|
() => undefined
|
||||||
);
|
);
|
||||||
@@ -52,17 +55,25 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('scrolls the now row into view instantly on first load', () => {
|
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).toHaveBeenCalledTimes(1);
|
||||||
expect(scrollSpy).toHaveBeenCalledWith(false); // instant, no animation
|
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 list = {} as HTMLElement;
|
||||||
const rows = [rowAt('now')];
|
const programs = [programAt(-30, 60), programAt(30, 60)];
|
||||||
controller.maybeAutoScroll(list, rows, true, 'ch');
|
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||||
controller.maybeAutoScroll(list, rows, true, 'ch');
|
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||||
|
|
||||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||||
// The dedup path still refreshes the now-strip — layout can change
|
// 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)', () => {
|
it('restores the now row when the same channel list remounts (collapse then expand)', () => {
|
||||||
const rows = [rowAt('now')];
|
const programs = [programAt(0, 120)];
|
||||||
controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // mount
|
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // mount
|
||||||
controller.maybeAutoScroll(undefined, rows, true, 'ch'); // collapsed
|
controller.maybeAutoScroll(undefined, programs, true, 'ch'); // collapsed
|
||||||
controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // expand
|
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // expand
|
||||||
|
|
||||||
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
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', () => {
|
it('re-scrolls when the channel changes', () => {
|
||||||
const list = {} as HTMLElement;
|
const list = {} as HTMLElement;
|
||||||
controller.maybeAutoScroll(list, [rowAt('now', 'a')], true, 'alpha');
|
controller.maybeAutoScroll(list, [programAt(0, 120, 'a')], true, 'a');
|
||||||
controller.maybeAutoScroll(list, [rowAt('now', 'b')], true, 'beta');
|
controller.maybeAutoScroll(list, [programAt(0, 120, 'b')], true, 'b');
|
||||||
|
|
||||||
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves a non-today day alone (no snap back to now)', () => {
|
it('returns to today when a new channel arrives while parked on another day', () => {
|
||||||
controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], false, 'ch');
|
// 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', () => {
|
it('leaves day navigation alone while the set is unchanged', () => {
|
||||||
controller.maybeAutoScroll({} as HTMLElement, [rowAt('past')], true, 'ch');
|
// Same channel, user steps to yesterday: same set key → no snap back.
|
||||||
controller.maybeAutoScroll(undefined, [rowAt('now')], true, 'ch');
|
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(scrollSpy).not.toHaveBeenCalled();
|
||||||
|
expect(commitToday).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,6 +153,8 @@ describe('EpgListScrollController (now-strip visibility)', () => {
|
|||||||
list: () => list as HTMLElement,
|
list: () => list as HTMLElement,
|
||||||
isViewToday: () => true,
|
isViewToday: () => true,
|
||||||
setNowStripVisible: (value) => (visible = value),
|
setNowStripVisible: (value) => (visible = value),
|
||||||
|
hasProgramsToday: () => true,
|
||||||
|
commitToday: () => undefined,
|
||||||
});
|
});
|
||||||
controller.updateNowStrip();
|
controller.updateNowStrip();
|
||||||
return visible;
|
return visible;
|
||||||
@@ -180,6 +206,8 @@ describe('EpgListScrollController (scroll target maths)', () => {
|
|||||||
list: () => list,
|
list: () => list,
|
||||||
isViewToday: () => true,
|
isViewToday: () => true,
|
||||||
setNowStripVisible: () => undefined,
|
setNowStripVisible: () => undefined,
|
||||||
|
hasProgramsToday: () => true,
|
||||||
|
commitToday: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.scrollNowIntoView(false);
|
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 {
|
export interface EpgListScrollDeps {
|
||||||
/** The scrollable `.g-list` element (undefined before first render). */
|
/** The scrollable `.g-list` element (undefined before first render). */
|
||||||
@@ -7,6 +8,10 @@ export interface EpgListScrollDeps {
|
|||||||
readonly isViewToday: () => boolean;
|
readonly isViewToday: () => boolean;
|
||||||
/** Toggle the sticky now-strip's visibility. */
|
/** Toggle the sticky now-strip's visibility. */
|
||||||
readonly setNowStripVisible: (visible: boolean) => void;
|
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) {}
|
constructor(private readonly deps: EpgListScrollDeps) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll the on-air row into view once per channel/EPG load, today only.
|
* Scroll the on-air row into view once per channel/EPG (re)load. Keyed by
|
||||||
* A *new* list element under an unchanged key means the body was unmounted
|
* the FULL programme-set identity (`programsFocusKey`, like the timeline) —
|
||||||
* and remounted (the inline panel was collapsed and re-expanded), which
|
* stable across day navigation, 30s now-ticks, and programme rollovers, so
|
||||||
* resets scrollTop to 0 — restore the now-row instead of stranding the user
|
* the viewport is never yanked out from under the user. A *new* list
|
||||||
* at the top of the day. The same element (data re-emit / 30s now-tick) is
|
* element under an unchanged key means the body was unmounted and
|
||||||
* left alone so the viewport is never yanked out from under the user.
|
* 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(
|
maybeAutoScroll(
|
||||||
list: HTMLElement | undefined,
|
list: HTMLElement | undefined,
|
||||||
rows: EpgListRow[],
|
programs: readonly EpgProgram[],
|
||||||
today: boolean,
|
today: boolean,
|
||||||
channel: string
|
channel: string
|
||||||
): void {
|
): void {
|
||||||
if (!list || !today) {
|
const setKey = programsFocusKey(programs);
|
||||||
|
if (!setKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = rows.find((row) => row.when === 'now');
|
const key = `${channel}|${setKey}`;
|
||||||
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}`;
|
|
||||||
if (key === this.autoScrollKey) {
|
if (key === this.autoScrollKey) {
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (list !== this.lastList) {
|
if (list !== this.lastList) {
|
||||||
this.lastList = list;
|
this.lastList = list;
|
||||||
this.focusNowAfterRender();
|
this.focusNowAfterRender();
|
||||||
@@ -61,8 +66,17 @@ export class EpgListScrollController {
|
|||||||
}
|
}
|
||||||
return;
|
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.autoScrollKey = key;
|
||||||
this.lastList = list;
|
this.lastList = list ?? null;
|
||||||
|
if (!today) {
|
||||||
|
this.deps.commitToday();
|
||||||
|
}
|
||||||
this.focusNowAfterRender();
|
this.focusNowAfterRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
linkedSignal,
|
linkedSignal,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
untracked,
|
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
@@ -47,6 +45,7 @@ import {
|
|||||||
} from '../epg-timeline/epg-timeline.utils';
|
} from '../epg-timeline/epg-timeline.utils';
|
||||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||||
import { EpgListViewRowComponent } from './epg-list-view-row/epg-list-view-row.component';
|
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';
|
import { buildEpgListRows, EpgListRow } from './epg-list-view.utils';
|
||||||
|
|
||||||
type RenderState = 'loading' | 'list' | EpgTimelineEmptyReason;
|
type RenderState = 'loading' | 'list' | EpgTimelineEmptyReason;
|
||||||
@@ -191,33 +190,20 @@ export class EpgListViewComponent {
|
|||||||
list: () => this.list()?.nativeElement,
|
list: () => this.list()?.nativeElement,
|
||||||
isViewToday: () => this.isViewToday(),
|
isViewToday: () => this.isViewToday(),
|
||||||
setNowStripVisible: (visible) => this.nowStripVisible.set(visible),
|
setNowStripVisible: (visible) => this.nowStripVisible.set(visible),
|
||||||
|
hasProgramsToday: () =>
|
||||||
|
hasProgramsForDateKey(this.programs(), getTodayEpgDateKey()),
|
||||||
|
commitToday: () => this.commitDay(getTodayEpgDateKey()),
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// 30s tick reclassifies past/now/future and refreshes progress. This is
|
registerEpgListViewEffects({
|
||||||
// a controlled component (activeProgram/isLivePlayback come from the
|
nowMs: this.nowMs,
|
||||||
// host), so the tick never clobbers active archive playback.
|
list: () => this.list()?.nativeElement,
|
||||||
effect((onCleanup) => {
|
rows: () => this.rows(),
|
||||||
const intervalId = window.setInterval(
|
programs: () => this.programs(),
|
||||||
() => this.nowMs.set(Date.now()),
|
isViewToday: () => this.isViewToday(),
|
||||||
30_000
|
channelName: () => this.channelName(),
|
||||||
);
|
scroll: this.scroll,
|
||||||
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)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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