feat: unify live epg toolbar

Entire-Checkpoint: 978eaff2e478
This commit is contained in:
4gray committed 2026-04-29 00:20:34 +02:00
1 parent d163a1fbf2
commit 31258624b5
23 files changed
+648 -123

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export * from './lib/epg-list/epg-list.component';
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
export * from './lib/epg-date';
export * from './lib/epg-progress-panel/epg-progress-panel.component';
export * from './lib/epg-source-status/epg-source-status.component';
export * from './lib/multi-epg/multi-epg-container.component';
+39
View File
@@ -0,0 +1,39 @@
import { addDays, format, subDays } from 'date-fns';
export const EPG_DATE_KEY_FORMAT = 'yyyy-MM-dd';
export type EpgDateNavigationDirection = 'next' | 'prev';
export function getTodayEpgDateKey(): string {
return format(new Date(), EPG_DATE_KEY_FORMAT);
}
export function parseEpgDateKey(
dateKey: string | null | undefined,
fallbackDateKey = getTodayEpgDateKey()
): Date {
const normalizedDateKey =
typeof dateKey === 'string' && dateKey.trim().length > 0
? dateKey
: fallbackDateKey;
const parsedDate = new Date(`${normalizedDateKey}T00:00:00`);
if (Number.isFinite(parsedDate.getTime())) {
return parsedDate;
}
return new Date(`${fallbackDateKey}T00:00:00`);
}
export function shiftEpgDateKey(
dateKey: string | null | undefined,
direction: EpgDateNavigationDirection
): string {
const selectedDate = parseEpgDateKey(dateKey);
return format(
direction === 'next'
? addDays(selectedDate, 1)
: subDays(selectedDate, 1),
EPG_DATE_KEY_FORMAT
);
}
@@ -1,33 +1,35 @@
@let items = filteredItems();
@let timeshiftLimit = timeshiftUntil();
<!-- channel info panel -->
<div id="channel-header" color="primary">
<div id="date-navigator">
<button
class="previous-day"
mat-icon-button
(click)="changeDate('prev')"
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="selected-date">
{{
selectedDate()
| date: 'MMMM d, EEEE' : undefined : currentLocale()
}}</span
>
<button
class="next-day"
mat-icon-button
(click)="changeDate('next')"
[matTooltip]="'EPG.NEXT_DAY' | translate"
>
<mat-icon>chevron_right</mat-icon>
</button>
@if (showDateNavigator()) {
<!-- channel info panel -->
<div id="channel-header" color="primary">
<div id="date-navigator">
<button
class="previous-day"
mat-icon-button
(click)="changeDate('prev')"
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="selected-date">
{{
selectedDateKey()
| date: 'MMMM d, EEEE' : undefined : currentLocale()
}}</span
>
<button
class="next-day"
mat-icon-button
(click)="changeDate('next')"
[matTooltip]="'EPG.NEXT_DAY' | translate"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</div>
</div>
}
@if (timeshiftLimit) {
<!-- program list -->
@@ -243,6 +243,34 @@ describe('EpgListComponent', () => {
.textContent.trim()
).toContain('Sonntag');
});
it('can hide its internal date navigator for a parent-owned toolbar', () => {
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.componentRef.setInput('showDateNavigator', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('#channel-header')
).toBeNull();
expect(
fixture.nativeElement.querySelector('#program-list')
).not.toBeNull();
});
it('emits date changes when the selected date is controlled', () => {
const emittedDates: string[] = [];
component.selectedDateChange.subscribe((date) =>
emittedDates.push(date)
);
fixture.componentRef.setInput('selectedDate', '2026-04-04');
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
fixture.detectChanges();
component.changeDate('next');
expect(emittedDates).toEqual(['2026-04-05']);
expect(component.selectedDateKey()).toBe('2026-04-04');
});
});
function buildPrograms(overrides?: {
@@ -253,11 +281,9 @@ function buildPrograms(overrides?: {
}): 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 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 futureStart = overrides?.futureStart ?? '2026-04-05T12:30:00.000Z';
const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z';
return [
@@ -20,13 +20,17 @@ import { normalizeDateLocale } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgActions, selectActive } from 'm3u-state';
import { addDays, format, subDays } from 'date-fns';
import { format, subDays } from 'date-fns';
import { startWith } from 'rxjs';
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces';
import {
EPG_DATE_KEY_FORMAT,
EpgDateNavigationDirection,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '../epg-date';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
const DATE_FORMAT = 'yyyy-MM-dd';
export interface EpgProgramActivationEvent {
program: EpgProgram;
type: 'live' | 'timeshift';
@@ -51,7 +55,12 @@ export class EpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly selectedDateInput = input<string | null>(null, {
alias: 'selectedDate',
});
readonly showDateNavigator = input(true);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly selectedDateChange = output<string>();
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
@@ -70,7 +79,10 @@ export class EpgListComponent {
);
readonly programList = viewChild<ElementRef<HTMLElement>>('programList');
readonly selectedDate = signal(format(new Date(), DATE_FORMAT));
readonly internalSelectedDate = signal(getTodayEpgDateKey());
readonly selectedDateKey = computed(
() => this.selectedDateInput() ?? this.internalSelectedDate()
);
readonly timeNow = signal(new Date().toISOString());
readonly currentTimeMs = computed(() => Date.parse(this.timeNow()));
readonly currentLocale = computed(() => {
@@ -85,8 +97,8 @@ export class EpgListComponent {
this.controlledChannel() !== null ||
this.controlledPrograms() !== null
);
readonly displayChannel = computed(
() => this.controlledChannel() ?? this.activeChannel()
readonly displayChannel = computed<Channel | null>(
() => this.controlledChannel() ?? this.activeChannel() ?? null
);
readonly channel = computed<EpgChannel | null>(() => {
const channel = this.displayChannel();
@@ -96,7 +108,9 @@ export class EpgListComponent {
return {
id: channel.tvg?.id || '',
displayName: channel.name ? [{ lang: '', value: channel.name }] : [],
displayName: channel.name
? [{ lang: '', value: channel.name }]
: [],
url: channel.url ? [channel.url] : [],
icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [],
};
@@ -119,14 +133,14 @@ export class EpgListComponent {
subDays(new Date(), this.archiveDays()).toISOString()
);
readonly archivePlaybackEnabled = computed(
() => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0)
() => this.archivePlaybackAvailable() ?? this.archiveDays() > 0
);
readonly filteredItems = computed(() =>
[...this.items()]
.filter(
(item) =>
getProgramDateKey(item.start, item.startTimestamp) ===
this.selectedDate()
this.selectedDateKey()
)
.sort(
(left, right) =>
@@ -185,16 +199,14 @@ export class EpgListComponent {
});
}
changeDate(direction: 'next' | 'prev'): void {
const selectedDate = new Date(`${this.selectedDate()}T00:00:00`);
this.selectedDate.set(
format(
direction === 'next'
? addDays(selectedDate, 1)
: subDays(selectedDate, 1),
DATE_FORMAT
)
);
changeDate(direction: EpgDateNavigationDirection): void {
const nextDate = shiftEpgDateKey(this.selectedDateKey(), direction);
if (this.selectedDateInput() == null) {
this.internalSelectedDate.set(nextDate);
}
this.selectedDateChange.emit(nextDate);
}
activateProgram(program: EpgProgram): void {
@@ -225,7 +237,8 @@ export class EpgListComponent {
canActivateProgram(program: EpgProgram): boolean {
return (
this.isProgramPlaying(program) || this.canPlayArchivedProgram(program)
this.isProgramPlaying(program) ||
this.canPlayArchivedProgram(program)
);
}
@@ -240,7 +253,9 @@ export class EpgListComponent {
const total = stop - start;
const elapsed = now - start;
return total > 0 ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0;
return total > 0
? Math.min(100, Math.max(0, (elapsed / total) * 100))
: 0;
}
isProgramPlaying(program: EpgProgram): boolean {
@@ -289,7 +304,7 @@ export class EpgListComponent {
}
private scheduleScrollToCurrentProgram(): void {
if (this.selectedDate() !== format(new Date(), DATE_FORMAT)) {
if (this.selectedDateKey() !== getTodayEpgDateKey()) {
return;
}
@@ -385,5 +400,5 @@ function getProgramDateKey(
return '';
}
return format(new Date(programTimeMs), DATE_FORMAT);
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
}