mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(epg): restore timeline position when the ribbon remounts
Collapsing the inline EPG panel unmounts the ribbon (`@if (!collapsed())`); expanding recreates the scroller at scrollLeft 0. `maybeAutoFocus` deduped the same programme set and skipped, so the user landed at the far-left of the multi-day guide instead of back on the live programme. Flagged by Codex. Distinguish a real remount (a *new* scroller element) from a data re-emit / now-tick (same element): on a new element for an already-focused channel, re-centre the currently-viewed day — "now" for today, else the viewed day — without committing a day, so a user parked on another day is not snapped back. The same-element case is still left alone. Empty-today channels never set lastFocusKey, so the ① snap-back protection is untouched. Tests: rework the remount spec into a collapse/expand restore case (asserts a re-centre with no day commit) and extend the empty-day guard across a remount; a commitDay spy locks in "restore never snaps a day". ui-epg 80 green, lint clean, controller 153 lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
30696d037d
commit
d14c9a4a6f
2 files changed
+51
-20
No files matched your search
@@ -54,10 +54,12 @@ describe('TimelineScrollController', () => {
|
||||
let programs: EpgProgram[];
|
||||
let controller: TimelineScrollController;
|
||||
let scrollSpy: jest.SpyInstance;
|
||||
let commitDaySpy: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
programs = [];
|
||||
const nowMs = Date.now();
|
||||
commitDaySpy = jest.fn();
|
||||
controller = new TimelineScrollController({
|
||||
ribbon: () => undefined,
|
||||
scale: () => 1,
|
||||
@@ -70,7 +72,7 @@ describe('TimelineScrollController', () => {
|
||||
),
|
||||
nowMs: () => nowMs,
|
||||
viewDayKey: () => 'today',
|
||||
commitDay: () => undefined,
|
||||
commitDay: commitDaySpy,
|
||||
});
|
||||
scrollSpy = jest
|
||||
.spyOn(controller, 'scrollToOffset')
|
||||
@@ -102,16 +104,16 @@ describe('TimelineScrollController', () => {
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not re-focus the same channel when its ribbon remounts (no snap-back)', () => {
|
||||
// Unmount + remount of the SAME channel — e.g. the user navigates from
|
||||
// an empty day to a day with programmes — must not re-focus, or it
|
||||
// would commit today again and snap the view back to the empty day.
|
||||
it('restores position when the same channel ribbon remounts (collapse then expand)', () => {
|
||||
// Collapse → expand recreates the ribbon as a *new* element at
|
||||
// scrollLeft 0, so re-centre the viewed programme — but WITHOUT
|
||||
// committing a day, so a user parked on another day is not snapped.
|
||||
const p = [programAt(0, 120, 'Now')];
|
||||
const scroller = {} as HTMLElement;
|
||||
focus(scroller, p); // focus #1
|
||||
focus(undefined, p); // ribbon unmounts (empty-day navigation)
|
||||
focus(scroller, p); // remount on another day → must NOT re-focus
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
focus({} as HTMLElement, p); // initial mount → focus #1
|
||||
focus(undefined, p); // collapse: ribbon unmounts
|
||||
focus({} as HTMLElement, p); // expand: new element → restore
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
||||
expect(commitDaySpy).toHaveBeenCalledTimes(1); // only the initial focus
|
||||
});
|
||||
|
||||
it('re-focuses when the channel changes', () => {
|
||||
@@ -123,9 +125,13 @@ describe('TimelineScrollController', () => {
|
||||
|
||||
it('does not focus or snap to today when today has no programmes', () => {
|
||||
// Programmes only three days out → today is empty; auto-focus must
|
||||
// leave the user's day navigation alone instead of forcing today.
|
||||
focus({} as HTMLElement, [programAt(3 * 1440, 60)]);
|
||||
// leave the user's day navigation alone instead of forcing today —
|
||||
// even across a collapse/expand remount (a new scroller element).
|
||||
const p = [programAt(3 * 1440, 60)];
|
||||
focus({} as HTMLElement, p);
|
||||
focus({} as HTMLElement, p); // remount → still no focus/snap
|
||||
expect(scrollSpy).not.toHaveBeenCalled();
|
||||
expect(commitDaySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips focus entirely when the ribbon is not mounted', () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ export function programsFocusKey(programs: readonly EpgProgram[]): string {
|
||||
export class TimelineScrollController {
|
||||
private scrollFrame = 0;
|
||||
private lastFocusKey: string | null = null;
|
||||
private lastScroller: HTMLElement | null = null;
|
||||
|
||||
constructor(private readonly ctx: TimelineScrollContext) {}
|
||||
|
||||
@@ -51,12 +52,12 @@ export class TimelineScrollController {
|
||||
this.scrollToOffset(offsetMin, 0.5, smooth);
|
||||
}
|
||||
|
||||
scrollToDateKey(dateKey: string, frac: number): void {
|
||||
scrollToDateKey(dateKey: string, frac: number, smooth = true): void {
|
||||
const axis = this.ctx.axis();
|
||||
const noonMs =
|
||||
parseEpgDateKey(dateKey).getTime() + 12 * 60 * TIMELINE_MINUTE_MS;
|
||||
const offsetMin = (noonMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
this.scrollToOffset(offsetMin, frac);
|
||||
this.scrollToOffset(offsetMin, frac, smooth);
|
||||
}
|
||||
|
||||
scrollToOffset(offsetMin: number, frac: number, smooth = true): void {
|
||||
@@ -105,12 +106,22 @@ export class TimelineScrollController {
|
||||
programs: readonly EpgProgram[]
|
||||
): void {
|
||||
const key = programsFocusKey(programs);
|
||||
// Skip (without clearing lastFocusKey) when there's no ribbon yet, no
|
||||
// programmes, or we've already focused this channel. Re-running for the
|
||||
// same channel — e.g. when the ribbon remounts after the user navigates
|
||||
// to another day — must NOT re-focus, or `commitDay(today)` below would
|
||||
// snap the view back to today and trap the user on an empty day.
|
||||
if (!scroller || !key || key === this.lastFocusKey) {
|
||||
// Nothing to centre without a ribbon or programmes.
|
||||
if (!scroller || !key) {
|
||||
return;
|
||||
}
|
||||
if (key === this.lastFocusKey) {
|
||||
// Same channel already focused. A *new* scroller element means the
|
||||
// ribbon was unmounted then remounted (e.g. the inline panel was
|
||||
// collapsed and re-expanded), which resets scrollLeft to 0 — restore
|
||||
// the viewed day without committing a different day, so the user
|
||||
// lands back on their programme rather than the far-left of the
|
||||
// guide. The same element (data re-emit / now-tick) is left alone so
|
||||
// we never yank the viewport out from under the user.
|
||||
if (scroller !== this.lastScroller) {
|
||||
this.lastScroller = scroller;
|
||||
this.restorePosition();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const todayKey = getTodayEpgDateKey();
|
||||
@@ -121,8 +132,22 @@ export class TimelineScrollController {
|
||||
return;
|
||||
}
|
||||
this.lastFocusKey = key;
|
||||
this.lastScroller = scroller;
|
||||
this.ctx.commitDay(todayKey);
|
||||
// Instant: land already centred, no annoying scroll animation.
|
||||
this.focusCurrentProgram(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-centre the currently-viewed day after the ribbon remounts (which resets
|
||||
* scrollLeft). Restores "now" for today, otherwise the viewed day — never
|
||||
* commits a different day, so a user parked on another day stays there.
|
||||
*/
|
||||
private restorePosition(): void {
|
||||
if (this.ctx.viewDayKey() === getTodayEpgDateKey()) {
|
||||
this.focusCurrentProgram(false);
|
||||
} else {
|
||||
this.scrollToDateKey(this.ctx.viewDayKey(), 0.5, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user