diff --git a/libs/epg/data-access/src/lib/epg.service.ts b/libs/epg/data-access/src/lib/epg.service.ts
index 0e857645d..9e41ecd44 100644
--- a/libs/epg/data-access/src/lib/epg.service.ts
+++ b/libs/epg/data-access/src/lib/epg.service.ts
@@ -68,6 +68,7 @@ export class EpgService {
from(window.electron.getChannelPrograms(channelId))
.pipe(
+ timeout(3000),
map((programs: EpgProgram[]) =>
programs.map((program) => ({
...program,
diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts
new file mode 100644
index 000000000..8916bfc69
--- /dev/null
+++ b/libs/m3u-state/src/lib/reducers/epg.reducers.spec.ts
@@ -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('');
+ });
+});
diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.ts
index b00ddbb60..6ff313be4 100644
--- a/libs/m3u-state/src/lib/reducers/epg.reducers.ts
+++ b/libs/m3u-state/src/lib/reducers/epg.reducers.ts
@@ -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,
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 06612168c..8349c4f9e 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
@@ -1,5 +1,5 @@
-@let items = filteredItems$ | async;
-@let timeshiftUntil = timeshiftUntil$ | async;
+@let items = filteredItems();
+@let timeshiftLimit = timeshiftUntil();
-@if (timeshiftUntil) {
+@if (timeshiftLimit) {
- @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()
) {
@@ -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
)
"
>
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 20a431785..b5560c656 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,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
(null);
+ readonly controlledPrograms = input(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(
- moment().format(DATE_FORMAT)
- );
readonly programList = viewChild>('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(() => {
+ 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,
+ });
+ }
}
}