mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: enhance EPG list component with program activation and scrolling improvements
This commit is contained in:
1 parent
dd265f15e9
commit
826d8d24b1
6 files changed
+443
-238
No files matched your search
@@ -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"
|
||||
}
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user