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