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);
}
@@ -25,37 +25,114 @@
{{ 'EPG.CURRENT_PROGRAM' | translate }}
</span>
@if (loading()) {
<span class="live-epg-panel__title">
{{ 'EPG.LOADING' | translate }}
</span>
} @else if (hasSummary()) {
<span class="live-epg-panel__title">
{{ summary()?.title }}
</span>
<div class="live-epg-panel__program-line">
@if (loading()) {
<span class="live-epg-panel__title">
{{ 'EPG.LOADING' | translate }}
</span>
} @else if (hasSummary()) {
<span class="live-epg-panel__title">
{{ summary()?.title }}
</span>
@if (hasTimeRange()) {
<span class="live-epg-panel__time">
@if (summary()?.start) {
{{ summary()?.start | date: 'HH:mm' }}
}
@if (summary()?.start && summary()?.stop) {
-
}
@if (summary()?.stop) {
{{ summary()?.stop | date: 'HH:mm' }}
}
@if (hasTimeRange()) {
<span class="live-epg-panel__time">
@if (summary()?.start) {
{{ summary()?.start | date: 'HH:mm' }}
}
@if (summary()?.start && summary()?.stop) {
-
}
@if (summary()?.stop) {
{{ summary()?.stop | date: 'HH:mm' }}
}
</span>
}
} @else {
<span
class="live-epg-panel__title live-epg-panel__title--muted"
>
{{ 'EPG.NO_PROGRAM_INFO' | translate }}
</span>
}
} @else {
<span
class="live-epg-panel__title live-epg-panel__title--muted"
>
{{ 'EPG.NO_PROGRAM_INFO' | translate }}
</span>
}
</div>
</div>
@if (showDateNavigator()) {
@if (!collapsed()) {
<div
class="live-epg-panel__date-nav"
aria-label="EPG date navigation"
>
<button
class="previous-day"
mat-icon-button
type="button"
(click)="navigateDate('prev')"
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="live-epg-panel__selected-date selected-date">
{{
selectedDateValue()
| date
: 'MMMM d, EEEE'
: undefined
: currentLocale()
}}
</span>
<button
class="next-day"
mat-icon-button
type="button"
(click)="navigateDate('next')"
[matTooltip]="'EPG.NEXT_DAY' | translate"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<button
mat-button
type="button"
class="live-epg-panel__date-chip"
[matMenuTriggerFor]="liveEpgDateMenu"
[matTooltip]="
selectedDateValue()
| date: 'MMMM d, EEEE' : undefined : currentLocale()
"
>
<mat-icon>calendar_today</mat-icon>
<span>{{
selectedDateValue()
| date: 'MMM d, EEE' : undefined : currentLocale()
}}</span>
</button>
<mat-menu #liveEpgDateMenu="matMenu" xPosition="before">
<button
class="previous-day"
mat-menu-item
type="button"
(click)="navigateDate('prev')"
>
<mat-icon>chevron_left</mat-icon>
<span>{{ 'EPG.PREVIOUS_DAY' | translate }}</span>
</button>
<button
class="next-day"
mat-menu-item
type="button"
(click)="navigateDate('next')"
>
<mat-icon>chevron_right</mat-icon>
<span>{{ 'EPG.NEXT_DAY' | translate }}</span>
</button>
</mat-menu>
}
}
@if (progress() !== null) {
<div class="live-epg-panel__progress" aria-hidden="true">
<div
@@ -16,7 +16,7 @@
.live-epg-panel__summary-bar {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
column-gap: 8px;
min-height: 56px;
@@ -44,6 +44,13 @@
}
.live-epg-panel__summary {
min-width: 0;
display: grid;
align-content: center;
row-gap: 3px;
}
.live-epg-panel__program-line {
min-width: 0;
display: flex;
align-items: baseline;
@@ -51,11 +58,14 @@
}
.live-epg-panel__label {
flex-shrink: 0;
font-size: 0.7rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.61rem;
line-height: 1;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--app-selection-color);
}
@@ -65,7 +75,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
font-size: 0.94rem;
line-height: 1.25;
font-weight: 600;
color: var(--mat-sys-on-surface);
@@ -85,6 +95,67 @@
color: var(--mat-sys-on-surface-variant);
}
.live-epg-panel__date-nav {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
app-region: no-drag;
button {
width: 40px;
height: 40px;
border-radius: 10px;
color: var(--mat-sys-on-surface-variant);
&:hover:not([disabled]) {
color: var(--mat-sys-on-surface);
background: color-mix(in srgb, currentColor 10%, transparent);
}
}
}
.live-epg-panel__selected-date {
min-width: 150px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-size: 0.83rem;
line-height: 1.2;
font-weight: 600;
color: var(--mat-sys-on-surface-variant);
}
.live-epg-panel__date-chip {
display: none;
min-width: 0;
max-width: 150px;
height: 36px;
padding-inline: 10px;
border-radius: 10px;
color: var(--mat-sys-on-surface-variant);
background: color-mix(in srgb, currentColor 7%, transparent);
app-region: no-drag;
mat-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
font-size: 18px;
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 600;
}
}
.live-epg-panel__progress {
position: absolute;
inset-inline: 8px 12px;
@@ -119,6 +190,26 @@
border-bottom-color: transparent;
}
.live-epg-panel__date-nav {
display: none;
}
.live-epg-panel__summary {
display: flex;
align-items: baseline;
gap: 8px;
}
.live-epg-panel__label {
flex-shrink: 0;
font-size: 0.7rem;
letter-spacing: 0.08em;
}
.live-epg-panel__program-line {
gap: 8px;
}
.live-epg-panel__body {
flex-basis: 0;
block-size: 0;
@@ -128,3 +219,23 @@
pointer-events: none;
}
}
@media only screen and (max-width: 720px) {
.live-epg-panel__date-nav {
display: none;
}
.live-epg-panel__date-chip {
display: inline-flex;
}
}
@media only screen and (max-width: 560px) {
.live-epg-panel__label {
display: none;
}
.live-epg-panel--collapsed .live-epg-panel__label {
display: none;
}
}
@@ -15,7 +15,10 @@ import {
[collapsed]="collapsed"
[summary]="summary"
[loading]="loading"
[showDateNavigator]="showDateNavigator"
[selectedDate]="selectedDate"
(collapsedChange)="collapsed = $event"
(dateNavigation)="dateDirection = $event"
>
<div class="projected-content">Projected EPG</div>
</app-live-epg-panel>
@@ -24,6 +27,9 @@ import {
class HostComponent {
collapsed = false;
loading = false;
showDateNavigator = false;
selectedDate = '2026-04-05';
dateDirection: 'next' | 'prev' | null = null;
summary: LiveEpgPanelSummary | null = {
title: 'Current Show',
start: '2026-04-05T11:30:00.000Z',
@@ -107,4 +113,30 @@ describe('LiveEpgPanelComponent', () => {
.textContent
).toContain('EPG.NO_PROGRAM_INFO');
});
it('renders date navigation in the unified toolbar and emits day changes', () => {
fixture.componentInstance.showDateNavigator = true;
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.selected-date').textContent
).toContain('April 5, Sunday');
fixture.nativeElement.querySelector('.next-day').click();
expect(fixture.componentInstance.dateDirection).toBe('next');
});
it('hides date navigation controls while collapsed', () => {
fixture.componentInstance.collapsed = true;
fixture.componentInstance.showDateNavigator = true;
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.live-epg-panel__date-chip')
).toBeNull();
expect(
fixture.nativeElement.querySelector('.live-epg-panel__date-nav')
).toBeNull();
});
});
@@ -4,14 +4,23 @@ import {
Component,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import {
EpgDateNavigationDirection,
parseEpgDateKey,
} from '@iptvnator/ui/epg/date';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
export interface LiveEpgPanelSummary {
readonly title?: string | null;
@@ -22,7 +31,15 @@ export interface LiveEpgPanelSummary {
@Component({
selector: 'app-live-epg-panel',
imports: [DatePipe, MatIcon, MatIconButton, MatTooltip, TranslatePipe],
imports: [
DatePipe,
MatButton,
MatIcon,
MatIconButton,
MatMenuModule,
MatTooltip,
TranslatePipe,
],
templateUrl: './live-epg-panel.component.html',
styleUrl: './live-epg-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -31,9 +48,17 @@ export class LiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<EpgDateNavigationDirection>();
private readonly translate = inject(TranslateService);
private readonly currentTimeMs = signal(Date.now());
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly hasSummary = computed(() => {
const title = this.summary()?.title;
@@ -65,6 +90,15 @@ export class LiveEpgPanelComponent {
const elapsed = this.currentTimeMs() - startMs;
return clampProgress((elapsed / (stopMs - startMs)) * 100);
});
readonly selectedDateValue = computed(() =>
parseEpgDateKey(this.selectedDate())
);
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
constructor() {
effect((onCleanup) => {
@@ -79,6 +113,10 @@ export class LiveEpgPanelComponent {
toggleCollapsed(): void {
this.collapsedChange.emit(!this.collapsed());
}
navigateDate(direction: EpgDateNavigationDirection): void {
this.dateNavigation.emit(direction);
}
}
function toTimeMs(