feat: enhance EPG list component with program activation and scrolling improvements

This commit is contained in:
4gray committed 2026-04-05 19:04:09 +02:00
1 parent dd265f15e9
commit 826d8d24b1
6 files changed
+443 -238

No files matched your search

+15
View File
@@ -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"
}
@@ -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'
@@ -91,6 +91,7 @@ describe('EpgListItemComponent', () => {
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
data: {},
width: '800px',
});
});
});
@@ -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)"
>
<div class="program-meta">
<app-epg-list-item
@@ -1,194 +1,303 @@
/* import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { BehaviorSubject, Observable } from 'rxjs';
import { Component, input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MockPipe } from 'ng-mocks';
import { BehaviorSubject, of } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { DataService, ElectronServiceStub } from 'services';
import { MomentDatePipe } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
import { EpgListComponent } from './epg-list.component';
// Update moment mock to handle namespace import
jest.mock('moment', () => {
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<EpgProgram>();
readonly timeNow = input('');
readonly timeshiftUntil = input('');
}
describe('EpgListComponent', () => {
let component: EpgListComponent;
let fixture: ComponentFixture<EpgListComponent>;
let electronService: DataService;
let mockStore: MockStore;
let epgService: EpgService;
const actions$ = new Observable<Actions>();
let component: EpgListComponent;
let store: { select: jest.Mock; dispatch: jest.Mock };
let epgService: { currentEpgPrograms$: BehaviorSubject<EpgProgram[]> };
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<EpgProgram[]>([]),
};
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<EpgProgram[]>).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<EpgListComponent>
): 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);
}
}
@@ -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<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly programActivated = output<EpgProgramActivationEvent>();
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<HTMLElement>(
'.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);
}