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

@@ -0,0 +1,49 @@
# Unified Live EPG Toolbar
## Summary
Merge the current-program strip and date navigator into one shared `app-live-epg-panel` toolbar for M3U, Xtream, and Stalker live playback. The EPG list starts directly below the unified toolbar when the panel is expanded. In collapsed mode, keep the slim current-program strip and show a compact date chip.
## Key Changes
- Update `EpgListComponent` to support controlled date state:
- Add `[selectedDate]`, `(selectedDateChange)`, and `[showDateNavigator]`.
- Rename its internal date signal to avoid API collision.
- Keep existing standalone behavior by default, so non-live or external-player layouts still show the internal date row.
- Add small shared EPG date helpers in `@iptvnator/ui/epg`:
- Today date key.
- Parse date key.
- Shift date key by previous/next day.
- Extend `LiveEpgPanelComponent` with optional date navigation:
- Inputs: `[showDateNavigator]`, `[selectedDate]`.
- Output: `(dateNavigation)` emitting `prev | next`.
- Expanded toolbar: collapse button, current-program label/title/time, previous-day button, date label, next-day button, progress line.
- Collapsed toolbar: current-program summary plus compact date chip; the chip opens a small menu for previous/next day on narrow or collapsed states.
- Wire the unified toolbar into M3U, Xtream, and Stalker live layouts.
- Update focused unit specs and e2e selectors that currently assume `.selected-date` / `.next-day` live inside `app-epg-list`.
- Update `docs/architecture/iptvnator-ui-guidelines.md` to document that live inline-player layouts use the unified current-program/date toolbar.
## Test Plan
- Run unit tests:
- `pnpm nx test ui-epg`
- `pnpm nx test shared-portals`
- `pnpm nx test portal-xtream-feature`
- `pnpm nx test portal-stalker-feature`
- `pnpm nx test web`
- Run build/type validation through Nx:
- `pnpm nx build web --configuration=development`
- Use the running Electron app via CDP `127.0.0.1:9222` with `agent-browser`:
- Verify the IPTVnator page target is selected.
- Confirm the old `app-epg-list #channel-header` date row is absent when wrapped by `app-live-epg-panel`.
- Confirm the program list starts immediately below the unified toolbar.
- Click previous/next day in the unified toolbar and verify visible EPG programs change.
- Collapse the panel and verify the slim strip remains, body is inert, and the date chip stays visible.
- Capture a screenshot for visual confirmation.
- If docs changed and `IPTVNATOR_WIKI_VAULT` is configured, run `pnpm wiki:export --mode changed`.
## Assumptions
- Keep external MPV/VLC and non-inline layouts on the existing full EPG list behavior.
- Use existing translation keys for current program, loading, no program info, previous day, and next day.
- Leave unrelated dirty worktree changes untouched.
+43 -24
View File
@@ -31,7 +31,9 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
const currentProgram = fixture.shortEpg[0];
if (!currentProgram) {
throw new Error('Expected the Xtream EPG fixture to include a current program.');
throw new Error(
'Expected the Xtream EPG fixture to include a current program.'
);
}
const app = await launchElectronApp(dataDir, {
env: { TZ: timeZone },
@@ -45,7 +47,10 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickCategoryByNameExact(
app.mainWindow,
fixture.categoryName
);
const channelRow = channelItemByTitle(
app.mainWindow,
@@ -55,14 +60,18 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
await expect
.poll(async () =>
((await channelRow.locator('.epg-title').textContent()) ?? '').trim()
(
(await channelRow
.locator('.epg-title')
.textContent()) ?? ''
).trim()
)
.toBe(currentProgram.title);
await expect
.poll(async () => {
return (await channelRow.locator('.epg-time').allInnerTexts()).map(
(value) => value.trim()
);
return (
await channelRow.locator('.epg-time').allInnerTexts()
).map((value) => value.trim());
})
.toEqual([
formatTimeInZone(currentProgram.startTimestamp, timeZone),
@@ -84,17 +93,17 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
timeZone
);
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
currentDayTitles
);
await expect
.poll(() => visibleProgramTitles(app.mainWindow))
.toEqual(currentDayTitles);
const currentProgramRow = app.mainWindow
.locator('app-epg-list .program-item.current-program')
.first();
await expect(currentProgramRow).toBeVisible();
await expect(currentProgramRow.locator('.program-title')).toHaveText(
currentProgram.title
);
await expect(
currentProgramRow.locator('.program-title')
).toHaveText(currentProgram.title);
await expect(currentProgramRow.locator('.time')).toHaveText(
`${formatTimeInZone(
currentProgram.startTimestamp,
@@ -114,25 +123,29 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
firstFutureDateKey!,
timeZone
);
const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!);
const nextDayClicks = dayDifference(
currentDateKey,
firstFutureDateKey!
);
for (let index = 0; index < nextDayClicks; index += 1) {
await app.mainWindow.locator('app-epg-list .next-day').click();
await app.mainWindow
.locator(
'app-live-epg-panel .next-day, app-epg-list .next-day'
)
.click();
}
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
futureDayTitles
);
await expect
.poll(() => visibleProgramTitles(app.mainWindow))
.toEqual(futureDayTitles);
} finally {
await closeElectronApp(app);
}
});
}
function formatTimeInZone(
timestampSeconds: number,
timeZone: string
): string {
function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
return new Intl.DateTimeFormat('en-US', {
timeZone,
hour: '2-digit',
@@ -157,7 +170,9 @@ function formatDateKeyInZone(
const day = parts.find((part) => part.type === 'day')?.value;
if (!year || !month || !day) {
throw new Error(`Failed to format a date key for timezone ${timeZone}.`);
throw new Error(
`Failed to format a date key for timezone ${timeZone}.`
);
}
return `${year}-${month}-${day}`;
@@ -215,14 +230,18 @@ function dayDifference(fromDateKey: string, toDateKey: string): number {
return Math.round((to - from) / (24 * 60 * 60 * 1000));
}
async function visibleProgramTitles(page: Parameters<typeof channelItemByTitle>[0]) {
async function visibleProgramTitles(
page: Parameters<typeof channelItemByTitle>[0]
) {
return page
.locator('app-epg-list .program-title')
.allInnerTexts()
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}
async function getProgressWidthPercent(row: ReturnType<typeof channelItemByTitle>) {
async function getProgressWidthPercent(
row: ReturnType<typeof channelItemByTitle>
) {
const style = await row.locator('.epg-progress-fill').getAttribute('style');
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
return Number.parseFloat(width);
+5 -3
View File
@@ -201,9 +201,11 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => {
await expect(page.locator('app-epg-list')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('app-epg-list .selected-date')).toBeVisible({
timeout: 20_000,
});
await expect(
page.locator(
'app-live-epg-panel .selected-date, app-epg-list .selected-date'
)
).toBeVisible({ timeout: 20_000 });
});
test('@stalker bulk EPG is fetched once and reused across channel switches', async ({
+4 -1
View File
@@ -130,10 +130,13 @@ The shared row should be reused instead of rebuilding channel markup per view.
- Live TV layouts with an internal player use `app-live-epg-panel` around the
EPG content.
- The live panel toolbar owns the current-program summary and live date
navigation together, so `app-epg-list` hides its internal date navigator when
projected inside that panel.
- Collapsed state is shared across M3U, Xtream, and Stalker with
`live-epg-panel-state`; missing or invalid values restore to expanded.
- The collapsed panel is a slim current-program strip with a trailing progress
line and an expand button.
line, an expand button, and a compact date chip.
- Do not render the collapsed strip for external MPV/VLC playback; those
layouts keep the full EPG-only panel.
- Keep the EPG content mounted while collapsed so current-program state can
@@ -91,15 +91,23 @@
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<div class="epg-content">
<app-epg-list
[selectedDate]="selectedLiveEpgDate()"
[showDateNavigator]="false"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
/>
</div>
</app-live-epg-panel>
@@ -45,7 +45,10 @@ class StubLiveEpgPanelComponent {
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<'next' | 'prev'>();
}
describe('VideoPlayerComponent', () => {
@@ -22,8 +22,11 @@ import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
COMPONENT_OVERLAY_REF,
EpgDateNavigationDirection,
EpgListComponent,
getTodayEpgDateKey,
MultiEpgContainerComponent,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import {
ChannelActions,
@@ -158,6 +161,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState()
);
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
@@ -444,6 +448,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
persistLiveEpgPanelState(state);
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
);
}
onLiveEpgSelectedDateChange(selectedDate: string): void {
this.selectedLiveEpgDate.set(selectedDate);
}
/**
* Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://...
@@ -94,12 +94,25 @@
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<ng-container *ngTemplateOutlet="epgPanelContent" />
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel>
} @else {
<ng-container *ngTemplateOutlet="epgPanelContent" />
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
}
</div>
} @else {
@@ -110,7 +123,7 @@
}
</div>
<ng-template #epgPanelContent>
<ng-template #epgPanelContent let-unifiedDateNavigator="unifiedDateNavigator">
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
@@ -121,6 +134,13 @@
[controlledChannel]="controlledChannel()"
[controlledPrograms]="activeEpgPrograms()"
[controlledArchiveDays]="0"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
[showDateNavigator]="!unifiedDateNavigator"
(selectedDateChange)="
unifiedDateNavigator && onLiveEpgSelectedDateChange($event)
"
/>
}
</div>
@@ -60,6 +60,9 @@ class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly selectedDateChange = output<string>();
}
@Component({
@@ -74,7 +77,10 @@ class StubLiveEpgPanelComponent {
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<'next' | 'prev'>();
}
@Component({
@@ -24,7 +24,12 @@ import {
EpgProgram,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { EpgListComponent } from '@iptvnator/ui/epg';
import {
EpgDateNavigationDirection,
EpgListComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import {
LiveEpgPanelComponent,
LiveEpgPanelSummary,
@@ -120,6 +125,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState()
);
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
@@ -363,6 +369,16 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
persistLiveEpgPanelState(state);
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
);
}
onLiveEpgSelectedDateChange(selectedDate: string): void {
this.selectedLiveEpgDate.set(selectedDate);
}
private async loadEpgForChannel(item: StalkerItvChannel) {
const requestId = ++this.epgLoadRequestId;
const normalizedChannelId = normalizeStalkerEntityId(item.id);
@@ -89,12 +89,25 @@
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<ng-container *ngTemplateOutlet="epgPanelContent" />
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel>
} @else {
<ng-container *ngTemplateOutlet="epgPanelContent" />
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
}
</div>
} @else {
@@ -105,7 +118,7 @@
}
</div>
<ng-template #epgPanelContent>
<ng-template #epgPanelContent let-unifiedDateNavigator="unifiedDateNavigator">
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
@@ -131,7 +144,14 @@
[controlledPrograms]="controlledEpgPrograms()"
[controlledArchiveDays]="controlledArchiveDays()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
[showDateNavigator]="!unifiedDateNavigator"
(programActivated)="onProgramActivated($event)"
(selectedDateChange)="
unifiedDateNavigator && onLiveEpgSelectedDateChange($event)
"
/>
} @else if (!isElectron && epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
@@ -66,7 +66,10 @@ class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly selectedDateChange = output<string>();
}
@Component({
@@ -81,7 +84,10 @@ class StubLiveEpgPanelComponent {
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<'next' | 'prev'>();
}
@Directive({
@@ -36,7 +36,13 @@ import {
XtreamUrlService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg';
import {
EpgDateNavigationDirection,
EpgListComponent,
EpgProgramActivationEvent,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
EpgViewComponent,
@@ -156,6 +162,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState()
);
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
@@ -347,6 +354,16 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
persistLiveEpgPanelState(state);
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
);
}
onLiveEpgSelectedDateChange(selectedDate: string): void {
this.selectedLiveEpgDate.set(selectedDate);
}
ngOnDestroy(): void {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
+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(
+1
View File
@@ -35,6 +35,7 @@
"libs/playlist/shared/util/src/index.ts"
],
"@iptvnator/ui/epg": ["libs/ui/epg/src/index.ts"],
"@iptvnator/ui/epg/date": ["libs/ui/epg/src/lib/epg-date.ts"],
"@iptvnator/ui/epg/progress-panel": [
"libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts"
],