diff --git a/.plans/2026-04-28-live-epg-unified-toolbar.md b/.plans/2026-04-28-live-epg-unified-toolbar.md new file mode 100644 index 000000000..c0b45efff --- /dev/null +++ b/.plans/2026-04-28-live-epg-unified-toolbar.md @@ -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. diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 59876cc51..25b6f139d 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -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[0]) { +async function visibleProgramTitles( + page: Parameters[0] +) { return page .locator('app-epg-list .program-title') .allInnerTexts() .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } -async function getProgressWidthPercent(row: ReturnType) { +async function getProgressWidthPercent( + row: ReturnType +) { const style = await row.locator('.epg-progress-fill').getAttribute('style'); const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0'; return Number.parseFloat(width); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 57e6e543f..7747bb1b7 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 ({ diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 1bedd4071..da6637338 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e330aa962..3dfbf95f7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -91,15 +91,23 @@
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 6787ce449..8c5b897e2 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -45,7 +45,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } describe('VideoPlayerComponent', () => { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 0b154baf0..fb3b3c035 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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( 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://... diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index a2cb22c2e..e1732252a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -94,12 +94,25 @@ [collapsed]="isLiveEpgPanelCollapsed()" [summary]="liveEpgPanelSummary()" [loading]="isLoadingEpg()" + [showDateNavigator]="true" + [selectedDate]="selectedLiveEpgDate()" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + (dateNavigation)="onLiveEpgDateNavigation($event)" > - + } @else { - + } } @else { @@ -110,7 +123,7 @@ } - +
@if (isLoadingEpg()) {
@@ -121,6 +134,13 @@ [controlledChannel]="controlledChannel()" [controlledPrograms]="activeEpgPrograms()" [controlledArchiveDays]="0" + [selectedDate]=" + unifiedDateNavigator ? selectedLiveEpgDate() : null + " + [showDateNavigator]="!unifiedDateNavigator" + (selectedDateChange)=" + unifiedDateNavigator && onLiveEpgSelectedDateChange($event) + " /> }
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index e2aa8bbe7..4f29d769d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -60,6 +60,9 @@ class StubEpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDate = input(null); + readonly showDateNavigator = input(true); + readonly selectedDateChange = output(); } @Component({ @@ -74,7 +77,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } @Component({ diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 6e509bb7c..4fc195897 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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( 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); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index b4db74d23..3497dfc3b 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -89,12 +89,25 @@ [collapsed]="isLiveEpgPanelCollapsed()" [summary]="liveEpgPanelSummary()" [loading]="isLoadingEpg()" + [showDateNavigator]="true" + [selectedDate]="selectedLiveEpgDate()" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + (dateNavigation)="onLiveEpgDateNavigation($event)" > - + } @else { - + }
} @else { @@ -105,7 +118,7 @@ } - +
@if (isLoadingEpg()) {
@@ -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) { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index b1922d72c..4112ab276 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -66,7 +66,10 @@ class StubEpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDate = input(null); + readonly showDateNavigator = input(true); readonly programActivated = output(); + readonly selectedDateChange = output(); } @Component({ @@ -81,7 +84,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } @Directive({ diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index ffce599f3..dee7d6815 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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( 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?.(); diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index 1020292cc..aa9f3a7b7 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -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'; diff --git a/libs/ui/epg/src/lib/epg-date.ts b/libs/ui/epg/src/lib/epg-date.ts new file mode 100644 index 000000000..c984f94be --- /dev/null +++ b/libs/ui/epg/src/lib/epg-date.ts @@ -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 + ); +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index 1b27b847a..00d6cee08 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -1,33 +1,35 @@ @let items = filteredItems(); @let timeshiftLimit = timeshiftUntil(); - -
-
- - - {{ - selectedDate() - | date: 'MMMM d, EEEE' : undefined : currentLocale() - }} - +@if (showDateNavigator()) { + +
+
+ + + {{ + selectedDateKey() + | date: 'MMMM d, EEEE' : undefined : currentLocale() + }} + +
-
+} @if (timeshiftLimit) { diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts index 801d758b7..5c2c1d71c 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts @@ -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 [ diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index 0979fb2a4..b92072fbf 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -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(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDateInput = input(null, { + alias: 'selectedDate', + }); + readonly showDateNavigator = input(true); readonly programActivated = output(); + readonly selectedDateChange = output(); private readonly store = inject(Store); private readonly epgService = inject(EpgService); @@ -70,7 +79,10 @@ export class EpgListComponent { ); readonly programList = viewChild>('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( + () => this.controlledChannel() ?? this.activeChannel() ?? null ); readonly channel = computed(() => { 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); } diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html index d2dfec81d..98bf21751 100644 --- a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html @@ -25,37 +25,114 @@ {{ 'EPG.CURRENT_PROGRAM' | translate }} - @if (loading()) { - - {{ 'EPG.LOADING' | translate }} - - } @else if (hasSummary()) { - - {{ summary()?.title }} - +
+ @if (loading()) { + + {{ 'EPG.LOADING' | translate }} + + } @else if (hasSummary()) { + + {{ summary()?.title }} + - @if (hasTimeRange()) { - - @if (summary()?.start) { - {{ summary()?.start | date: 'HH:mm' }} - } - @if (summary()?.start && summary()?.stop) { - - - } - @if (summary()?.stop) { - {{ summary()?.stop | date: 'HH:mm' }} - } + @if (hasTimeRange()) { + + @if (summary()?.start) { + {{ summary()?.start | date: 'HH:mm' }} + } + @if (summary()?.start && summary()?.stop) { + - + } + @if (summary()?.stop) { + {{ summary()?.stop | date: 'HH:mm' }} + } + + } + } @else { + + {{ 'EPG.NO_PROGRAM_INFO' | translate }} } - } @else { - - {{ 'EPG.NO_PROGRAM_INFO' | translate }} - - } +
+ @if (showDateNavigator()) { + @if (!collapsed()) { +
+ + + {{ + selectedDateValue() + | date + : 'MMMM d, EEEE' + : undefined + : currentLocale() + }} + + +
+ + + + + + + + } + } + @if (progress() !== null) {