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
+600 -75

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 fixture = await fetchXtreamEpgFixture(request, epgCredentials);
const currentProgram = fixture.shortEpg[0]; const currentProgram = fixture.shortEpg[0];
if (!currentProgram) { 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, { const app = await launchElectronApp(dataDir, {
env: { TZ: timeZone }, env: { TZ: timeZone },
@@ -45,7 +47,10 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
}); });
await waitForXtreamWorkspaceReady(app.mainWindow); await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV'); await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); await clickCategoryByNameExact(
app.mainWindow,
fixture.categoryName
);
const channelRow = channelItemByTitle( const channelRow = channelItemByTitle(
app.mainWindow, app.mainWindow,
@@ -55,14 +60,18 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
await expect await expect
.poll(async () => .poll(async () =>
((await channelRow.locator('.epg-title').textContent()) ?? '').trim() (
(await channelRow
.locator('.epg-title')
.textContent()) ?? ''
).trim()
) )
.toBe(currentProgram.title); .toBe(currentProgram.title);
await expect await expect
.poll(async () => { .poll(async () => {
return (await channelRow.locator('.epg-time').allInnerTexts()).map( return (
(value) => value.trim() await channelRow.locator('.epg-time').allInnerTexts()
); ).map((value) => value.trim());
}) })
.toEqual([ .toEqual([
formatTimeInZone(currentProgram.startTimestamp, timeZone), formatTimeInZone(currentProgram.startTimestamp, timeZone),
@@ -84,17 +93,17 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
timeZone timeZone
); );
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual( await expect
currentDayTitles .poll(() => visibleProgramTitles(app.mainWindow))
); .toEqual(currentDayTitles);
const currentProgramRow = app.mainWindow const currentProgramRow = app.mainWindow
.locator('app-epg-list .program-item.current-program') .locator('app-epg-list .program-item.current-program')
.first(); .first();
await expect(currentProgramRow).toBeVisible(); await expect(currentProgramRow).toBeVisible();
await expect(currentProgramRow.locator('.program-title')).toHaveText( await expect(
currentProgram.title currentProgramRow.locator('.program-title')
); ).toHaveText(currentProgram.title);
await expect(currentProgramRow.locator('.time')).toHaveText( await expect(currentProgramRow.locator('.time')).toHaveText(
`${formatTimeInZone( `${formatTimeInZone(
currentProgram.startTimestamp, currentProgram.startTimestamp,
@@ -114,25 +123,29 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
firstFutureDateKey!, firstFutureDateKey!,
timeZone timeZone
); );
const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!); const nextDayClicks = dayDifference(
currentDateKey,
firstFutureDateKey!
);
for (let index = 0; index < nextDayClicks; index += 1) { 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( await expect
futureDayTitles .poll(() => visibleProgramTitles(app.mainWindow))
); .toEqual(futureDayTitles);
} finally { } finally {
await closeElectronApp(app); await closeElectronApp(app);
} }
}); });
} }
function formatTimeInZone( function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
timestampSeconds: number,
timeZone: string
): string {
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
timeZone, timeZone,
hour: '2-digit', hour: '2-digit',
@@ -157,7 +170,9 @@ function formatDateKeyInZone(
const day = parts.find((part) => part.type === 'day')?.value; const day = parts.find((part) => part.type === 'day')?.value;
if (!year || !month || !day) { 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}`; return `${year}-${month}-${day}`;
@@ -215,14 +230,18 @@ function dayDifference(fromDateKey: string, toDateKey: string): number {
return Math.round((to - from) / (24 * 60 * 60 * 1000)); 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 return page
.locator('app-epg-list .program-title') .locator('app-epg-list .program-title')
.allInnerTexts() .allInnerTexts()
.then((titles) => titles.map((title) => title.trim()).filter(Boolean)); .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 style = await row.locator('.epg-progress-fill').getAttribute('style');
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0'; const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
return Number.parseFloat(width); 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({ await expect(page.locator('app-epg-list')).toBeVisible({
timeout: 20_000, timeout: 20_000,
}); });
await expect(page.locator('app-epg-list .selected-date')).toBeVisible({ await expect(
timeout: 20_000, 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 ({ 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 - Live TV layouts with an internal player use `app-live-epg-panel` around the
EPG content. 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 - Collapsed state is shared across M3U, Xtream, and Stalker with
`live-epg-panel-state`; missing or invalid values restore to expanded. `live-epg-panel-state`; missing or invalid values restore to expanded.
- The collapsed panel is a slim current-program strip with a trailing progress - 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 - Do not render the collapsed strip for external MPV/VLC playback; those
layouts keep the full EPG-only panel. layouts keep the full EPG-only panel.
- Keep the EPG content mounted while collapsed so current-program state can - Keep the EPG content mounted while collapsed so current-program state can
@@ -91,15 +91,23 @@
<app-live-epg-panel <app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()" [collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()" [summary]="liveEpgPanelSummary()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)=" (collapsedChange)="
onLiveEpgPanelCollapsedChange($event) onLiveEpgPanelCollapsedChange($event)
" "
(dateNavigation)="onLiveEpgDateNavigation($event)"
> >
<div class="epg-content"> <div class="epg-content">
<app-epg-list <app-epg-list
[selectedDate]="selectedLiveEpgDate()"
[showDateNavigator]="false"
[archivePlaybackAvailable]=" [archivePlaybackAvailable]="
archivePlaybackAvailable() archivePlaybackAvailable()
" "
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
/> />
</div> </div>
</app-live-epg-panel> </app-live-epg-panel>
@@ -45,7 +45,10 @@ class StubLiveEpgPanelComponent {
readonly collapsed = input(false); readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null); readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false); readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsedChange = output<boolean>(); readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<'next' | 'prev'>();
} }
describe('VideoPlayerComponent', () => { describe('VideoPlayerComponent', () => {
@@ -22,8 +22,11 @@ import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { import {
COMPONENT_OVERLAY_REF, COMPONENT_OVERLAY_REF,
EpgDateNavigationDirection,
EpgListComponent, EpgListComponent,
getTodayEpgDateKey,
MultiEpgContainerComponent, MultiEpgContainerComponent,
shiftEpgDateKey,
} from '@iptvnator/ui/epg'; } from '@iptvnator/ui/epg';
import { import {
ChannelActions, ChannelActions,
@@ -158,6 +161,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly liveEpgPanelState = signal<LiveEpgPanelState>( readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState() restoreLiveEpgPanelState()
); );
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
readonly isLiveEpgPanelCollapsed = computed( readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed' () => this.liveEpgPanelState() === 'collapsed'
); );
@@ -444,6 +448,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
persistLiveEpgPanelState(state); 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 * Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://... * e.g. iptvnat.or?url=http://...
@@ -94,12 +94,25 @@
[collapsed]="isLiveEpgPanelCollapsed()" [collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()" [summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()" [loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
(dateNavigation)="onLiveEpgDateNavigation($event)"
> >
<ng-container *ngTemplateOutlet="epgPanelContent" /> <ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel> </app-live-epg-panel>
} @else { } @else {
<ng-container *ngTemplateOutlet="epgPanelContent" /> <ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
} }
</div> </div>
} @else { } @else {
@@ -110,7 +123,7 @@
} }
</div> </div>
<ng-template #epgPanelContent> <ng-template #epgPanelContent let-unifiedDateNavigator="unifiedDateNavigator">
<div class="epg-content"> <div class="epg-content">
@if (isLoadingEpg()) { @if (isLoadingEpg()) {
<div class="loading-indicator"> <div class="loading-indicator">
@@ -121,6 +134,13 @@
[controlledChannel]="controlledChannel()" [controlledChannel]="controlledChannel()"
[controlledPrograms]="activeEpgPrograms()" [controlledPrograms]="activeEpgPrograms()"
[controlledArchiveDays]="0" [controlledArchiveDays]="0"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
[showDateNavigator]="!unifiedDateNavigator"
(selectedDateChange)="
unifiedDateNavigator && onLiveEpgSelectedDateChange($event)
"
/> />
} }
</div> </div>
@@ -60,6 +60,9 @@ class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null); readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null); readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null); readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly selectedDateChange = output<string>();
} }
@Component({ @Component({
@@ -74,7 +77,10 @@ class StubLiveEpgPanelComponent {
readonly collapsed = input(false); readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null); readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false); readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsedChange = output<boolean>(); readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<'next' | 'prev'>();
} }
@Component({ @Component({
@@ -24,7 +24,12 @@ import {
EpgProgram, EpgProgram,
ResolvedPortalPlayback, ResolvedPortalPlayback,
} from 'shared-interfaces'; } from 'shared-interfaces';
import { EpgListComponent } from '@iptvnator/ui/epg'; import {
EpgDateNavigationDirection,
EpgListComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { import {
LiveEpgPanelComponent, LiveEpgPanelComponent,
LiveEpgPanelSummary, LiveEpgPanelSummary,
@@ -120,6 +125,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly liveEpgPanelState = signal<LiveEpgPanelState>( readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState() restoreLiveEpgPanelState()
); );
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
readonly isLiveEpgPanelCollapsed = computed( readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed' () => this.liveEpgPanelState() === 'collapsed'
); );
@@ -363,6 +369,16 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
persistLiveEpgPanelState(state); 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) { private async loadEpgForChannel(item: StalkerItvChannel) {
const requestId = ++this.epgLoadRequestId; const requestId = ++this.epgLoadRequestId;
const normalizedChannelId = normalizeStalkerEntityId(item.id); const normalizedChannelId = normalizeStalkerEntityId(item.id);
@@ -89,12 +89,25 @@
[collapsed]="isLiveEpgPanelCollapsed()" [collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()" [summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()" [loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
(dateNavigation)="onLiveEpgDateNavigation($event)"
> >
<ng-container *ngTemplateOutlet="epgPanelContent" /> <ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel> </app-live-epg-panel>
} @else { } @else {
<ng-container *ngTemplateOutlet="epgPanelContent" /> <ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
} }
</div> </div>
} @else { } @else {
@@ -105,7 +118,7 @@
} }
</div> </div>
<ng-template #epgPanelContent> <ng-template #epgPanelContent let-unifiedDateNavigator="unifiedDateNavigator">
<div class="epg-content"> <div class="epg-content">
@if (isLoadingEpg()) { @if (isLoadingEpg()) {
<div class="loading-indicator"> <div class="loading-indicator">
@@ -131,7 +144,14 @@
[controlledPrograms]="controlledEpgPrograms()" [controlledPrograms]="controlledEpgPrograms()"
[controlledArchiveDays]="controlledArchiveDays()" [controlledArchiveDays]="controlledArchiveDays()"
[archivePlaybackAvailable]="archivePlaybackAvailable()" [archivePlaybackAvailable]="archivePlaybackAvailable()"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
[showDateNavigator]="!unifiedDateNavigator"
(programActivated)="onProgramActivated($event)" (programActivated)="onProgramActivated($event)"
(selectedDateChange)="
unifiedDateNavigator && onLiveEpgSelectedDateChange($event)
"
/> />
} @else if (!isElectron && epgItems().length > 0) { } @else if (!isElectron && epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" /> <app-epg-view [epgItems]="epgItems()" />
@@ -66,7 +66,10 @@ class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null); readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null); readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null); readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly programActivated = output<EpgProgramActivationEvent>(); readonly programActivated = output<EpgProgramActivationEvent>();
readonly selectedDateChange = output<string>();
} }
@Component({ @Component({
@@ -81,7 +84,10 @@ class StubLiveEpgPanelComponent {
readonly collapsed = input(false); readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null); readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false); readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsedChange = output<boolean>(); readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<'next' | 'prev'>();
} }
@Directive({ @Directive({
@@ -36,7 +36,13 @@ import {
XtreamUrlService, XtreamUrlService,
XtreamStore, XtreamStore,
} from '@iptvnator/portal/xtream/data-access'; } 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { import {
EpgViewComponent, EpgViewComponent,
@@ -156,6 +162,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly liveEpgPanelState = signal<LiveEpgPanelState>( readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState() restoreLiveEpgPanelState()
); );
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
readonly isLiveEpgPanelCollapsed = computed( readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed' () => this.liveEpgPanelState() === 'collapsed'
); );
@@ -347,6 +354,16 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
persistLiveEpgPanelState(state); persistLiveEpgPanelState(state);
} }
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
);
}
onLiveEpgSelectedDateChange(selectedDate: string): void {
this.selectedLiveEpgDate.set(selectedDate);
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.(); this.unsubscribeRemoteCommand?.();
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/epg-list/epg-list.component'; export * from './lib/epg-list/epg-list.component';
export * from './lib/epg-list/epg-item-description/epg-item-description.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-progress-panel/epg-progress-panel.component';
export * from './lib/epg-source-status/epg-source-status.component'; export * from './lib/epg-source-status/epg-source-status.component';
export * from './lib/multi-epg/multi-epg-container.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,8 +1,9 @@
@let items = filteredItems(); @let items = filteredItems();
@let timeshiftLimit = timeshiftUntil(); @let timeshiftLimit = timeshiftUntil();
<!-- channel info panel --> @if (showDateNavigator()) {
<div id="channel-header" color="primary"> <!-- channel info panel -->
<div id="channel-header" color="primary">
<div id="date-navigator"> <div id="date-navigator">
<button <button
class="previous-day" class="previous-day"
@@ -14,7 +15,7 @@
</button> </button>
<span class="selected-date"> <span class="selected-date">
{{ {{
selectedDate() selectedDateKey()
| date: 'MMMM d, EEEE' : undefined : currentLocale() | date: 'MMMM d, EEEE' : undefined : currentLocale()
}}</span }}</span
> >
@@ -27,7 +28,8 @@
<mat-icon>chevron_right</mat-icon> <mat-icon>chevron_right</mat-icon>
</button> </button>
</div> </div>
</div> </div>
}
@if (timeshiftLimit) { @if (timeshiftLimit) {
<!-- program list --> <!-- program list -->
@@ -243,6 +243,34 @@ describe('EpgListComponent', () => {
.textContent.trim() .textContent.trim()
).toContain('Sonntag'); ).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?: { function buildPrograms(overrides?: {
@@ -253,11 +281,9 @@ function buildPrograms(overrides?: {
}): EpgProgram[] { }): EpgProgram[] {
const pastStart = '2026-04-05T09:00:00.000Z'; const pastStart = '2026-04-05T09:00:00.000Z';
const pastStop = '2026-04-05T10:00:00.000Z'; const pastStop = '2026-04-05T10:00:00.000Z';
const currentStart = const currentStart = overrides?.currentStart ?? '2026-04-05T11:30:00.000Z';
overrides?.currentStart ?? '2026-04-05T11:30:00.000Z';
const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z'; const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z';
const futureStart = const futureStart = overrides?.futureStart ?? '2026-04-05T12:30:00.000Z';
overrides?.futureStart ?? '2026-04-05T12:30:00.000Z';
const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z'; const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z';
return [ return [
@@ -20,13 +20,17 @@ import { normalizeDateLocale } from '@iptvnator/pipes';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgActions, selectActive } from 'm3u-state'; import { EpgActions, selectActive } from 'm3u-state';
import { addDays, format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { startWith } from 'rxjs'; import { startWith } from 'rxjs';
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces'; 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'; import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
const DATE_FORMAT = 'yyyy-MM-dd';
export interface EpgProgramActivationEvent { export interface EpgProgramActivationEvent {
program: EpgProgram; program: EpgProgram;
type: 'live' | 'timeshift'; type: 'live' | 'timeshift';
@@ -51,7 +55,12 @@ export class EpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null); readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null); readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | 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 programActivated = output<EpgProgramActivationEvent>();
readonly selectedDateChange = output<string>();
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly epgService = inject(EpgService); private readonly epgService = inject(EpgService);
@@ -70,7 +79,10 @@ export class EpgListComponent {
); );
readonly programList = viewChild<ElementRef<HTMLElement>>('programList'); 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 timeNow = signal(new Date().toISOString());
readonly currentTimeMs = computed(() => Date.parse(this.timeNow())); readonly currentTimeMs = computed(() => Date.parse(this.timeNow()));
readonly currentLocale = computed(() => { readonly currentLocale = computed(() => {
@@ -85,8 +97,8 @@ export class EpgListComponent {
this.controlledChannel() !== null || this.controlledChannel() !== null ||
this.controlledPrograms() !== null this.controlledPrograms() !== null
); );
readonly displayChannel = computed( readonly displayChannel = computed<Channel | null>(
() => this.controlledChannel() ?? this.activeChannel() () => this.controlledChannel() ?? this.activeChannel() ?? null
); );
readonly channel = computed<EpgChannel | null>(() => { readonly channel = computed<EpgChannel | null>(() => {
const channel = this.displayChannel(); const channel = this.displayChannel();
@@ -96,7 +108,9 @@ export class EpgListComponent {
return { return {
id: channel.tvg?.id || '', id: channel.tvg?.id || '',
displayName: channel.name ? [{ lang: '', value: channel.name }] : [], displayName: channel.name
? [{ lang: '', value: channel.name }]
: [],
url: channel.url ? [channel.url] : [], url: channel.url ? [channel.url] : [],
icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [], icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [],
}; };
@@ -119,14 +133,14 @@ export class EpgListComponent {
subDays(new Date(), this.archiveDays()).toISOString() subDays(new Date(), this.archiveDays()).toISOString()
); );
readonly archivePlaybackEnabled = computed( readonly archivePlaybackEnabled = computed(
() => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0) () => this.archivePlaybackAvailable() ?? this.archiveDays() > 0
); );
readonly filteredItems = computed(() => readonly filteredItems = computed(() =>
[...this.items()] [...this.items()]
.filter( .filter(
(item) => (item) =>
getProgramDateKey(item.start, item.startTimestamp) === getProgramDateKey(item.start, item.startTimestamp) ===
this.selectedDate() this.selectedDateKey()
) )
.sort( .sort(
(left, right) => (left, right) =>
@@ -185,16 +199,14 @@ export class EpgListComponent {
}); });
} }
changeDate(direction: 'next' | 'prev'): void { changeDate(direction: EpgDateNavigationDirection): void {
const selectedDate = new Date(`${this.selectedDate()}T00:00:00`); const nextDate = shiftEpgDateKey(this.selectedDateKey(), direction);
this.selectedDate.set(
format( if (this.selectedDateInput() == null) {
direction === 'next' this.internalSelectedDate.set(nextDate);
? addDays(selectedDate, 1) }
: subDays(selectedDate, 1),
DATE_FORMAT this.selectedDateChange.emit(nextDate);
)
);
} }
activateProgram(program: EpgProgram): void { activateProgram(program: EpgProgram): void {
@@ -225,7 +237,8 @@ export class EpgListComponent {
canActivateProgram(program: EpgProgram): boolean { canActivateProgram(program: EpgProgram): boolean {
return ( 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 total = stop - start;
const elapsed = now - 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 { isProgramPlaying(program: EpgProgram): boolean {
@@ -289,7 +304,7 @@ export class EpgListComponent {
} }
private scheduleScrollToCurrentProgram(): void { private scheduleScrollToCurrentProgram(): void {
if (this.selectedDate() !== format(new Date(), DATE_FORMAT)) { if (this.selectedDateKey() !== getTodayEpgDateKey()) {
return; return;
} }
@@ -385,5 +400,5 @@ function getProgramDateKey(
return ''; return '';
} }
return format(new Date(programTimeMs), DATE_FORMAT); return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
} }
@@ -25,6 +25,7 @@
{{ 'EPG.CURRENT_PROGRAM' | translate }} {{ 'EPG.CURRENT_PROGRAM' | translate }}
</span> </span>
<div class="live-epg-panel__program-line">
@if (loading()) { @if (loading()) {
<span class="live-epg-panel__title"> <span class="live-epg-panel__title">
{{ 'EPG.LOADING' | translate }} {{ 'EPG.LOADING' | translate }}
@@ -55,6 +56,82 @@
</span> </span>
} }
</div> </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) { @if (progress() !== null) {
<div class="live-epg-panel__progress" aria-hidden="true"> <div class="live-epg-panel__progress" aria-hidden="true">
@@ -16,7 +16,7 @@
.live-epg-panel__summary-bar { .live-epg-panel__summary-bar {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center; align-items: center;
column-gap: 8px; column-gap: 8px;
min-height: 56px; min-height: 56px;
@@ -44,6 +44,13 @@
} }
.live-epg-panel__summary { .live-epg-panel__summary {
min-width: 0;
display: grid;
align-content: center;
row-gap: 3px;
}
.live-epg-panel__program-line {
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -51,11 +58,14 @@
} }
.live-epg-panel__label { .live-epg-panel__label {
flex-shrink: 0; min-width: 0;
font-size: 0.7rem; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.61rem;
line-height: 1; line-height: 1;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.09em;
text-transform: uppercase; text-transform: uppercase;
color: var(--app-selection-color); color: var(--app-selection-color);
} }
@@ -65,7 +75,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 0.9rem; font-size: 0.94rem;
line-height: 1.25; line-height: 1.25;
font-weight: 600; font-weight: 600;
color: var(--mat-sys-on-surface); color: var(--mat-sys-on-surface);
@@ -85,6 +95,67 @@
color: var(--mat-sys-on-surface-variant); 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 { .live-epg-panel__progress {
position: absolute; position: absolute;
inset-inline: 8px 12px; inset-inline: 8px 12px;
@@ -119,6 +190,26 @@
border-bottom-color: transparent; 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 { .live-epg-panel__body {
flex-basis: 0; flex-basis: 0;
block-size: 0; block-size: 0;
@@ -128,3 +219,23 @@
pointer-events: none; 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" [collapsed]="collapsed"
[summary]="summary" [summary]="summary"
[loading]="loading" [loading]="loading"
[showDateNavigator]="showDateNavigator"
[selectedDate]="selectedDate"
(collapsedChange)="collapsed = $event" (collapsedChange)="collapsed = $event"
(dateNavigation)="dateDirection = $event"
> >
<div class="projected-content">Projected EPG</div> <div class="projected-content">Projected EPG</div>
</app-live-epg-panel> </app-live-epg-panel>
@@ -24,6 +27,9 @@ import {
class HostComponent { class HostComponent {
collapsed = false; collapsed = false;
loading = false; loading = false;
showDateNavigator = false;
selectedDate = '2026-04-05';
dateDirection: 'next' | 'prev' | null = null;
summary: LiveEpgPanelSummary | null = { summary: LiveEpgPanelSummary | null = {
title: 'Current Show', title: 'Current Show',
start: '2026-04-05T11:30:00.000Z', start: '2026-04-05T11:30:00.000Z',
@@ -107,4 +113,30 @@ describe('LiveEpgPanelComponent', () => {
.textContent .textContent
).toContain('EPG.NO_PROGRAM_INFO'); ).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, Component,
computed, computed,
effect, effect,
inject,
input, input,
output, output,
signal, signal,
} from '@angular/core'; } 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 { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip'; 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 { export interface LiveEpgPanelSummary {
readonly title?: string | null; readonly title?: string | null;
@@ -22,7 +31,15 @@ export interface LiveEpgPanelSummary {
@Component({ @Component({
selector: 'app-live-epg-panel', 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', templateUrl: './live-epg-panel.component.html',
styleUrl: './live-epg-panel.component.scss', styleUrl: './live-epg-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -31,9 +48,17 @@ export class LiveEpgPanelComponent {
readonly collapsed = input(false); readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null); readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false); readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsedChange = output<boolean>(); readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<EpgDateNavigationDirection>();
private readonly translate = inject(TranslateService);
private readonly currentTimeMs = signal(Date.now()); private readonly currentTimeMs = signal(Date.now());
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly hasSummary = computed(() => { readonly hasSummary = computed(() => {
const title = this.summary()?.title; const title = this.summary()?.title;
@@ -65,6 +90,15 @@ export class LiveEpgPanelComponent {
const elapsed = this.currentTimeMs() - startMs; const elapsed = this.currentTimeMs() - startMs;
return clampProgress((elapsed / (stopMs - startMs)) * 100); 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() { constructor() {
effect((onCleanup) => { effect((onCleanup) => {
@@ -79,6 +113,10 @@ export class LiveEpgPanelComponent {
toggleCollapsed(): void { toggleCollapsed(): void {
this.collapsedChange.emit(!this.collapsed()); this.collapsedChange.emit(!this.collapsed());
} }
navigateDate(direction: EpgDateNavigationDirection): void {
this.dateNavigation.emit(direction);
}
} }
function toTimeMs( function toTimeMs(
+1
View File
@@ -35,6 +35,7 @@
"libs/playlist/shared/util/src/index.ts" "libs/playlist/shared/util/src/index.ts"
], ],
"@iptvnator/ui/epg": ["libs/ui/epg/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": [ "@iptvnator/ui/epg/progress-panel": [
"libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts" "libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts"
], ],