mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat: unify live epg toolbar
Entire-Checkpoint: 978eaff2e478
This commit is contained in:
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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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://...
|
||||
|
||||
+23
-3
@@ -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>
|
||||
|
||||
+6
@@ -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({
|
||||
|
||||
+17
-1
@@ -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);
|
||||
|
||||
+23
-3
@@ -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()" />
|
||||
|
||||
+6
@@ -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({
|
||||
|
||||
+18
-1
@@ -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,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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user