feat(epg): implement timeout for channel program retrieval and refactor EPG list component with signals

This commit is contained in:
4gray committed 2026-03-28 15:42:08 +01:00
1 parent 145fe74781
commit d76287e63d
5 files changed
+285 -240

No files matched your search

@@ -68,6 +68,7 @@ export class EpgService {
from(window.electron.getChannelPrograms(channelId))
.pipe(
timeout(3000),
map((programs: EpgProgram[]) =>
programs.map((program) => ({
...program,
@@ -0,0 +1,69 @@
import { createReducer } from '@ngrx/store';
import { EpgActions } from '../actions';
import { initialState } from '../state';
import { epgReducers } from './epg.reducers';
const reducer = createReducer(initialState, ...epgReducers);
describe('epgReducers', () => {
it('does not recreate the active channel when epg params are already empty', () => {
const state = {
...initialState,
active: {
id: 'channel-1',
name: 'ARD-alpha',
url: 'https://example.com/live.m3u8',
tvg: {
id: '',
name: 'ARD-alpha',
url: '',
logo: '',
rec: '',
},
group: { title: '' },
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
},
};
const nextState = reducer(state, EpgActions.resetActiveEpgProgram());
expect(nextState).toBe(state);
});
it('clears epg params when they are present', () => {
const state = {
...initialState,
active: {
id: 'channel-1',
name: 'ARD-alpha',
url: 'https://example.com/live.m3u8',
tvg: {
id: '',
name: 'ARD-alpha',
url: '',
logo: '',
rec: '',
},
group: { title: '' },
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '?utc=123&lutc=456',
},
};
const nextState = reducer(state, EpgActions.resetActiveEpgProgram());
expect(nextState).not.toBe(state);
expect(nextState.active?.epgParams).toBe('');
});
});
@@ -1,14 +1,44 @@
import { on } from '@ngrx/store';
import moment from 'moment';
import { Channel } from 'shared-interfaces';
import { EpgActions } from '../actions';
import { PlaylistState } from '../state';
function toUnixTimestamp(value: string): number {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed / 1000);
}
const match = value.match(
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})$/
);
if (!match) {
return Math.floor(Date.now() / 1000);
}
const [, year, month, day, hour, minute, offsetHours, offsetMinutes] =
match;
const utcMillis = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute)
);
const offsetTotalMinutes =
Number(offsetHours) * 60 +
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
return Math.floor(
(utcMillis - offsetTotalMinutes * 60_000) / 1000
);
}
export const epgReducers = [
on(EpgActions.setActiveEpgProgram, (state, action): PlaylistState => {
const { program } = action;
const from = moment(program.start, 'YYYYMMDDHHmm ZZ').unix();
const now = moment(Date.now()).unix();
const from = toUnixTimestamp(program.start);
const now = Math.floor(Date.now() / 1000);
const epgParams = `?utc=${from}&lutc=${now}`;
return {
...state,
@@ -19,12 +49,20 @@ export const epgReducers = [
}),
on(
EpgActions.resetActiveEpgProgram,
(state): PlaylistState => ({
...state,
active: state.active
? ({ ...state.active, epgParams: '' } as Channel)
: undefined,
})
(state): PlaylistState => {
if (!state.active) {
return state;
}
if (!state.active.epgParams) {
return state;
}
return {
...state,
active: { ...state.active, epgParams: '' } as Channel,
};
}
),
on(
EpgActions.setCurrentEpgProgram,
@@ -1,5 +1,5 @@
@let items = filteredItems$ | async;
@let timeshiftUntil = timeshiftUntil$ | async;
@let items = filteredItems();
@let timeshiftLimit = timeshiftUntil();
<!-- channel info panel -->
<div id="channel-header" color="primary">
@@ -13,7 +13,9 @@
<mat-icon>chevron_left</mat-icon>
</button>
<span class="selected-date">
{{ dateToday | momentDate: 'MMMM Do, dddd' : 'YYYY-MM-DD' }}</span
{{
selectedDate() | momentDate: 'MMMM Do, dddd' : 'YYYY-MM-DD'
}}</span
>
<button
class="next-day"
@@ -26,13 +28,13 @@
</div>
</div>
@if (timeshiftUntil) {
@if (timeshiftLimit) {
<!-- program list -->
<div id="program-list" #programList>
@if (items?.length > 0) {
@if (items.length > 0) {
@for (program of items; track $index) {
@if (
program.start < timeshiftUntil || program.start >= timeNow
program.start < timeshiftLimit || program.start >= timeNow()
) {
<div
class="program-item"
@@ -40,8 +42,8 @@
>
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[timeshiftUntil]="timeshiftLimit"
[timeNow]="timeNow()"
[item]="program"
/>
</div>
@@ -69,21 +71,22 @@
class="program-item clickable"
[class.current-program]="isProgramPlaying(program)"
[class.active]="
timeNow >= program.start && timeNow <= program.stop
timeNow() >= program.start &&
timeNow() <= program.stop
"
(click)="
setEpgProgram(
program,
timeNow >= program.start &&
timeNow <= program.stop,
program.start > timeshiftUntil
timeNow() >= program.start &&
timeNow() <= program.stop,
program.start > timeshiftLimit
)
"
>
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[timeshiftUntil]="timeshiftLimit"
[timeNow]="timeNow()"
[item]="program"
/>
</div>
+151 -217
View File
@@ -1,15 +1,15 @@
import { AsyncPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
ElementRef,
inject,
Injector,
OnInit,
input,
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';
@@ -20,20 +20,13 @@ import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgActions, selectActive } from 'm3u-state';
import moment from 'moment';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { EpgChannel, EpgProgram } from 'shared-interfaces';
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
export interface EpgData {
channel: EpgChannel;
items: EpgProgram[];
}
const DATE_FORMAT = 'YYYY-MM-DD';
@Component({
imports: [
AsyncPipe,
EpgListItemComponent,
FormsModule,
MatIcon,
@@ -47,250 +40,186 @@ const DATE_FORMAT = 'YYYY-MM-DD';
styleUrls: ['./epg-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EpgListComponent implements OnInit {
export class EpgListComponent {
readonly controlledChannel = input<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly injector = inject(Injector);
/** Channel info in EPG format */
channel!: EpgChannel;
/** Today as formatted date string */
dateToday!: string;
/** Array with EPG programs */
items$ = this.epgService.currentEpgPrograms$;
/** Object with epg programs for the active channel */
programs!: {
payload: EpgData;
};
/** EPG selected program */
playingNow: EpgProgram | undefined;
/** Selected date */
selectedDate!: string;
/** Current time as formatted string */
timeNow!: string;
/** Timeshift availability date, based on tvg-rec value from the channel */
readonly timeshiftUntil$ = this.store.select(selectActive).pipe(
map((active) => {
// Create EpgChannel with proper structure
const displayNames = active?.name
? [{ lang: '', value: active.name }]
: [];
const icons = active?.tvg?.logo ? [{ src: active.tvg.logo }] : [];
this.channel = {
id: active?.tvg?.id || '',
displayName: displayNames,
url: active?.url ? [active.url] : [],
icon: icons,
};
return (
active?.tvg?.rec || active?.timeshift || active?.catchup?.days
);
}),
map((value) => {
const days = Number(value ?? 0) || 0;
return moment().subtract(days, 'days').toISOString();
})
private readonly activeChannel = toSignal(
this.store.select(selectActive),
{ initialValue: null }
);
private readonly servicePrograms = toSignal(
this.epgService.currentEpgPrograms$,
{ initialValue: [] as EpgProgram[] }
);
private readonly selectedDate$ = new BehaviorSubject<string>(
moment().format(DATE_FORMAT)
);
readonly programList = viewChild<ElementRef<HTMLElement>>('programList');
private scrollScheduled = false;
readonly selectedDate = signal(moment().format(DATE_FORMAT));
readonly timeNow = signal(new Date().toISOString());
readonly todayDate = signal(moment().format(DATE_FORMAT));
/** Filtered EPG programs based on selected date */
filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe(
map(([items, selectedDate]) =>
items
.filter(
(item) =>
moment(item.start).format('YYYY-MM-DD') === selectedDate
)
.sort((a, b) => moment(a.start).diff(moment(b.start)))
)
readonly isControlled = computed(
() =>
this.controlledChannel() !== null ||
this.controlledPrograms() !== null
);
/**
* Subscribe for values from the store on component init
*/
ngOnInit(): void {
effect(
(onCleanup) => {
const programList = this.programList()?.nativeElement;
if (!programList || typeof ResizeObserver === 'undefined') {
return;
}
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());
},
{ injector: this.injector }
);
this.items$.subscribe((programs) => this.handleEpgData(programs));
this.dateToday = moment().format(DATE_FORMAT);
this.selectedDate$.next(this.dateToday);
}
/**
* Handles incoming epg programs for the active channel from the main process
* @param programs
*/
handleEpgData(programs: EpgProgram[]): void {
this.timeNow = new Date().toISOString();
this.dateToday = moment().format(DATE_FORMAT);
// Dispatch EPG availability flag
this.store.dispatch(
EpgActions.setEpgAvailableFlag({ value: programs.length > 0 })
);
if (programs.length > 0) {
this.setPlayingNow();
} else {
this.channel = {} as EpgChannel;
// Clear the current EPG program when no programs available
this.store.dispatch(EpgActions.resetActiveEpgProgram());
readonly displayChannel = computed(
() => this.controlledChannel() ?? this.activeChannel()
);
readonly channel = computed<EpgChannel | null>(() => {
const channel = this.displayChannel();
if (!channel) {
return null;
}
// Trigger change detection for OnPush strategy
this.cdr.markForCheck();
this.scheduleScrollToCurrentProgram();
}
/**
* Selects the program based on the active date
*/
selectPrograms(programs: { payload: EpgData }): EpgProgram[] {
const selectedDate = moment(this.dateToday).format('YYYY-MM-DD');
return programs.payload?.items
return {
id: channel.tvg?.id || '',
displayName: channel.name ? [{ lang: '', value: channel.name }] : [],
url: channel.url ? [channel.url] : [],
icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [],
};
});
readonly items = computed(
() => this.controlledPrograms() ?? this.servicePrograms() ?? []
);
readonly timeshiftUntil = computed(() => {
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();
});
readonly filteredItems = computed(() =>
[...this.items()]
.filter(
(item) =>
moment(item.start).format('YYYY-MM-DD') === selectedDate
moment(item.start).format(DATE_FORMAT) === this.selectedDate()
)
.map((program) => ({
...program,
start: program.start, // Keep ISO format
stop: program.stop, // Keep ISO format
}))
.sort((a, b) => moment(a.start).diff(moment(b.start)));
}
.sort((a, b) => moment(a.start).diff(moment(b.start)))
);
/**
* Changes the date to update the epg list with programs
* @param direction direction to switch
*/
changeDate(direction: 'next' | 'prev'): void {
const newDate = moment(this.selectedDate$.value)
[direction === 'next' ? 'add' : 'subtract'](1, 'days')
.format(DATE_FORMAT);
private scrollScheduled = false;
this.dateToday = newDate;
this.selectedDate$.next(newDate);
this.scheduleScrollToCurrentProgram();
}
constructor() {
effect((onCleanup) => {
const programList = this.programList()?.nativeElement;
if (!programList || typeof ResizeObserver === 'undefined') {
return;
}
/**
* Sets the playing now variable based on the current time
*/
setPlayingNow(): void {
this.items$
.pipe(
map((items) =>
items.find((item) => {
const now = new Date().toISOString();
const start = new Date(item.start).toISOString();
const stop = new Date(item.stop).toISOString();
return now >= start && now <= stop;
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());
});
effect(() => {
const programs = this.items();
const channel = this.displayChannel();
this.timeNow.set(new Date().toISOString());
this.todayDate.set(moment().format(DATE_FORMAT));
if (!this.isControlled()) {
this.store.dispatch(
EpgActions.setEpgAvailableFlag({
value: programs.length > 0,
})
)
)
.subscribe((playingNow) => {
this.playingNow = playingNow;
);
if (playingNow) {
this.store.dispatch(
EpgActions.setCurrentEpgProgram({ program: playingNow })
);
if (programs.length > 0) {
const currentProgram = this.findCurrentProgram(programs);
if (currentProgram) {
this.store.dispatch(
EpgActions.setCurrentEpgProgram({
program: currentProgram,
})
);
} else {
this.store.dispatch(
EpgActions.resetActiveEpgProgram()
);
}
} else {
this.store.dispatch(EpgActions.resetActiveEpgProgram());
}
}
// Trigger change detection for OnPush strategy
this.cdr.markForCheck();
});
if (!channel && !this.isControlled()) {
this.store.dispatch(EpgActions.resetActiveEpgProgram());
}
this.scheduleScrollToCurrentProgram();
});
}
changeDate(direction: 'next' | 'prev'): void {
this.selectedDate.set(
moment(this.selectedDate())
[direction === 'next' ? 'add' : 'subtract'](1, 'days')
.format(DATE_FORMAT)
);
this.scheduleScrollToCurrentProgram();
}
/**
* Sets the provided epg program as active and starts to play
* @param program epg program to set
* @param isLive live stream flag
* @param timeshift timeshift flag
*/
setEpgProgram(
program: EpgProgram,
isLive?: boolean,
timeshift?: boolean
): void {
if (isLive) {
this.store.dispatch(EpgActions.resetActiveEpgProgram());
} else {
if (!timeshift) return;
this.store.dispatch(EpgActions.setActiveEpgProgram({ program }));
if (!this.isControlled()) {
if (isLive) {
this.store.dispatch(EpgActions.resetActiveEpgProgram());
} else if (timeshift) {
this.store.dispatch(
EpgActions.setActiveEpgProgram({ program })
);
} else {
return;
}
}
this.playingNow = program;
// Trigger change detection for OnPush strategy
this.cdr.markForCheck();
this.timeNow.set(new Date().toISOString());
}
/**
* Calculates the progress percentage for the EPG 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 total = stop - start;
const elapsed = now - start;
const progress = Math.min(100, Math.max(0, (elapsed / total) * 100));
return progress;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
/**
* Check if program is currently playing
*/
isProgramPlaying(program: EpgProgram): boolean {
const isPlaying =
this.timeNow >= program.start && this.timeNow <= program.stop;
const currentTime = this.timeNow();
return currentTime >= program.start && currentTime <= program.stop;
}
return isPlaying;
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();
return now >= start && now <= stop;
});
}
private scheduleScrollToCurrentProgram(): void {
if (this.selectedDate$.value !== moment().format(DATE_FORMAT)) {
if (this.selectedDate() !== moment().format(DATE_FORMAT)) {
return;
}
@@ -319,16 +248,21 @@ export class EpgListComponent implements OnInit {
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
const itemTop = currentProgram.offsetTop;
const itemBottom = itemTop + currentProgram.offsetHeight;
const elementTop = currentProgram.offsetTop;
const elementBottom = elementTop + currentProgram.offsetHeight;
if (itemTop >= viewTop && itemBottom <= viewBottom) {
if (elementTop < viewTop) {
container.scrollTo({ top: Math.max(elementTop - 16, 0) });
return;
}
currentProgram.scrollIntoView({
block: 'center',
inline: 'nearest',
});
if (elementBottom > viewBottom) {
container.scrollTo({
top:
elementBottom -
container.clientHeight +
16,
});
}
}
}