From 826d8d24b14db59c00a023a93446f41e0d7eb39a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 5 Apr 2026 16:04:53 +0200 Subject: [PATCH] feat: enhance EPG list component with program activation and scrolling improvements --- libs/ui/epg/project.json | 15 + .../epg-item-description.component.spec.ts | 8 +- .../epg-list-item.component.spec.ts | 1 + .../src/lib/epg-list/epg-list.component.html | 9 +- .../lib/epg-list/epg-list.component.spec.ts | 435 +++++++++++------- .../src/lib/epg-list/epg-list.component.ts | 213 ++++++--- 6 files changed, 443 insertions(+), 238 deletions(-) diff --git a/libs/ui/epg/project.json b/libs/ui/epg/project.json index bc80a674b..5dcfa878d 100644 --- a/libs/ui/epg/project.json +++ b/libs/ui/epg/project.json @@ -6,6 +6,21 @@ "projectType": "library", "tags": ["scope:shared", "domain:playback", "type:ui"], "targets": { + "test": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "command": [ + "node", + "./tools/testing/run-web-esm-lib-tests.mjs", + "libs/ui/epg/src" + ], + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + }, + "forwardAllArgs": true + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts index cc3e4d442..3c97f1575 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts @@ -21,6 +21,8 @@ describe('EpgItemDescriptionComponent', () => { { provide: MAT_DIALOG_DATA, useValue: { + start: '2026-04-05T11:30:00.000Z', + stop: '2026-04-05T12:30:00.000Z', title: 'TV Show 1', desc: 'Highly interesting show about pets', category: 'Fun', @@ -44,21 +46,21 @@ describe('EpgItemDescriptionComponent', () => { it('should render epg details in the dialog', () => { fixture.detectChanges(); const titleElement = fixture.debugElement.query( - By.css('[data-test="title"]') + By.css('.program-title') ); expect(titleElement.nativeElement.textContent.trim()).toContain( 'TV Show 1' ); const categoryElement = fixture.debugElement.query( - By.css('[data-test="category"]') + By.css('.category-tag') ); expect(categoryElement.nativeElement.textContent.trim()).toContain( 'Fun' ); const descElement = fixture.debugElement.query( - By.css('[data-test="desc"]') + By.css('.program-description') ); expect(descElement.nativeElement.textContent.trim()).toContain( 'Highly interesting show about pets' diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts index 92f23bfef..17355cba5 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts @@ -91,6 +91,7 @@ describe('EpgListItemComponent', () => { expect(dialog.open).toHaveBeenCalledTimes(1); expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, { data: {}, + width: '800px', }); }); }); diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index 8349c4f9e..73ffb9caa 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -74,14 +74,7 @@ timeNow() >= program.start && timeNow() <= program.stop " - (click)=" - setEpgProgram( - program, - timeNow() >= program.start && - timeNow() <= program.stop, - program.start > timeshiftLimit - ) - " + (click)="activateProgram(program)" >
{ - const momentFunc = () => ({ - format: () => '2023-01-01', - subtract: () => ({ - toISOString: () => '2023-01-01T00:00:00Z', - format: () => '2023-01-01', - }), - diff: () => 0, - add: () => ({ - format: () => '2023-01-01', - }), - }); - momentFunc.default = momentFunc; - return momentFunc; -}); +let requestAnimationFrameQueue: FrameRequestCallback[] = []; + +@Component({ + selector: 'app-epg-list-item', + standalone: true, + template: '', +}) +class StubEpgListItemComponent { + readonly item = input(); + readonly timeNow = input(''); + readonly timeshiftUntil = input(''); +} describe('EpgListComponent', () => { - let component: EpgListComponent; let fixture: ComponentFixture; - let electronService: DataService; - let mockStore: MockStore; - let epgService: EpgService; - const actions$ = new Observable(); + let component: EpgListComponent; + let store: { select: jest.Mock; dispatch: jest.Mock }; + let epgService: { currentEpgPrograms$: BehaviorSubject }; + let originalRequestAnimationFrame: typeof window.requestAnimationFrame; - const MOCKED_PROGRAMS = { - channel: { - id: '12345', - name: [ - { - lang: 'ab', - value: 'test me', - }, - { - lang: 'ar', - value: 'ar test', - }, - ], - icon: [], - url: [], + const fixedNow = new Date('2026-04-05T12:00:00.000Z'); + const controlledChannel: Channel = { + id: 'channel-101', + name: 'Channel 101', + url: 'https://example.com/live.m3u8', + group: { title: 'News' }, + tvg: { + id: 'channel-101', + name: 'Channel 101', + url: '', + logo: 'channel-101.png', + rec: '3', }, - items: [ - { - start: '2023-01-01', - stop: '2023-01-01', - channel: '12345', - title: [{ lang: 'en', value: 'NOW on PBS' }], - desc: [ - { - lang: 'en', - value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.", - }, - ], - date: ['20080711'], - category: [ - { lang: 'en', value: 'Newsmagazine' }, - { lang: 'en', value: 'Interview' }, - ], - episodeNum: [ - { system: 'dd_progid', value: 'EP01006886.0028' }, - { system: 'onscreen', value: '427' }, - ], - previouslyShown: [{ start: '20080711000000' }], - subtitles: [{ type: 'teletext' }], - rating: [ - { - system: 'VCHIP', - value: 'TV-G', - }, - ], - credits: [ - { - role: 'actor', - name: 'Peter Bergman', - }, - ], - icon: [ - 'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg', - ], - audio: [], - _attributes: { - start: '2023-01-01', - stop: '2023-01-01', - }, - }, - ], + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: 'false', + epgParams: '', }; - beforeEach(waitForAsync(() => { - const mockEpgService = { + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(fixedNow); + + originalRequestAnimationFrame = window.requestAnimationFrame; + requestAnimationFrameQueue = []; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + requestAnimationFrameQueue.push(callback); + return requestAnimationFrameQueue.length; + }) as typeof window.requestAnimationFrame; + + store = { + select: jest.fn().mockReturnValue(of(null)), + dispatch: jest.fn(), + }; + epgService = { currentEpgPrograms$: new BehaviorSubject([]), }; - TestBed.configureTestingModule({ - imports: [ - EpgListComponent, - MockPipe(MomentDatePipe), - TranslateModule.forRoot(), - MockComponent(EpgListItemComponent), - MockModule(MatIconModule), - MockModule(MatTooltipModule), - MockModule(MatListModule), - MockModule(MatDialogModule), - ], + await TestBed.configureTestingModule({ + imports: [EpgListComponent, NoopAnimationsModule], providers: [ - { provide: DataService, useClass: ElectronServiceStub }, - { provide: EpgService, useValue: mockEpgService }, - MockProvider(MatDialog), - provideMockStore(), - provideMockActions(actions$), + { provide: Store, useValue: store }, + { provide: EpgService, useValue: epgService }, ], - }).compileComponents(); - })); + }) + .overrideComponent(EpgListComponent, { + remove: { + imports: [ + EpgListItemComponent, + MomentDatePipe, + TranslatePipe, + ], + }, + add: { + imports: [ + StubEpgListItemComponent, + MockPipe( + MomentDatePipe, + ( + value: string | null | undefined, + format?: string + ) => { + if (!value) { + return ''; + } + + const parsed = new Date(value); + if (format === 'HH:mm') { + return parsed + .toISOString() + .slice(11, 16); + } + + return value.slice(0, 10); + } + ), + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(EpgListComponent); component = fixture.componentInstance; - electronService = TestBed.inject(DataService); - epgService = TestBed.inject(EpgService); + fixture.componentRef.setInput('controlledChannel', controlledChannel); + fixture.componentRef.setInput('controlledArchiveDays', 3); + }); + + afterEach(() => { + fixture.destroy(); + window.requestAnimationFrame = originalRequestAnimationFrame; + jest.useRealTimers(); + }); + + it('auto-scrolls once on the initial current-day load', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); - mockStore = TestBed.inject(MockStore); - mockStore.setState({ - playlistState: { - active: { - id: '', - url: '', - name: '', - group: { title: '' }, - tvg: { - rec: '3', - }, - } as unknown as Channel, - }, - }); fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + + flushAnimationFrames(); + + expect(scrollToSpy).toHaveBeenCalledTimes(1); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 172 }); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('does not re-trigger auto-scroll on timer-driven updates', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); + + scrollToSpy.mockClear(); + jest.advanceTimersByTime(30_000); + fixture.detectChanges(); + flushAnimationFrames(); + + expect(scrollToSpy).not.toHaveBeenCalled(); }); - it('should handle an empty epg programs object', () => { - const emptyPrograms: EpgProgram[] = []; - (epgService.currentEpgPrograms$ as BehaviorSubject).next( - emptyPrograms + it('keeps older same-day rows reachable after the initial scroll', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + const programList = fixture.nativeElement.querySelector( + '#program-list' + ) as HTMLElement; + flushAnimationFrames(); + + scrollToSpy.mockClear(); + programList.scrollTop = 0; + jest.advanceTimersByTime(30_000); + fixture.detectChanges(); + flushAnimationFrames(); + + expect(programList.scrollTop).toBe(0); + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + + it('does not re-arm auto-scroll when changing dates for the same selection', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + const scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); + + scrollToSpy.mockClear(); + component.changeDate('prev'); + fixture.detectChanges(); + component.changeDate('next'); + fixture.detectChanges(); + flushAnimationFrames(); + + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + + it('re-arms auto-scroll when the effective selection changes', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + + fixture.detectChanges(); + let scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); + scrollToSpy.mockClear(); + + fixture.componentRef.setInput( + 'controlledPrograms', + buildPrograms({ + currentStart: '2026-04-05T11:45:00.000Z', + currentStop: '2026-04-05T12:45:00.000Z', + futureStart: '2026-04-05T12:45:00.000Z', + futureStop: '2026-04-05T13:45:00.000Z', + }) ); - component.handleEpgData(emptyPrograms); fixture.detectChanges(); - expect(component.timeNow).toBeTruthy(); - expect(component.dateToday).toBeTruthy(); - expect(component.channel).toBeNull(); - // Use async pipe or subscribe to test the Observable - component.items$.subscribe((items) => { - expect(items).toHaveLength(0); - }); - }); + scrollToSpy = configureScrollableProgramList(fixture); + flushAnimationFrames(); - it('should set epg program as active', () => { - jest.spyOn(mockStore, 'dispatch'); - component.setEpgProgram(MOCKED_PROGRAMS.items[0], false, true); - expect(mockStore.dispatch).toHaveBeenCalledTimes(1); - expect(mockStore.dispatch).toHaveBeenCalledWith({ - program: MOCKED_PROGRAMS.items[0], - type: expect.stringContaining('epg program'), - }); - }); - - it('should reset active epg program', () => { - jest.spyOn(mockStore, 'dispatch'); - component.setEpgProgram(MOCKED_PROGRAMS.items[0], true); - expect(mockStore.dispatch).toHaveBeenCalledTimes(1); - component.setEpgProgram(MOCKED_PROGRAMS.items[0], true, true); - expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + expect(scrollToSpy).toHaveBeenCalledTimes(1); }); }); - */ + +function buildPrograms(overrides?: { + currentStart?: string; + currentStop?: string; + futureStart?: string; + futureStop?: string; +}): EpgProgram[] { + const pastStart = '2026-04-05T09:00:00.000Z'; + const pastStop = '2026-04-05T10:00:00.000Z'; + const currentStart = + overrides?.currentStart ?? '2026-04-05T11:30:00.000Z'; + const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z'; + const futureStart = + overrides?.futureStart ?? '2026-04-05T12:30:00.000Z'; + const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z'; + + return [ + buildProgram('past', 'Past Show', pastStart, pastStop), + buildProgram('current', 'Current Show', currentStart, currentStop), + buildProgram('future', 'Future Show', futureStart, futureStop), + ]; +} + +function buildProgram( + channelSuffix: string, + title: string, + start: string, + stop: string +): EpgProgram { + return { + start, + stop, + channel: `channel-${channelSuffix}`, + title, + desc: null, + category: null, + startTimestamp: Math.floor(Date.parse(start) / 1000), + stopTimestamp: Math.floor(Date.parse(stop) / 1000), + }; +} + +function configureScrollableProgramList( + fixture: ComponentFixture +): jest.Mock { + const programList = fixture.nativeElement.querySelector( + '#program-list' + ) as HTMLElement; + const currentProgram = fixture.nativeElement.querySelector( + '.program-item.current-program' + ) as HTMLElement; + + let scrollTop = 0; + Object.defineProperty(programList, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }); + Object.defineProperty(programList, 'clientHeight', { + configurable: true, + value: 120, + }); + Object.defineProperty(currentProgram, 'offsetTop', { + configurable: true, + value: 240, + }); + Object.defineProperty(currentProgram, 'offsetHeight', { + configurable: true, + value: 36, + }); + + const scrollToSpy = jest.fn(({ top }: { top: number }) => { + scrollTop = top; + }); + programList.scrollTo = scrollToSpy as typeof programList.scrollTo; + + return scrollToSpy; +} + +function flushAnimationFrames(): void { + while (requestAnimationFrameQueue.length > 0) { + const callback = requestAnimationFrameQueue.shift(); + callback?.(0); + } +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index b5560c656..c036baa86 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -1,16 +1,16 @@ import { ChangeDetectionStrategy, Component, + ElementRef, computed, effect, - ElementRef, inject, input, + output, signal, viewChild, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; @@ -25,10 +25,14 @@ import { EpgListItemComponent } from './epg-list-item/epg-list-item.component'; const DATE_FORMAT = 'YYYY-MM-DD'; +export interface EpgProgramActivationEvent { + program: EpgProgram; + type: 'live' | 'timeshift'; +} + @Component({ imports: [ EpgListItemComponent, - FormsModule, MatIcon, MatIconButton, MatTooltip, @@ -43,14 +47,15 @@ const DATE_FORMAT = 'YYYY-MM-DD'; export class EpgListComponent { readonly controlledChannel = input(null); readonly controlledPrograms = input(null); + readonly controlledArchiveDays = input(null); + readonly programActivated = output(); private readonly store = inject(Store); private readonly epgService = inject(EpgService); - private readonly activeChannel = toSignal( - this.store.select(selectActive), - { initialValue: null } - ); + private readonly activeChannel = toSignal(this.store.select(selectActive), { + initialValue: null, + }); private readonly servicePrograms = toSignal( this.epgService.currentEpgPrograms$, { initialValue: [] as EpgProgram[] } @@ -85,44 +90,47 @@ export class EpgListComponent { readonly items = computed( () => this.controlledPrograms() ?? this.servicePrograms() ?? [] ); - readonly timeshiftUntil = computed(() => { + readonly archiveDays = computed(() => { + const controlledArchiveDays = this.controlledArchiveDays(); + if (controlledArchiveDays !== null) { + return Math.max(0, controlledArchiveDays); + } + const channel = this.displayChannel(); const value = channel?.tvg?.rec || channel?.timeshift || channel?.catchup?.days; - const days = Number(value ?? 0) || 0; - return moment().subtract(days, 'days').toISOString(); + return Math.max(0, Number(value ?? 0) || 0); }); + readonly timeshiftUntil = computed(() => + moment() + .subtract(this.archiveDays(), 'days') + .toISOString() + ); readonly filteredItems = computed(() => [...this.items()] .filter( (item) => - moment(item.start).format(DATE_FORMAT) === this.selectedDate() + moment(item.start).format(DATE_FORMAT) === + this.selectedDate() + ) + .sort( + (left, right) => + getProgramTimeMs(left.start, left.startTimestamp) - + getProgramTimeMs(right.start, right.startTimestamp) ) - .sort((a, b) => moment(a.start).diff(moment(b.start))) ); private scrollScheduled = false; + private activeScrollContextKey: string | null = null; + private lastAutoScrolledContextKey: string | null = null; constructor() { effect((onCleanup) => { - const programList = this.programList()?.nativeElement; - if (!programList || typeof ResizeObserver === 'undefined') { - return; - } + const intervalId = window.setInterval(() => { + this.timeNow.set(new Date().toISOString()); + }, 30_000); - const resizeObserver = new ResizeObserver((entries) => { - const [entry] = entries; - if ( - entry && - entry.contentRect.height > 0 && - entry.contentRect.width > 0 - ) { - this.scheduleScrollToCurrentProgram(); - } - }); - - resizeObserver.observe(programList); - onCleanup(() => resizeObserver.disconnect()); + onCleanup(() => clearInterval(intervalId)); }); effect(() => { @@ -148,9 +156,7 @@ export class EpgListComponent { }) ); } else { - this.store.dispatch( - EpgActions.resetActiveEpgProgram() - ); + this.store.dispatch(EpgActions.resetActiveEpgProgram()); } } else { this.store.dispatch(EpgActions.resetActiveEpgProgram()); @@ -161,6 +167,7 @@ export class EpgListComponent { this.store.dispatch(EpgActions.resetActiveEpgProgram()); } + this.updateScrollContext(channel, programs); this.scheduleScrollToCurrentProgram(); }); } @@ -171,58 +178,106 @@ export class EpgListComponent { [direction === 'next' ? 'add' : 'subtract'](1, 'days') .format(DATE_FORMAT) ); - this.scheduleScrollToCurrentProgram(); } - setEpgProgram( - program: EpgProgram, - isLive?: boolean, - timeshift?: boolean - ): void { - if (!this.isControlled()) { - if (isLive) { - this.store.dispatch(EpgActions.resetActiveEpgProgram()); - } else if (timeshift) { - this.store.dispatch( - EpgActions.setActiveEpgProgram({ program }) - ); - } else { - return; - } + activateProgram(program: EpgProgram): void { + const isLive = this.isProgramPlaying(program); + const isTimeshift = this.isProgramArchived(program); + + if (!isLive && !isTimeshift) { + return; + } + + if (this.isControlled()) { + this.programActivated.emit({ + program, + type: isLive ? 'live' : 'timeshift', + }); + this.timeNow.set(new Date().toISOString()); + return; + } + + if (isLive) { + this.store.dispatch(EpgActions.resetActiveEpgProgram()); + } else { + this.store.dispatch(EpgActions.setActiveEpgProgram({ program })); } this.timeNow.set(new Date().toISOString()); } + canActivateProgram(program: EpgProgram): boolean { + return this.isProgramPlaying(program) || this.isProgramArchived(program); + } + calculateProgress(program: EpgProgram): number { - const now = new Date().getTime(); - const start = new Date(program.start).getTime(); - const stop = new Date(program.stop).getTime(); + const now = Date.now(); + const start = getProgramTimeMs(program.start, program.startTimestamp); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); const total = stop - start; const elapsed = now - start; - return Math.min(100, Math.max(0, (elapsed / total) * 100)); + return total > 0 ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0; } isProgramPlaying(program: EpgProgram): boolean { - const currentTime = this.timeNow(); - return currentTime >= program.start && currentTime <= program.stop; + const now = Date.now(); + const start = getProgramTimeMs(program.start, program.startTimestamp); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); + return now >= start && now <= stop; + } + + isProgramArchived(program: EpgProgram): boolean { + if (this.archiveDays() <= 0) { + return false; + } + + const now = Date.now(); + const start = getProgramTimeMs(program.start, program.startTimestamp); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); + const archiveLimit = Date.parse(this.timeshiftUntil()); + + return stop < now && start >= archiveLimit; } private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined { - const now = new Date().toISOString(); - return programs.find((item) => { - const start = new Date(item.start).toISOString(); - const stop = new Date(item.stop).toISOString(); + const now = Date.now(); + return programs.find((program) => { + const start = getProgramTimeMs( + program.start, + program.startTimestamp + ); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); return now >= start && now <= stop; }); } + private updateScrollContext( + channel: Channel | null, + programs: EpgProgram[] + ): void { + const nextContextKey = buildScrollContextKey(channel, programs); + if (nextContextKey === this.activeScrollContextKey) { + return; + } + + this.activeScrollContextKey = nextContextKey; + this.scrollScheduled = false; + } + private scheduleScrollToCurrentProgram(): void { if (this.selectedDate() !== moment().format(DATE_FORMAT)) { return; } + const scrollContextKey = this.activeScrollContextKey; + if ( + !scrollContextKey || + this.lastAutoScrolledContextKey === scrollContextKey + ) { + return; + } + if (this.scrollScheduled) { return; } @@ -231,12 +286,12 @@ export class EpgListComponent { requestAnimationFrame(() => { requestAnimationFrame(() => { this.scrollScheduled = false; - this.scrollCurrentProgramIntoView(); + this.scrollCurrentProgramIntoView(scrollContextKey); }); }); } - private scrollCurrentProgramIntoView(): void { + private scrollCurrentProgramIntoView(scrollContextKey: string): void { const container = this.programList()?.nativeElement; const currentProgram = container?.querySelector( '.program-item.current-program' @@ -246,6 +301,8 @@ export class EpgListComponent { return; } + this.lastAutoScrolledContextKey = scrollContextKey; + const viewTop = container.scrollTop; const viewBottom = viewTop + container.clientHeight; const elementTop = currentProgram.offsetTop; @@ -258,11 +315,39 @@ export class EpgListComponent { if (elementBottom > viewBottom) { container.scrollTo({ - top: - elementBottom - - container.clientHeight + - 16, + top: elementBottom - container.clientHeight + 16, }); } } } + +function buildScrollContextKey( + channel: Channel | null, + programs: EpgProgram[] +): string | null { + if (!channel && programs.length === 0) { + return null; + } + + const channelKey = + channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel'; + const programKey = programs + .map( + (program) => + `${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}` + ) + .join('|'); + + return `${channelKey}:${programKey}`; +} + +function getProgramTimeMs( + isoValue: string, + timestampValue?: number | null +): number { + if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) { + return Number(timestampValue) * 1000; + } + + return Date.parse(isoValue); +}