feat(epg): add vertical list view for the live EPG panel

Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).

- New EpgListViewComponent (app-epg-list-view) mirrors
  EpgTimelineComponent's input/output contract 1:1, so all four live
  hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
  with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
  hasProgramsForDateKey, epg-archive.util catch-up gating,
  epg-summary.util collapsed-summary maths, epg-date helpers,
  EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
  duplicated logic.
- Rows show time range, title, optional description, live progress on
  the on-air row, catch-up "Watch" on past rows when archive playback
  is available, and a details dialog; keyboard activation guards
  nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
  collapse/expand remounts, and shows a sticky in-flow "On now" strip
  (never overlaying rows) when the current programme is scrolled away;
  all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
  modifier (--epg-inline-height clamp); timeline and collapsed heights
  are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
  SettingsStore/StorageMap → segmented control in the EPG section);
  Electron-only UI, PWA stays on the timeline default. i18n keys added
  to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
  persistence spec, swap tests in all four host specs, and Electron
  E2E for the settings round-trip and the rendered list view. Docs
  updated (m3u-playlist-module.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-07-02 00:29:52 +02:00
1 parent 401012eac4
commit c4890748e0
57 files changed
+2981 -90

No files matched your search

@@ -94,32 +94,65 @@
<div
class="epg"
[class.epg--inline]="canCollapseEpgPanel"
[class.epg--list]="epgViewMode() === 'list'"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
<div class="epg-content">
<app-epg-timeline
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="onTimelineProgramActivated($event)"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
/>
} @else {
<app-epg-timeline
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
/>
}
</div>
</div>
}
@@ -121,8 +121,10 @@ class StubWebPlayerViewComponent {
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline',
selector: 'app-epg-timeline, app-epg-list-view',
standalone: true,
template: '',
})
@@ -189,6 +191,7 @@ describe('VideoPlayerComponent', () => {
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline');
const originalElectron = window.electron;
const overlayRef = {
@@ -386,6 +389,7 @@ describe('VideoPlayerComponent', () => {
useValue: {
player,
showCaptions,
epgViewMode,
},
},
{
@@ -469,6 +473,29 @@ describe('VideoPlayerComponent', () => {
).not.toBeNull();
});
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
epgViewMode.set('list');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('app-epg-list-view')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
// Taller inline panel for the list view (see _portal-layout.scss).
expect(
fixture.nativeElement
.querySelector('.epg')
?.classList.contains('epg--list')
).toBe(true);
epgViewMode.set('timeline'); // restore for sibling tests
});
it('hides EPG controls and the multi-EPG header action in browser/PWA playback', () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
@@ -29,6 +29,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
COMPONENT_OVERLAY_REF,
EpgDateNavigationDirection,
EpgListViewComponent,
EpgProgramActivationEvent,
EpgTimelineComponent,
getTodayEpgDateKey,
@@ -122,6 +123,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
AudioPlayerComponent,
ChannelListLoadingStateComponent,
CommonModule,
EpgListViewComponent,
EpgTimelineComponent,
MatButtonModule,
MatIconModule,
@@ -273,6 +275,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
restoreLiveEpgPanelState()
);
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = computed(
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
);
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
@@ -73,25 +73,53 @@
<div
class="epg"
[class.epg--inline]="shouldUseInlinePlayer()"
[class.epg--list]="epgViewMode() === 'list'"
[class.epg-collapsed]="
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
"
>
<div class="epg-content">
<app-epg-timeline
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archiveDays]="timelineArchiveDays()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archiveDays]="timelineArchiveDays()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
shouldUseInlinePlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
/>
} @else {
<app-epg-timeline
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archiveDays]="timelineArchiveDays()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
shouldUseInlinePlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
/>
}
</div>
</div>
}
@@ -17,6 +17,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import {
EpgListViewComponent,
EpgTimelineComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
@@ -78,8 +79,10 @@ class StubGlobalFavoritesListComponent {
readonly removeRequested = output<UnifiedFavoriteChannel>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline',
selector: 'app-epg-timeline, app-epg-list-view',
template: '<div class="stub-epg-timeline"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -127,6 +130,7 @@ describe('UnifiedLiveTabComponent', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
let component: UnifiedLiveTabComponent;
let player: ReturnType<typeof signal<VideoPlayer>>;
let epgViewMode: ReturnType<typeof signal<'timeline' | 'list'>>;
let streamResolver: {
resolveLiveDetail: jest.Mock;
resolveM3uPlaybackDetail: jest.Mock;
@@ -160,6 +164,7 @@ describe('UnifiedLiveTabComponent', () => {
recordLivePlayback: jest.fn(),
};
player = signal(VideoPlayer.VideoJs);
epgViewMode = signal<'timeline' | 'list'>('timeline');
portalPlayer = {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
@@ -184,6 +189,7 @@ describe('UnifiedLiveTabComponent', () => {
useValue: {
openStreamOnDoubleClick: signal(false),
player,
epgViewMode,
},
},
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
@@ -193,6 +199,7 @@ describe('UnifiedLiveTabComponent', () => {
remove: {
imports: [
AudioPlayerComponent,
EpgListViewComponent,
EpgTimelineComponent,
GlobalFavoritesListComponent,
ResizableDirective,
@@ -447,6 +454,44 @@ describe('UnifiedLiveTabComponent', () => {
expect(timeline.selectedDate()).toBe(nextDate);
});
it('swaps the timeline for the list view when epgViewMode is "list"', async () => {
epgViewMode.set('list');
const item = buildLiveItem('xtream');
streamResolver.resolveLiveDetail.mockResolvedValue({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/xtream.m3u8',
title: 'Xtream Live',
},
epgItems: [buildCurrentEpgItem('Xtream Now')],
});
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(
fixture.nativeElement.querySelector('app-epg-list-view')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
// Taller inline panel for the list view (see _portal-layout.scss).
expect(
fixture.nativeElement
.querySelector('.epg')
?.classList.contains('epg--list')
).toBe(true);
});
it('renders inline portal EPG in the timeline and flows collapse state through', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildLiveItem('xtream');
@@ -36,6 +36,7 @@ import {
} from '@iptvnator/portal/shared/data-access';
import {
EpgDateNavigationDirection,
EpgListViewComponent,
EpgTimelineComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
@@ -59,6 +60,7 @@ import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AudioPlayerComponent,
EpgListViewComponent,
EpgTimelineComponent,
GlobalFavoritesListComponent,
MatButtonModule,
@@ -191,6 +193,10 @@ export class UnifiedLiveTabComponent {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = computed(
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
);
readonly liveEpgPanelSummary = computed(() => {
this.progressTick();
return this.getLiveEpgPanelSummary(this.activeDetail());
@@ -141,6 +141,7 @@
<div
class="epg"
[class.epg--inline]="canCollapseEpgPanel"
[class.epg--list]="epgViewMode() === 'list'"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
@@ -158,24 +159,50 @@
<ng-template #epgPanelContent>
<div class="epg-content">
<app-epg-timeline
[programs]="activeEpgPrograms()"
[channelName]="controlledChannel()?.name ?? activePlaybackTitle()"
[channelLogo]="activePlaybackArtwork()"
[sourceLabel]="'Stalker Portal'"
[archivePlaybackAvailable]="false"
[archiveDays]="0"
[loading]="isLoadingEpg()"
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
!!streamUrl() &&
usesEmbeddedPlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="activeEpgPrograms()"
[channelName]="
controlledChannel()?.name ?? activePlaybackTitle()
"
[channelLogo]="activePlaybackArtwork()"
[sourceLabel]="'Stalker Portal'"
[archivePlaybackAvailable]="false"
[archiveDays]="0"
[loading]="isLoadingEpg()"
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
!!streamUrl() &&
usesEmbeddedPlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
} @else {
<app-epg-timeline
[programs]="activeEpgPrograms()"
[channelName]="
controlledChannel()?.name ?? activePlaybackTitle()
"
[channelLogo]="activePlaybackArtwork()"
[sourceLabel]="'Stalker Portal'"
[archivePlaybackAvailable]="false"
[archiveDays]="0"
[loading]="isLoadingEpg()"
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
!!streamUrl() &&
usesEmbeddedPlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
}
</div>
</ng-template>
@@ -10,7 +10,10 @@ import {
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgTimelineComponent } from '@iptvnator/ui/epg';
import {
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
@@ -69,8 +72,10 @@ class StubAudioPlayerComponent {
readonly channelSwitchRequested = output<'next' | 'previous'>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline',
selector: 'app-epg-timeline, app-epg-list-view',
standalone: true,
template: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
@@ -230,10 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
};
const settingsStore = {
openStreamOnDoubleClick: signal(false),
epgViewMode: signal<'timeline' | 'list'>('timeline'),
};
const originalElectron = window.electron;
beforeEach(async () => {
// The store mock is module-scoped: reset so a failed test can't leak
// 'list' into siblings.
settingsStore.epgViewMode.set('timeline');
window.electron = {
platform: 'darwin',
updateRemoteControlStatus: jest.fn(),
@@ -341,6 +350,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
imports: [
ChannelListItemComponent,
AudioPlayerComponent,
EpgListViewComponent,
EpgTimelineComponent,
PortalEmptyStateComponent,
ResizableDirective,
@@ -386,6 +396,27 @@ describe('StalkerLiveStreamLayoutComponent', () => {
).toBeNull();
});
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
settingsStore.epgViewMode.set('list');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('app-epg-list-view')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
// Taller inline panel for the list view (see _portal-layout.scss).
expect(
fixture.nativeElement
.querySelector('.epg')
?.classList.contains('epg--list')
).toBe(true);
settingsStore.epgViewMode.set('timeline'); // restore for sibling tests
});
it('does not request or render EPG in browser/PWA playback', async () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
@@ -39,6 +39,7 @@ import {
} from '@iptvnator/shared/interfaces';
import {
EpgDateNavigationDirection,
EpgListViewComponent,
EpgTimelineComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
@@ -81,6 +82,7 @@ type StalkerPlayableChannel = StalkerPortalItem & {
AudioPlayerComponent,
ChannelListItemComponent,
ChannelListSkeletonComponent,
EpgListViewComponent,
EpgTimelineComponent,
MatButtonModule,
MatIconModule,
@@ -191,6 +193,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = computed(
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
@@ -109,6 +109,7 @@
<div
class="epg"
[class.epg--inline]="isEmbeddedPlayer"
[class.epg--list]="epgViewMode() === 'list'"
[class.epg-collapsed]="
isEmbeddedPlayer && isLiveEpgPanelCollapsed()
"
@@ -172,24 +173,46 @@
<ng-template #epgPanelContent>
<div class="epg-content">
<app-epg-timeline
[programs]="controlledEpgPrograms()"
[channelName]="activePlayback()?.title ?? ''"
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="controlledArchiveDays()"
[activeProgram]="activeCatchupProgram()"
[isLivePlayback]="!showReturnToLive()"
[loading]="isLoadingEpg()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="onProgramActivated($event)"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
@if (epgViewMode() === 'list') {
<app-epg-list-view
[programs]="controlledEpgPrograms()"
[channelName]="activePlayback()?.title ?? ''"
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="controlledArchiveDays()"
[activeProgram]="activeCatchupProgram()"
[isLivePlayback]="!showReturnToLive()"
[loading]="isLoadingEpg()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="onProgramActivated($event)"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
} @else {
<app-epg-timeline
[programs]="controlledEpgPrograms()"
[channelName]="activePlayback()?.title ?? ''"
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="controlledArchiveDays()"
[activeProgram]="activeCatchupProgram()"
[isLivePlayback]="!showReturnToLive()"
[loading]="isLoadingEpg()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="onProgramActivated($event)"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
}
</div>
</ng-template>
@@ -24,6 +24,7 @@ import {
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
EpgListViewComponent,
EpgProgramActivationEvent,
EpgTimelineComponent,
EpgTimelineSummary,
@@ -83,8 +84,10 @@ class StubWebPlayerViewComponent {
readonly externalFallbackRequested = output<unknown>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
// asserted by tag name; both branches share the identical contract.
@Component({
selector: 'app-epg-timeline',
selector: 'app-epg-timeline, app-epg-list-view',
standalone: true,
template: `
<div class="live-epg-panel-label">{{ summaryLabelKey() }}</div>
@@ -195,6 +198,9 @@ describe('LiveStreamLayoutComponent', () => {
};
const settingsStore = {
openStreamOnDoubleClick: signal(false),
// Reset in beforeEach: the store is module-scoped, so a test failure
// before an in-test restore must not leak 'list' into siblings.
epgViewMode: signal<'timeline' | 'list'>('timeline'),
};
const originalElectron = window.electron;
@@ -202,6 +208,7 @@ describe('LiveStreamLayoutComponent', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(fixedNow);
settingsStore.epgViewMode.set('timeline');
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
@@ -301,6 +308,7 @@ describe('LiveStreamLayoutComponent', () => {
.overrideComponent(LiveStreamLayoutComponent, {
remove: {
imports: [
EpgListViewComponent,
EpgTimelineComponent,
GridListComponent,
PortalChannelsListComponent,
@@ -359,6 +367,28 @@ describe('LiveStreamLayoutComponent', () => {
).not.toBeNull();
});
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
settingsStore.epgViewMode.set('list');
component.playLive(sampleChannel);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('app-epg-list-view')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
// Taller inline panel for the list view (see _portal-layout.scss).
expect(
fixture.nativeElement
.querySelector('.epg')
?.classList.contains('epg--list')
).toBe(true);
settingsStore.epgViewMode.set('timeline'); // restore for sibling tests
});
it('hides the EPG panel in browser/PWA playback', () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
@@ -49,6 +49,7 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import {
EpgDateNavigationDirection,
EpgListViewComponent,
EpgProgramActivationEvent,
EpgTimelineComponent,
getTodayEpgDateKey,
@@ -91,6 +92,7 @@ interface XtreamLiveChannelItem {
styleUrls: ['./live-stream-layout.component.scss'],
providers: [LiveStreamAutoOpenStateService],
imports: [
EpgListViewComponent,
EpgTimelineComponent,
MatButtonModule,
MatIcon,
@@ -208,6 +210,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = computed(
() => this.settingsStore.epgViewMode?.() ?? 'timeline'
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(
@@ -43,6 +43,7 @@ const DEFAULT_SETTINGS: Settings = {
downloadFolder: '',
recordingFolder: '',
coverSize: 'medium',
epgViewMode: 'timeline',
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
@@ -173,6 +174,8 @@ export const SettingsStore = signalStore(
store.recordingFolder?.() ??
DEFAULT_SETTINGS.recordingFolder,
coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
epgViewMode:
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
dashboardRails: normalizeDashboardRailsSettings(
store.dashboardRails?.()
),
@@ -21,6 +21,9 @@ export enum StartupBehavior {
export type CoverSize = 'small' | 'medium' | 'large';
/** Rendering of the live EPG panel under the player. */
export type EpgViewMode = 'timeline' | 'list';
export interface DashboardRailsSettings {
hero: boolean;
continueWatching: boolean;
@@ -103,6 +106,8 @@ export interface Settings {
recordingFolder?: string;
/** Cover/poster sizing preset applied across grids and rails */
coverSize?: CoverSize;
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
epgViewMode?: EpgViewMode;
/** Per-rail dashboard visibility preferences. Missing keys default on. */
dashboardRails?: DashboardRailsSettings;
/**
+3
View File
@@ -1,5 +1,8 @@
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-view/epg-list-view.component';
export * from './lib/epg-list-view/epg-list-view-row/epg-list-view-row.component';
export * from './lib/epg-list-view/epg-list-view.utils';
export * from './lib/epg-date';
export * from './lib/epg-timeline/epg-timeline.component';
export * from './lib/epg-timeline/epg-timeline-empty-state.component';
@@ -0,0 +1,190 @@
import { EpgListScrollController } from './epg-list-scroll.controller';
import { EpgListRow } from './epg-list-view.utils';
function rowAt(when: EpgListRow['when'], key = `${when}-row`): EpgListRow {
const startMs = Date.now();
return {
program: {
start: new Date(startMs).toISOString(),
stop: new Date(startMs + 60 * 60_000).toISOString(),
channel: 'ch',
title: 'P',
desc: null,
category: null,
},
key,
startMs,
stopMs: startMs + 60 * 60_000,
when,
progress: when === 'now' ? 50 : null,
isActive: false,
canCatchUp: false,
};
}
describe('EpgListScrollController (channel-select auto-scroll)', () => {
let controller: EpgListScrollController;
let scrollSpy: jest.SpyInstance;
let rafSpy: jest.SpyInstance;
beforeEach(() => {
rafSpy = jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb: FrameRequestCallback) => {
cb(0);
return 1;
});
controller = new EpgListScrollController({
list: () => undefined,
isViewToday: () => true,
setNowStripVisible: () => undefined,
});
scrollSpy = jest
.spyOn(controller, 'scrollNowIntoView')
.mockImplementation(() => undefined);
jest.spyOn(controller, 'updateNowStrip').mockImplementation(
() => undefined
);
});
afterEach(() => {
rafSpy.mockRestore();
});
it('scrolls the now row into view instantly on first load', () => {
controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], true, 'ch');
expect(scrollSpy).toHaveBeenCalledTimes(1);
expect(scrollSpy).toHaveBeenCalledWith(false); // instant, no animation
});
it('does not re-scroll while the same channel stays loaded (now-tick / re-emit)', () => {
const list = {} as HTMLElement;
const rows = [rowAt('now')];
controller.maybeAutoScroll(list, rows, true, 'ch');
controller.maybeAutoScroll(list, rows, true, 'ch');
expect(scrollSpy).toHaveBeenCalledTimes(1);
// The dedup path still refreshes the now-strip — layout can change
// without scroll events (e.g. the panel grew and nothing scrolls).
expect(controller.updateNowStrip).toHaveBeenCalled();
});
it('restores the now row when the same channel list remounts (collapse then expand)', () => {
const rows = [rowAt('now')];
controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // mount
controller.maybeAutoScroll(undefined, rows, true, 'ch'); // collapsed
controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // expand
expect(scrollSpy).toHaveBeenCalledTimes(2);
});
it('does not re-scroll when the on-air programme rolls over within the same set', () => {
// The 30s tick reclassifies `when` at every programme boundary; the
// programme SET is unchanged, so the viewport must stay put.
const list = {} as HTMLElement;
const a = rowAt('now', 'a');
const b = {
...rowAt('future', 'b'),
startMs: a.stopMs,
stopMs: a.stopMs + 3_600_000,
};
controller.maybeAutoScroll(list, [a, b], true, 'ch');
const rolled = [
{ ...a, when: 'past' as const },
{ ...b, when: 'now' as const },
];
controller.maybeAutoScroll(list, rolled, true, 'ch');
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
it('re-scrolls when the channel changes', () => {
const list = {} as HTMLElement;
controller.maybeAutoScroll(list, [rowAt('now', 'a')], true, 'alpha');
controller.maybeAutoScroll(list, [rowAt('now', 'b')], true, 'beta');
expect(scrollSpy).toHaveBeenCalledTimes(2);
});
it('leaves a non-today day alone (no snap back to now)', () => {
controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], false, 'ch');
expect(scrollSpy).not.toHaveBeenCalled();
});
it('does nothing without an on-air row or a mounted list', () => {
controller.maybeAutoScroll({} as HTMLElement, [rowAt('past')], true, 'ch');
controller.maybeAutoScroll(undefined, [rowAt('now')], true, 'ch');
expect(scrollSpy).not.toHaveBeenCalled();
});
});
describe('EpgListScrollController (now-strip visibility)', () => {
function stripStateFor(list: Partial<HTMLElement>): boolean | null {
let visible: boolean | null = null;
const controller = new EpgListScrollController({
list: () => list as HTMLElement,
isViewToday: () => true,
setNowStripVisible: (value) => (visible = value),
});
controller.updateNowStrip();
return visible;
}
/** Row 500–560px below the list's viewport top (out of a 0–200 view). */
const offscreenRow = {
getBoundingClientRect: () => ({ top: 500, bottom: 560, height: 60 }),
};
it('hides the strip when the list does not scroll (nothing can be scrolled away)', () => {
expect(
stripStateFor({
scrollHeight: 200,
clientHeight: 200,
getBoundingClientRect: () => ({ top: 0, bottom: 200 }),
querySelector: () => offscreenRow,
} as unknown as HTMLElement)
).toBe(false);
});
it('shows the strip when the on-air row is scrolled out of view', () => {
expect(
stripStateFor({
scrollHeight: 600,
clientHeight: 200,
getBoundingClientRect: () => ({ top: 0, bottom: 200 }),
querySelector: () => offscreenRow,
} as unknown as HTMLElement)
).toBe(true);
});
});
describe('EpgListScrollController (scroll target maths)', () => {
it('scrolls relative to the list scroller, not the offset parent', () => {
// Regression: the rows have no positioned ancestor inside the list, so
// offsetTop-based maths measured against a far ancestor (including the
// player above) and scrolled to a wildly wrong position.
const scrollTo = jest.fn();
const list = {
scrollTop: 100,
scrollTo,
getBoundingClientRect: () => ({ top: 50 }),
querySelector: () => ({
getBoundingClientRect: () => ({ top: 350 }),
}),
} as unknown as HTMLElement;
const controller = new EpgListScrollController({
list: () => list,
isViewToday: () => true,
setNowStripVisible: () => undefined,
});
controller.scrollNowIntoView(false);
// 100 (current scroll) + (350 − 50) (row offset within view) − 8
expect(scrollTo).toHaveBeenCalledWith({ top: 392, behavior: 'auto' });
});
});
@@ -0,0 +1,144 @@
import { EpgListRow } from './epg-list-view.utils';
export interface EpgListScrollDeps {
/** The scrollable `.g-list` element (undefined before first render). */
readonly list: () => HTMLElement | undefined;
/** Whether the viewed day is today (auto-focus/now-strip are today-only). */
readonly isViewToday: () => boolean;
/** Toggle the sticky now-strip's visibility. */
readonly setNowStripVisible: (visible: boolean) => void;
}
/**
* Vertical-list scroll behaviour extracted from the component: auto-focus the
* on-air row when a channel's EPG (re)loads, keep the sticky "now" strip in sync
* with scroll position, and reset scroll on day changes. The vertical analogue
* of the timeline's `TimelineScrollController`.
*/
export class EpgListScrollController {
private autoScrollKey: string | null = null;
private lastList: HTMLElement | null = null;
constructor(private readonly deps: EpgListScrollDeps) {}
/**
* Scroll the on-air row into view once per channel/EPG load, today only.
* A *new* list element under an unchanged key means the body was unmounted
* and remounted (the inline panel was collapsed and re-expanded), which
* resets scrollTop to 0 — restore the now-row instead of stranding the user
* at the top of the day. The same element (data re-emit / 30s now-tick) is
* left alone so the viewport is never yanked out from under the user.
*/
maybeAutoScroll(
list: HTMLElement | undefined,
rows: EpgListRow[],
today: boolean,
channel: string
): void {
if (!list || !today) {
return;
}
const now = rows.find((row) => row.when === 'now');
if (!now) {
return;
}
// Programme-SET identity (like the timeline's `programsFocusKey`), NOT
// the on-air row's key: the latter changes at every programme rollover
// (the 30s tick reclassifies `when`), which would re-trigger the scroll
// and yank the viewport away from wherever the user scrolled to.
const first = rows[0];
const last = rows[rows.length - 1];
const key = `${channel}|${rows.length}|${first.startMs}|${last.stopMs}`;
if (key === this.autoScrollKey) {
if (list !== this.lastList) {
this.lastList = list;
this.focusNowAfterRender();
} else {
// No scroll — but keep the now-strip honest: layout can change
// without scroll events (panel resize, rows re-render), and a
// non-scrollable list never fires scroll to self-correct.
this.updateNowStrip();
}
return;
}
this.autoScrollKey = key;
this.lastList = list;
this.focusNowAfterRender();
}
/**
* Scroll to the now-row after the next render. Public for the toolbar
* "Now" jump: committing today re-renders the rows, so a synchronous DOM
* query would still see the previous day's rows and find no now-row —
* the deferral lets change detection paint today first.
*/
focusNowAfterRender(animate = false): void {
requestAnimationFrame(() =>
requestAnimationFrame(() => {
this.scrollNowIntoView(animate);
this.updateNowStrip();
})
);
}
scrollNowIntoView(animate: boolean): void {
const list = this.deps.list();
const nowRow = this.nowRowElement(list);
if (list && nowRow) {
// Rect-based, relative to the scroller itself: `offsetTop` is
// measured against the nearest *positioned* ancestor, which is
// outside the list (the rows/list are not positioned), so it would
// include the player above and scroll to a wildly wrong spot.
const listRect = list.getBoundingClientRect();
const rowRect = nowRow.getBoundingClientRect();
const top = list.scrollTop + (rowRect.top - listRect.top) - 8;
list.scrollTo({
top: Math.max(0, top),
behavior: animate ? 'smooth' : 'auto',
});
}
}
resetListScroll(): void {
const list = this.deps.list();
if (list) {
list.scrollTop = 0;
}
this.deps.setNowStripVisible(false);
}
/** Show the condensed now-strip only when the on-air row is scrolled away. */
updateNowStrip(): void {
const list = this.deps.list();
const nowRow = this.nowRowElement(list);
if (!list || !nowRow || !this.deps.isViewToday()) {
this.deps.setNowStripVisible(false);
return;
}
// A list that doesn't scroll can't have scrolled the row away — and it
// also never fires scroll events to clear a stale strip.
if (list.scrollHeight <= list.clientHeight) {
this.deps.setNowStripVisible(false);
return;
}
// Rect-based for the same reason as scrollNowIntoView.
const listRect = list.getBoundingClientRect();
const rowRect = nowRow.getBoundingClientRect();
if (rowRect.height <= 0) {
this.deps.setNowStripVisible(false);
return;
}
const visible =
Math.min(rowRect.bottom, listRect.bottom) -
Math.max(rowRect.top, listRect.top);
this.deps.setNowStripVisible(visible / rowRect.height < 0.5);
}
private nowRowElement(
list: HTMLElement | undefined
): HTMLElement | null {
return (
list?.querySelector<HTMLElement>('.g-row[data-when="now"]') ?? null
);
}
}
@@ -0,0 +1,61 @@
@let r = row();
<div class="time">
<span class="time-range"
>{{ r.startMs | date: 'HH:mm' }} – {{ r.stopMs | date: 'HH:mm' }}</span
>
@if (r.when === 'now') {
<span class="time-now">{{ 'EPG.TIMELINE.ON_NOW' | translate }}</span>
}
</div>
<div class="body">
<div class="title">{{ r.program.title }}</div>
@if (r.program.desc) {
<div class="desc">{{ r.program.desc }}</div>
}
@if (r.when === 'now' && r.progress !== null) {
<div class="g-now-bar" aria-hidden="true">
<i [style.width.%]="r.progress"></i>
</div>
}
@if (isPlaying()) {
<div class="playtag">
<mat-icon>play_arrow</mat-icon>
{{ 'EPG.TIMELINE.FROM_ARCHIVE' | translate }}
</div>
}
</div>
<div class="aux">
@if (r.when === 'now') {
@if (minutesLeft() !== null) {
<span class="nowtag"
>{{ minutesLeft() }}
{{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}</span
>
}
} @else if (r.canCatchUp) {
<button
type="button"
class="watch"
(click)="onWatch($event)"
[attr.aria-label]="'EPG.TIMELINE.WATCH_FROM_START' | translate"
>
<mat-icon>replay</mat-icon>
{{ 'EPG.TIMELINE.WATCH' | translate }}
</button>
}
<button
type="button"
class="icbtn"
(click)="onInfo($event)"
[attr.aria-label]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
"
>
<mat-icon>info_outline</mat-icon>
</button>
</div>
@@ -0,0 +1,224 @@
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
$surface-1: var(--surface-1, #0f141c);
$surface-2: var(--surface-2, #161d28);
$surface-3: var(--surface-3, #1d2533);
$text-primary: var(--text-primary, #e7ecf3);
$text-secondary: var(--text-secondary, #9aa3b2);
$text-tertiary: var(--text-tertiary, #6b7384);
$accent-blue: var(--accent-blue, #4f8eff);
$accent-cyan: var(--accent-cyan, #5cd6ff);
$accent-live: var(--accent-live, #ff5b6e);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
:host {
box-sizing: border-box;
display: grid;
grid-template-columns: 92px 1fr auto;
gap: 16px;
align-items: center;
padding: 11px 18px 11px 22px;
position: relative;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 120ms ease;
outline: none;
}
:host *,
:host *::before {
box-sizing: border-box;
}
:host(:hover) {
background: $surface-1;
}
.time {
font-family: $font-mono;
font-size: 12px;
color: $text-tertiary;
white-space: nowrap;
}
.time-now {
display: flex;
align-items: center;
gap: 5px;
margin-top: 7px;
font-family: $font-mono;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $accent-live;
}
.time-now::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: $accent-live;
box-shadow: 0 0 6px $accent-live;
}
.body {
min-width: 0;
}
.title {
font-size: 14px;
font-weight: 500;
color: $text-primary;
line-height: 1.3;
}
.desc {
font-size: 12.5px;
color: $text-tertiary;
margin-top: 3px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 54ch;
}
.g-now-bar {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
margin-top: 8px;
max-width: 320px;
overflow: hidden;
}
.g-now-bar > i {
display: block;
height: 100%;
background: $accent-cyan;
}
.playtag {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 8px;
font-family: $font-mono;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $accent-blue;
}
.playtag mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
.aux {
display: flex;
align-items: center;
gap: 6px;
}
.nowtag {
font-size: 12px;
color: $text-tertiary;
padding-right: 4px;
white-space: nowrap;
}
.icbtn {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
border: none;
background: transparent;
color: $text-secondary;
cursor: pointer;
opacity: 0;
transition:
opacity 120ms,
background 120ms,
color 120ms;
}
.icbtn mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
:host(:hover) .icbtn,
:host(:focus-within) .icbtn {
opacity: 1;
}
.icbtn:hover {
background: $surface-3;
color: $text-primary;
}
.watch {
display: inline-flex;
align-items: center;
gap: 7px;
height: 30px;
padding: 0 13px;
border-radius: 999px;
border: 1px solid $line-strong;
background: $surface-2;
color: $text-primary;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.watch mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
.watch:hover {
border-color: $accent-blue;
color: $accent-blue;
}
/* ── PAST — dimmed ── */
:host([data-when='past']) .time,
:host([data-when='past']) .title {
color: $text-secondary;
}
:host([data-when='past']) .title {
font-weight: 400;
}
:host([data-when='past']:hover) .time,
:host([data-when='past']:hover) .title {
color: $text-primary;
}
/* ── NOW — accent bar + fill ── */
:host([data-when='now']) {
align-items: start;
background: rgba(79, 142, 255, 0.08);
border-left-color: #4f8eff;
}
:host([data-when='now']) .time {
color: $accent-blue;
}
:host([data-when='now']) .title {
color: #fff;
font-weight: 600;
}
:host([data-when='now']) .desc {
color: $text-secondary;
}
/* ── PLAYING from archive — distinct from now ── */
:host(.playing) {
background: rgba(79, 142, 255, 0.13);
border-left-color: #4f8eff;
}
:host(.playing) .title {
color: #fff;
font-weight: 600;
}
:host(.playing) .watch {
border-color: $accent-blue;
background: rgba(79, 142, 255, 0.18);
color: $accent-blue;
}
/* ── SELECTED (focus) — ring only, independent of temporal/playing state ── */
:host(.sel) {
box-shadow: inset 0 0 0 1px rgba(79, 142, 255, 0.55);
}
@@ -0,0 +1,131 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgListRow } from '../epg-list-view.utils';
import { EpgListViewRowComponent } from './epg-list-view-row.component';
function program(title = 'P', desc: string | null = null): EpgProgram {
return {
start: new Date().toISOString(),
stop: new Date().toISOString(),
channel: 'ch',
title,
desc,
category: null,
};
}
function row(overrides: Partial<EpgListRow> = {}): EpgListRow {
return {
program: program(),
key: 'k',
startMs: Date.now(),
stopMs: Date.now() + 60_000,
when: 'future',
progress: null,
isActive: false,
canCatchUp: false,
...overrides,
};
}
describe('EpgListViewRowComponent', () => {
let fixture: ComponentFixture<EpgListViewRowComponent>;
let component: EpgListViewRowComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [EpgListViewRowComponent, TranslateModule.forRoot()],
});
fixture = TestBed.createComponent(EpgListViewRowComponent);
component = fixture.componentInstance;
});
it('marks an active past programme as playing (archive), not a now row', () => {
fixture.componentRef.setInput('row', row({ when: 'past', isActive: true }));
expect(component.isPlaying()).toBe(true);
fixture.componentRef.setInput('row', row({ when: 'now', isActive: true }));
expect(component.isPlaying()).toBe(false);
});
it('computes minutes left for a now row from the tick', () => {
const stopMs = Date.now() + 25 * 60_000;
fixture.componentRef.setInput('row', row({ when: 'now', stopMs }));
fixture.componentRef.setInput('nowMs', Date.now());
expect(component.minutesLeft()).toBeGreaterThanOrEqual(24);
expect(component.minutesLeft()).toBeLessThanOrEqual(25);
});
it('activates on keyboard only when the row itself is the target', () => {
fixture.componentRef.setInput('row', row());
const events: string[] = [];
component.activate.subscribe(() => events.push('activate'));
const host = fixture.nativeElement as HTMLElement;
const nested = document.createElement('button');
// Enter bubbling up from a nested watch/info button must NOT activate
// the row (and must not preventDefault, which would suppress the
// button's own native click).
const fromButton = {
target: nested,
currentTarget: host,
preventDefault: jest.fn(),
} as unknown as Event;
component.onKeydown(fromButton);
expect(events).toEqual([]);
expect(
(fromButton as unknown as { preventDefault: jest.Mock })
.preventDefault
).not.toHaveBeenCalled();
// Enter on the focused row itself activates it.
const fromRow = {
target: host,
currentTarget: host,
preventDefault: jest.fn(),
} as unknown as Event;
component.onKeydown(fromRow);
expect(events).toEqual(['activate']);
});
it('emits activate on row click and stops propagation on watch/info', () => {
fixture.componentRef.setInput('row', row());
const events: string[] = [];
component.activate.subscribe(() => events.push('activate'));
component.watch.subscribe(() => events.push('watch'));
component.info.subscribe(() => events.push('info'));
component.onRowClick();
const stop = jest.fn();
component.onWatch({ stopPropagation: stop } as unknown as Event);
component.onInfo({ stopPropagation: stop } as unknown as Event);
expect(events).toEqual(['activate', 'watch', 'info']);
expect(stop).toHaveBeenCalledTimes(2);
});
it('renders the temporal state and catch-up Watch affordance', () => {
fixture.componentRef.setInput(
'row',
row({ when: 'past', canCatchUp: true })
);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.getAttribute('data-when')).toBe('past');
expect(host.querySelector('.watch')).not.toBeNull();
});
it('renders the live progress bar for a now row and hides Watch', () => {
fixture.componentRef.setInput(
'row',
row({ when: 'now', progress: 40, canCatchUp: false })
);
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.getAttribute('data-when')).toBe('now');
expect(host.querySelector('.g-now-bar')).not.toBeNull();
expect(host.querySelector('.watch')).toBeNull();
});
});
@@ -0,0 +1,90 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgListRow } from '../epg-list-view.utils';
/**
* One programme row in the vertical EPG list. Dumb/presentational: it renders
* the precomputed `EpgListRow` and emits semantic intents; the list component
* owns activation policy (live vs timeshift vs open-details). Mirrors the
* mockup's `.g-row[data-when]` template.
*/
@Component({
selector: 'app-epg-list-view-row',
templateUrl: './epg-list-view-row.component.html',
styleUrl: './epg-list-view-row.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DatePipe, MatIcon, TranslatePipe],
host: {
class: 'g-row',
role: 'button',
tabindex: '0',
'[attr.data-when]': 'row().when',
'[class.sel]': 'selected()',
'[class.playing]': 'isPlaying()',
'(click)': 'onRowClick()',
'(keydown.enter)': 'onKeydown($event)',
'(keydown.space)': 'onKeydown($event)',
},
})
export class EpgListViewRowComponent {
readonly row = input.required<EpgListRow>();
readonly selected = input(false);
readonly currentLocale = input('en');
/** Current wall-clock ms from the list's 30s tick (drives minutes-left). */
readonly nowMs = input(0);
readonly activate = output<void>();
readonly watch = output<void>();
readonly info = output<void>();
/** Archive-playback highlight: the active programme is a past one. */
readonly isPlaying = computed(() => {
const row = this.row();
return row.isActive && row.when === 'past';
});
/** Minutes remaining on the on-air programme (for the `now` row tag). */
readonly minutesLeft = computed(() => {
const row = this.row();
if (row.when !== 'now') {
return null;
}
return Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000));
});
onRowClick(): void {
this.activate.emit();
}
/**
* Keyboard Enter/Space activates the row only when the event target is the
* row itself — keydown from the nested watch/info buttons bubbles up here,
* and hijacking it would swap the button's action for row activation (the
* `preventDefault()` would also suppress the button's native click).
*/
onKeydown(event: Event): void {
if (event.target !== event.currentTarget) {
return;
}
event.preventDefault();
this.activate.emit();
}
onWatch(event: Event): void {
event.stopPropagation();
this.watch.emit();
}
onInfo(event: Event): void {
event.stopPropagation();
this.info.emit();
}
}
@@ -0,0 +1,217 @@
@let state = renderState();
<div
class="guide"
[class.guide--no-archive]="!archivePlaybackAvailable()"
[class.is-collapsed]="collapsed()"
>
<!-- toolbar: collapse + channel on the left, live/jump/stepper on the right -->
<div class="g-head">
<button
type="button"
class="g-collapse"
(click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<span class="cc"><mat-icon>keyboard_arrow_down</mat-icon></span>
<span class="gt">
<b>{{ channelName() }}</b>
<span class="src">{{ sourceLabel() }}</span>
</span>
</button>
<span class="g-spacer"></span>
@if (collapsed()) {
<!-- collapsed inline summary lives IN the toolbar row: hosts clamp
the collapsed panel to 56px, so a block below it would be
clipped (mirrors the timeline's collapsed layout). -->
@if (loading()) {
<span class="cn-title cn-title--muted">{{
'EPG.LOADING' | translate
}}</span>
} @else if (hasSummary()) {
@if (isLivePlayback()) {
<span class="cn-tag live">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
<span class="cn-title">{{ summary()?.title }}</span>
@if (progress() !== null) {
<span class="cn-bar" aria-hidden="true"
><i [style.width.%]="progress()"></i
></span>
}
@if (minutesLeft() !== null) {
<span class="cn-meta"
>{{ minutesLeft() }}
{{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}</span
>
}
} @else {
<span class="cn-tag arch">
<mat-icon>play_arrow</mat-icon>
{{ 'EPG.ARCHIVE_PLAYBACK' | translate }}
</span>
<span class="cn-title">{{ summary()?.title }}</span>
@if (hasTimeRange()) {
<span class="cn-meta"
>{{ summary()?.start | date: 'HH:mm' }} –
{{ summary()?.stop | date: 'HH:mm' }}</span
>
}
}
} @else {
<span class="cn-title cn-title--muted">{{
'EPG.NO_PROGRAM_INFO' | translate
}}</span>
}
} @else {
@if (!isLivePlayback()) {
<button
type="button"
class="g-livebtn"
(click)="returnToLive.emit()"
>
{{ 'EPG.TIMELINE.RETURN_TO_LIVE' | translate }}
</button>
}
@if (showJump()) {
<button
type="button"
class="g-jump"
(click)="jumpToNow()"
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
>
<mat-icon>my_location</mat-icon>
{{ 'EPG.TIMELINE.NOW' | translate }}
</button>
}
@if (showDateStepper()) {
<div class="g-date">
<button
type="button"
class="nav"
[attr.aria-label]="'EPG.PREVIOUS_DAY' | translate"
(click)="stepDay('prev')"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div class="d">
<span>
@if (isViewToday()) {
{{ 'EPG.TIMELINE.TODAY' | translate }}
} @else {
{{
viewDate()
| date: 'EEE' : '' : currentLocale()
}}
}
</span>
<small>{{
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
}}</small>
</div>
<button
type="button"
class="nav"
[attr.aria-label]="'EPG.NEXT_DAY' | translate"
(click)="stepDay('next')"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
}
}
</div>
@if (!collapsed()) {
<!-- schedule-only notice (provider exposes history but no catch-up) -->
@if (state === 'list' && !archivePlaybackAvailable()) {
<div class="g-notice" role="note">
<mat-icon>info</mat-icon>
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
</div>
}
@switch (state) {
@case ('loading') {
<div class="g-list g-list--skeleton">
@for (row of skeletonRows; track $index) {
<div class="sk-row">
<span class="sk-time"></span>
<span class="sk-title"></span>
</div>
}
</div>
}
@case ('list') {
<div class="g-listwrap">
<!-- In normal flow (not an overlay): when it appears it
pushes the list down instead of covering the topmost
programme row. -->
@if (nowStripVisible() && nowRow(); as now) {
<button
type="button"
class="g-nowstrip"
(click)="jumpToNow()"
>
<span class="ns-dot" aria-hidden="true"></span>
<span class="ns-lbl">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
<span class="ns-title">{{ now.program.title }}</span>
@if (now.progress !== null) {
<span class="ns-bar" aria-hidden="true"
><i [style.width.%]="now.progress"></i
></span>
}
@if (nowRowMinutesLeft() !== null) {
<span class="ns-left"
>{{ nowRowMinutesLeft() }}
{{
'EPG.TIMELINE.MINUTES_SHORT' | translate
}}</span
>
}
<span class="ns-jump">
<mat-icon>arrow_upward</mat-icon>
{{ 'EPG.TIMELINE.NOW' | translate }}
</span>
</button>
}
<div
#list
class="g-list app-scrollbar"
(scroll)="onListScroll()"
>
@for (row of rows(); track row.key) {
<app-epg-list-view-row
[row]="row"
[selected]="row.key === selectedKey()"
[currentLocale]="currentLocale()"
[nowMs]="nowMs()"
(activate)="onRowActivate(row)"
(watch)="onWatch(row)"
(info)="openDetails(row)"
/>
}
</div>
</div>
}
@default {
<app-epg-timeline-empty-state
[reason]="emptyStateReason()"
[hasOtherDays]="hasOtherDays()"
(jumpToday)="jumpToNow()"
(jumpNearest)="jumpToNearestDay()"
(openSettings)="openEpgSettings.emit()"
(retry)="retry.emit()"
/>
}
}
}
</div>
@@ -0,0 +1,440 @@
$line: var(--line, rgba(255, 255, 255, 0.06));
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
$surface-1: var(--surface-1, #0f141c);
$surface-2: var(--surface-2, #161d28);
$surface-3: var(--surface-3, #1d2533);
$text-primary: var(--text-primary, #e7ecf3);
$text-secondary: var(--text-secondary, #9aa3b2);
$text-tertiary: var(--text-tertiary, #6b7384);
$accent-blue: var(--accent-blue, #4f8eff);
$accent-cyan: var(--accent-cyan, #5cd6ff);
$accent-live: var(--accent-live, #ff5b6e);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
/* This app has no global border-box reset; the list relies on it. */
.guide,
.guide * {
box-sizing: border-box;
}
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #0a0e15;
}
.guide {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* ── toolbar ── */
.g-head {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 16px;
border-bottom: 1px solid $line;
background: $surface-1;
flex: 0 0 auto;
}
.g-collapse {
display: inline-flex;
align-items: center;
gap: 9px;
background: transparent;
border: none;
color: $text-primary;
cursor: pointer;
padding: 4px 6px;
border-radius: 7px;
min-width: 0;
}
.g-collapse:hover {
background: $surface-2;
}
.cc {
width: 20px;
height: 20px;
display: grid;
place-items: center;
color: $text-secondary;
transition: transform 200ms ease;
flex: 0 0 auto;
}
.cc mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.is-collapsed .cc {
transform: rotate(-90deg);
}
.gt {
display: flex;
flex-direction: column;
line-height: 1.15;
text-align: left;
min-width: 0;
}
.gt b {
font-size: 13.5px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gt .src {
font-family: $font-mono;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $text-tertiary;
}
.g-spacer {
flex: 1;
}
.g-livebtn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 15px;
border-radius: 999px;
background: rgba(255, 91, 110, 0.14);
border: 1px solid rgba(255, 91, 110, 0.32);
color: $accent-live;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
flex: 0 0 auto;
}
.g-livebtn::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: $accent-live;
box-shadow: 0 0 7px $accent-live;
}
.g-jump {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 13px;
border-radius: 999px;
background: $surface-2;
border: 1px solid $line-strong;
color: $text-secondary;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
flex: 0 0 auto;
}
.g-jump:hover {
color: $text-primary;
border-color: $accent-blue;
}
.g-jump mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.g-date {
display: inline-flex;
align-items: center;
gap: 4px;
height: 34px;
background: $surface-2;
border: 1px solid $line;
border-radius: 999px;
padding: 2px;
flex: 0 0 auto;
}
.g-date .nav {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
border: none;
background: transparent;
color: $text-secondary;
cursor: pointer;
}
.g-date .nav:hover {
background: $surface-3;
color: $text-primary;
}
.g-date .nav mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.g-date .d {
font-size: 13px;
font-weight: 500;
color: $text-primary;
padding: 0 8px;
min-width: 116px;
text-align: center;
}
.g-date .d span {
display: block;
}
.g-date .d small {
display: block;
font-family: $font-mono;
font-size: 9.5px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-tertiary;
margin-top: 1px;
}
/* ── schedule-only notice ── */
.g-notice {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 18px;
background: rgba(79, 142, 255, 0.07);
border-bottom: 1px solid $line;
color: $text-secondary;
font-size: 12.5px;
flex: 0 0 auto;
}
.g-notice mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
color: $accent-blue;
flex: 0 0 auto;
}
/* ── collapsed inline summary (rendered inside the .g-head toolbar row:
hosts clamp the collapsed panel to 56px, so a separate block below the
toolbar would be clipped — mirrors the timeline's collapsed layout) ── */
.cn-tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: $font-mono;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
flex: 0 0 auto;
}
.cn-tag.live {
color: $accent-live;
}
.cn-tag.live::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: $accent-live;
box-shadow: 0 0 6px $accent-live;
}
.cn-tag.arch {
color: $accent-blue;
}
.cn-tag mat-icon {
width: 12px;
height: 12px;
font-size: 12px;
}
.cn-title {
font-size: 13px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.cn-title--muted {
color: $text-tertiary;
font-weight: 500;
}
.cn-bar {
width: 84px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
flex: 0 0 auto;
}
.cn-bar > i {
display: block;
height: 100%;
background: $accent-cyan;
}
.cn-meta {
font-family: $font-mono;
font-size: 11px;
color: $text-tertiary;
flex: 0 0 auto;
}
/* ── list ── */
.g-listwrap {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.g-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px 0;
}
/* ── condensed now-strip: in normal flow above the list, so appearing pushes
the rows down instead of overlaying the topmost programme ── */
.g-nowstrip {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 11px;
padding: 9px 18px;
border: none;
border-bottom: 1px solid $line-strong;
background: rgba(13, 18, 26, 0.93);
cursor: pointer;
text-align: left;
animation: epg-nowstrip-in 180ms ease;
}
@keyframes epg-nowstrip-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.g-nowstrip {
animation: none;
}
}
.ns-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: $accent-live;
box-shadow: 0 0 7px $accent-live;
flex: 0 0 auto;
}
.ns-lbl {
font-family: $font-mono;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $accent-live;
flex: 0 0 auto;
}
.ns-title {
font-size: 12.5px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.ns-bar {
width: 84px;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
flex: 0 0 auto;
}
.ns-bar > i {
display: block;
height: 100%;
background: $accent-cyan;
}
.ns-left {
font-family: $font-mono;
font-size: 11px;
color: $text-tertiary;
flex: 0 0 auto;
}
.ns-jump {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 600;
color: $accent-blue;
flex: 0 0 auto;
}
.ns-jump mat-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
/* ── loading skeleton ── */
.g-list--skeleton {
flex: 1;
min-height: 0;
padding: 12px 22px;
}
.sk-row {
display: grid;
grid-template-columns: 92px 1fr;
gap: 16px;
align-items: center;
padding: 11px 0;
}
.sk-time,
.sk-title {
height: 12px;
border-radius: 6px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0.06)
);
background-size: 200% 100%;
animation: epg-list-shimmer 1.4s linear infinite;
}
.sk-title {
max-width: 60%;
}
@keyframes epg-list-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.sk-time,
.sk-title {
animation: none;
}
}
@@ -0,0 +1,247 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { EpgListViewComponent } from './epg-list-view.component';
import { EpgListRow } from './epg-list-view.utils';
function programAt(
startOffsetMin: number,
durationMin: number,
title = 'P'
): EpgProgram {
const start = new Date(Date.now() + startOffsetMin * 60_000);
const stop = new Date(start.getTime() + durationMin * 60_000);
return {
start: start.toISOString(),
stop: stop.toISOString(),
channel: 'ch',
title,
desc: null,
category: null,
};
}
function localDateKey(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
describe('EpgListViewComponent', () => {
let fixture: ComponentFixture<EpgListViewComponent>;
let component: EpgListViewComponent;
let dialogResult: BehaviorSubject<'live' | 'timeshift' | undefined>;
beforeEach(() => {
dialogResult = new BehaviorSubject<'live' | 'timeshift' | undefined>(
undefined
);
TestBed.configureTestingModule({
imports: [EpgListViewComponent],
providers: [
{
provide: MatDialog,
useValue: {
open: () => ({ afterClosed: () => dialogResult }),
},
},
{
provide: TranslateService,
useValue: {
currentLang: 'en',
defaultLang: 'en',
onLangChange: new BehaviorSubject(null),
},
},
],
});
fixture = TestBed.createComponent(EpgListViewComponent);
component = fixture.componentInstance;
});
function setInputs(inputs: Record<string, unknown>): void {
for (const [key, value] of Object.entries(inputs)) {
fixture.componentRef.setInput(key, value);
}
}
function rowWhen(when: EpgListRow['when']): EpgListRow {
const row = component.rows().find((r) => r.when === when);
expect(row).toBeDefined();
return row as EpgListRow;
}
it('renders the list when programmes exist for the current day', () => {
setInputs({ programs: [programAt(0, 120, 'Now')] });
expect(component.renderState()).toBe('list');
expect(component.emptyStateReason()).toBe('none');
});
it('shows the loading state regardless of programmes', () => {
setInputs({ programs: [programAt(0, 60)], loading: true });
expect(component.renderState()).toBe('loading');
});
it('falls back to channel-unmapped when there are no programmes', () => {
setInputs({ programs: [] });
expect(component.renderState()).toBe('channel-unmapped');
});
it('honours an explicit empty reason from the host', () => {
setInputs({ programs: [], emptyReason: 'provider-no-epg' });
expect(component.renderState()).toBe('provider-no-epg');
});
it('reports empty-day when programmes exist but not for the viewed day', () => {
setInputs({ programs: [programAt(3 * 1440, 60)] });
expect(component.renderState()).toBe('empty-day');
});
it('honours a controlled non-today selectedDate', () => {
const future = programAt(3 * 1440, 60);
setInputs({
programs: [future],
selectedDate: localDateKey(future.start),
});
expect(component.renderState()).toBe('list');
});
it('follows a programmatic selectedDate change from the host', () => {
const future = programAt(3 * 1440, 60);
setInputs({ programs: [future] });
expect(component.renderState()).toBe('empty-day');
setInputs({ selectedDate: localDateKey(future.start) });
expect(component.renderState()).toBe('list');
});
it('shows the date stepper for list + empty-day, hides it with no EPG anywhere', () => {
setInputs({ programs: [programAt(0, 120)] });
expect(component.showDateStepper()).toBe(true);
setInputs({ programs: [programAt(3 * 1440, 60)] });
expect(component.renderState()).toBe('empty-day');
expect(component.showDateStepper()).toBe(true);
setInputs({ programs: [] });
expect(component.showDateStepper()).toBe(false);
setInputs({ programs: [], emptyReason: 'provider-no-epg' });
expect(component.showDateStepper()).toBe(false);
});
it('shows the Now jump only off-today or while watching archive', () => {
setInputs({ programs: [programAt(0, 120)], isLivePlayback: true });
expect(component.showJump()).toBe(false); // today + live
setInputs({ isLivePlayback: false });
expect(component.showJump()).toBe(true); // watching archive
});
it('emits returnToLive when the on-now row is activated', () => {
setInputs({ programs: [programAt(-30, 60, 'Live')] });
let returned = false;
component.returnToLive.subscribe(() => (returned = true));
component.onRowActivate(rowWhen('now'));
expect(returned).toBe(true);
});
it('emits a timeshift activation when a catch-up row is activated', () => {
// Anchor the viewed day to the fixture's own day: a "3 hours ago"
// programme belongs to *yesterday* when the suite runs 00:00–02:00
// local, and today's row filter would drop it (midnight flakiness).
const past = programAt(-180, 60, 'Earlier');
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
archivePlaybackAvailable: true,
archiveDays: 7,
});
const events: string[] = [];
component.programActivated.subscribe((e) => events.push(e.type));
component.onRowActivate(rowWhen('past'));
expect(events).toEqual(['timeshift']);
});
it('does not activate a past row when catch-up is unavailable', () => {
const past = programAt(-180, 60, 'Earlier');
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
archivePlaybackAvailable: false,
});
const events: string[] = [];
component.programActivated.subscribe((e) => events.push(e.type));
let returned = false;
component.returnToLive.subscribe(() => (returned = true));
component.onRowActivate(rowWhen('past'));
expect(events).toEqual([]);
expect(returned).toBe(false);
});
it('emits timeshift from the explicit Watch affordance', () => {
const past = programAt(-180, 60, 'Earlier');
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
archivePlaybackAvailable: true,
archiveDays: 7,
});
const events: string[] = [];
component.programActivated.subscribe((e) => events.push(e.type));
component.onWatch(rowWhen('past'));
expect(events).toEqual(['timeshift']);
});
it('opens details and reacts to a timeshift dialog result', () => {
const past = programAt(-180, 60, 'Earlier');
setInputs({
programs: [past],
selectedDate: localDateKey(past.start),
archivePlaybackAvailable: true,
archiveDays: 7,
});
dialogResult.next('timeshift');
const events: string[] = [];
component.programActivated.subscribe((e) => events.push(e.type));
component.openDetails(rowWhen('past'));
expect(events).toEqual(['timeshift']);
});
it('emits the day when stepping forward', () => {
setInputs({ programs: [programAt(-30, 60)] });
const emitted: string[] = [];
component.selectedDateChange.subscribe((d) => emitted.push(d));
component.stepDay('next');
expect(emitted).toHaveLength(1);
expect(emitted[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('toggles the collapsed state through the output', () => {
setInputs({ collapsed: false });
const emitted: boolean[] = [];
component.collapsedChange.subscribe((v) => emitted.push(v));
component.toggleCollapsed();
expect(emitted).toEqual([true]);
});
it('derives a live progress percentage from the summary when collapsed', () => {
const start = new Date(Date.now() - 30 * 60_000).toISOString();
const stop = new Date(Date.now() + 30 * 60_000).toISOString();
setInputs({ collapsed: true, summary: { title: 'Now', start, stop } });
expect(component.hasSummary()).toBe(true);
expect(component.progress()).toBeGreaterThan(40);
expect(component.progress()).toBeLessThan(60);
expect(component.minutesLeft()).toBeGreaterThan(0);
});
});
@@ -0,0 +1,304 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
linkedSignal,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
EpgDateNavigationDirection,
getTodayEpgDateKey,
parseEpgDateKey,
shiftEpgDateKey,
} from '../epg-date';
import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component';
import type { EpgProgramActivationEvent } from '../epg-list/epg-list.component';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import { epgDialogActionFor } from '../epg-timeline/epg-archive.util';
import {
EpgTimelineSummary,
summaryHasTimeRange,
summaryHasTitle,
summaryMinutesLeft,
summaryProgress,
} from '../epg-timeline/epg-summary.util';
import {
EpgTimelineEmptyReason,
EpgTimelineEmptyStateComponent,
} from '../epg-timeline/epg-timeline-empty-state.component';
import {
hasProgramsForDateKey,
nearestDateKeyWithPrograms,
} from '../epg-timeline/epg-timeline.utils';
import { EpgListScrollController } from './epg-list-scroll.controller';
import { EpgListViewRowComponent } from './epg-list-view-row/epg-list-view-row.component';
import { buildEpgListRows, EpgListRow } from './epg-list-view.utils';
type RenderState = 'loading' | 'list' | EpgTimelineEmptyReason;
/**
* Vertical, single-day EPG list — a drop-in alternative to `app-epg-timeline`
* with an identical controlled input/output contract, so hosts swap the two
* with a plain `@if`. Reuses the timeline's view-agnostic modules (archive
* gating, summary maths, date helpers, empty-state, details dialog); drops all
* ribbon geometry, zoom, and horizontal scroll.
*/
@Component({
selector: 'app-epg-list-view',
templateUrl: './epg-list-view.component.html',
styleUrl: './epg-list-view.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DatePipe,
EpgListViewRowComponent,
EpgTimelineEmptyStateComponent,
MatIcon,
MatTooltip,
TranslatePipe,
],
})
export class EpgListViewComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
/** Same input as the timeline's, but a list-appropriate default: hosts
* that don't pass a label (M3U, unified tab) would otherwise show the
* timeline's literal "Timeline" while in list mode. */
readonly sourceLabel = input('Programme guide');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly emptyReason = input<EpgTimelineEmptyReason>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
private readonly programmeDialog = inject(EpgProgrammeDialogService);
private readonly translate = inject(TranslateService);
readonly list = viewChild<ElementRef<HTMLElement>>('list');
readonly nowMs = signal(Date.now());
readonly selectedKey = signal<string | null>(null);
readonly nowStripVisible = signal(false);
/** Day shown, seeded from the controlled `selectedDate` so a non-today
* date survives (re)mount and follows host changes — same as the timeline. */
private readonly viewDayKey = linkedSignal(() => {
const key = this.selectedDate()?.trim();
return key ? key : getTodayEpgDateKey();
});
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly currentLocale = computed(() => {
this.languageTick();
return normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
});
readonly rows = computed<EpgListRow[]>(() =>
buildEpgListRows(this.programs(), this.viewDayKey(), this.nowMs(), {
archivePlaybackAvailable: this.archivePlaybackAvailable(),
archiveDays: this.archiveDays(),
activeProgram: this.activeProgram(),
})
);
readonly nowRow = computed<EpgListRow | null>(
() => this.rows().find((row) => row.when === 'now') ?? null
);
readonly nowRowMinutesLeft = computed(() => {
const row = this.nowRow();
return row ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000)) : null;
});
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
readonly isViewToday = computed(
() => this.viewDayKey() === getTodayEpgDateKey()
);
readonly renderState = computed<RenderState>(() => {
if (this.loading()) {
return 'loading';
}
const reason = this.emptyReason();
if (reason !== 'none') {
return reason;
}
if (this.programs().length === 0) {
return 'channel-unmapped';
}
if (!hasProgramsForDateKey(this.programs(), this.viewDayKey())) {
return 'empty-day';
}
return 'list';
});
readonly emptyStateReason = computed<EpgTimelineEmptyReason>(() => {
const state = this.renderState();
return state === 'loading' || state === 'list' ? 'none' : state;
});
readonly hasOtherDays = computed(() => this.programs().length > 0);
readonly showDateStepper = computed(() => {
const state = this.renderState();
return state === 'list' || state === 'empty-day';
});
/** "Now" jump: only useful off-today or while watching archive. */
readonly showJump = computed(
() =>
this.renderState() === 'list' &&
(!this.isViewToday() || !this.isLivePlayback())
);
readonly skeletonRows = [0, 1, 2, 3, 4, 5];
// ── collapsed-summary state (reused from the timeline) ──
readonly hasSummary = computed(() => summaryHasTitle(this.summary()));
readonly hasTimeRange = computed(() => summaryHasTimeRange(this.summary()));
readonly progress = computed(() =>
summaryProgress(this.summary(), this.nowMs())
);
readonly minutesLeft = computed(() =>
summaryMinutesLeft(this.summary(), this.nowMs())
);
private readonly scroll = new EpgListScrollController({
list: () => this.list()?.nativeElement,
isViewToday: () => this.isViewToday(),
setNowStripVisible: (visible) => this.nowStripVisible.set(visible),
});
constructor() {
// 30s tick reclassifies past/now/future and refreshes progress. This is
// a controlled component (activeProgram/isLivePlayback come from the
// host), so the tick never clobbers active archive playback.
effect((onCleanup) => {
const intervalId = window.setInterval(
() => this.nowMs.set(Date.now()),
30_000
);
onCleanup(() => clearInterval(intervalId));
});
// Auto-focus the on-air row when a channel's EPG (re)loads or the list
// (re)mounts (collapse → expand) while viewing today — the vertical
// analogue of the ribbon's auto-focus. Tracks the `list` viewChild so a
// remount re-triggers; deduped inside the controller so 30s ticks and
// data re-emits don't re-jump.
effect(() => {
const list = this.list()?.nativeElement;
const rows = this.rows();
const today = this.isViewToday();
const channel = this.channelName();
untracked(() =>
this.scroll.maybeAutoScroll(list, rows, today, channel)
);
});
}
toggleCollapsed(): void {
this.collapsedChange.emit(!this.collapsed());
}
stepDay(direction: EpgDateNavigationDirection): void {
this.commitDay(shiftEpgDateKey(this.viewDayKey(), direction));
this.scroll.resetListScroll();
}
jumpToNow(): void {
this.commitDay(getTodayEpgDateKey());
// Deferred: when jumping from another day, today's rows only exist
// after the next render. Deliberate user action → animate.
this.scroll.focusNowAfterRender(true);
}
jumpToNearestDay(): void {
const nearest = nearestDateKeyWithPrograms(
this.programs(),
this.nowMs()
);
if (nearest) {
this.commitDay(nearest);
this.scroll.resetListScroll();
}
}
onRowActivate(row: EpgListRow): void {
this.selectedKey.set(row.key);
if (row.when === 'now') {
this.returnToLive.emit();
return;
}
if (row.canCatchUp) {
this.programActivated.emit({
program: row.program,
type: 'timeshift',
});
}
}
onWatch(row: EpgListRow): void {
this.selectedKey.set(row.key);
this.programActivated.emit({ program: row.program, type: 'timeshift' });
}
openDetails(row: EpgListRow): void {
this.programmeDialog
.open({
...row.program,
channelName: this.channelName(),
channelLogo: this.channelLogo(),
primaryAction: epgDialogActionFor(row.when, row.canCatchUp),
archiveUnavailableNote:
row.when === 'past' && !this.archivePlaybackAvailable(),
})
.subscribe((result: EpgItemDialogAction | undefined) => {
if (result === 'live') {
this.returnToLive.emit();
} else if (result === 'timeshift') {
this.selectedKey.set(row.key);
this.programActivated.emit({
program: row.program,
type: 'timeshift',
});
}
});
}
onListScroll(): void {
this.scroll.updateNowStrip();
}
private commitDay(dayKey: string): void {
if (dayKey === this.viewDayKey()) {
return;
}
this.viewDayKey.set(dayKey);
this.selectedDateChange.emit(dayKey);
}
}
@@ -0,0 +1,137 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { buildEpgListRows } from './epg-list-view.utils';
// Anchor "now" at local noon so ±3h fixtures stay inside the same local day,
// keeping the overlap-based day filter deterministic regardless of run time.
const NOON = new Date();
NOON.setHours(12, 0, 0, 0);
const NOW = NOON.getTime();
function programAt(
startOffsetMin: number,
durationMin: number,
title = 'P',
extra: Partial<EpgProgram> = {}
): EpgProgram {
const start = new Date(NOW + startOffsetMin * 60_000);
const stop = new Date(start.getTime() + durationMin * 60_000);
return {
start: start.toISOString(),
stop: stop.toISOString(),
channel: 'ch',
title,
desc: null,
category: null,
...extra,
};
}
function dayKey(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const TODAY = dayKey(NOW);
const DEFAULT_OPTS = {
archivePlaybackAvailable: false,
archiveDays: 0,
activeProgram: null,
};
describe('buildEpgListRows', () => {
it('keeps only programmes overlapping the selected day, sorted by start', () => {
const rows = buildEpgListRows(
[
programAt(90, 60, 'Later'),
programAt(-30, 60, 'Now'),
programAt(3 * 1440, 60, 'ThreeDaysOut'),
],
TODAY,
NOW,
DEFAULT_OPTS
);
expect(rows.map((r) => r.program.title)).toEqual(['Now', 'Later']);
});
it('classifies past / now / future relative to nowMs', () => {
const rows = buildEpgListRows(
[
programAt(-180, 60, 'Past'),
programAt(-30, 60, 'Now'),
programAt(120, 60, 'Future'),
],
TODAY,
NOW,
DEFAULT_OPTS
);
expect(rows.map((r) => r.when)).toEqual(['past', 'now', 'future']);
});
it('computes live progress only for the now row', () => {
const rows = buildEpgListRows(
[programAt(-30, 60, 'Now'), programAt(120, 60, 'Future')],
TODAY,
NOW,
DEFAULT_OPTS
);
const now = rows.find((r) => r.when === 'now');
const future = rows.find((r) => r.when === 'future');
expect(now?.progress).toBeGreaterThan(40);
expect(now?.progress).toBeLessThan(60);
expect(future?.progress).toBeNull();
});
it('gates catch-up on archive availability + window', () => {
const past = [programAt(-180, 60, 'Past')];
const withArchive = buildEpgListRows(past, TODAY, NOW, {
archivePlaybackAvailable: true,
archiveDays: 7,
activeProgram: null,
});
expect(withArchive[0].canCatchUp).toBe(true);
const noArchive = buildEpgListRows(past, TODAY, NOW, DEFAULT_OPTS);
expect(noArchive[0].canCatchUp).toBe(false);
});
it('marks the active programme', () => {
const active = programAt(-180, 60, 'Past');
const rows = buildEpgListRows(
[active, programAt(-30, 60, 'Now')],
TODAY,
NOW,
{
archivePlaybackAvailable: true,
archiveDays: 7,
activeProgram: active,
}
);
expect(rows.find((r) => r.program.title === 'Past')?.isActive).toBe(
true
);
expect(rows.find((r) => r.program.title === 'Now')?.isActive).toBe(
false
);
});
it('deduplicates programmes sharing a time slot, keeping the richer one', () => {
const rows = buildEpgListRows(
[
programAt(-30, 60, 'Now'),
programAt(-30, 60, 'Now', { desc: 'With description' }),
],
TODAY,
NOW,
DEFAULT_OPTS
);
expect(rows).toHaveLength(1);
expect(rows[0].program.desc).toBe('With description');
});
});
@@ -0,0 +1,117 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { addDays } from 'date-fns';
import { parseEpgDateKey } from '../epg-date';
import {
areProgramsSame,
deduplicateProgramsByTimeSlot,
getProgramTimeMs,
} from '../epg-list/epg-list.utils';
import { canCatchUpProgramme } from '../epg-timeline/epg-archive.util';
import {
classifyTimelineWhen,
TimelineWhen,
} from '../epg-timeline/epg-timeline.utils';
/**
* One vertical list row. View-agnostic classification/gating is precomputed so
* the row component stays dumb and the list re-derives everything off the 30s
* `nowMs` tick. Mirrors the timeline's `TimelineBlock` semantics without the
* ribbon geometry (offset/duration in px).
*/
export interface EpgListRow {
readonly program: EpgProgram;
readonly key: string;
readonly startMs: number;
readonly stopMs: number;
readonly when: TimelineWhen;
/** Live progress 0–100 for the `now` row, else null. */
readonly progress: number | null;
/** Matches the host's active programme (archive playback highlight). */
readonly isActive: boolean;
/** Past programme playable from the catch-up archive. */
readonly canCatchUp: boolean;
}
export interface BuildEpgListRowsOptions {
readonly archivePlaybackAvailable: boolean;
readonly archiveDays: number;
readonly activeProgram: EpgProgram | null;
}
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value));
}
function liveProgress(
when: TimelineWhen,
startMs: number,
stopMs: number,
nowMs: number
): number | null {
if (when !== 'now' || stopMs <= startMs) {
return null;
}
return clampPercent(((nowMs - startMs) / (stopMs - startMs)) * 100);
}
/**
* Programmes overlapping the given local day, sorted by start and deduplicated.
* Overlap-based (not start-date-only) so a cross-midnight programme airing now
* still shows for *today* — matching `hasProgramsForDateKey`, which the list's
* render-state uses. A start-date filter would drop the on-air programme and
* leave an empty list while the state says `list`.
*/
export function buildEpgListRows(
programs: readonly EpgProgram[],
selectedDateKey: string,
nowMs: number,
options: BuildEpgListRowsOptions
): EpgListRow[] {
const dayStart = parseEpgDateKey(selectedDateKey);
const dayStartMs = dayStart.getTime();
const dayEndMs = addDays(dayStart, 1).getTime(); // exclusive
const forDay = programs.filter((program) => {
const startMs = getProgramTimeMs(program.start, program.startTimestamp);
const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp);
if (!Number.isFinite(startMs) || !Number.isFinite(stopMs)) {
return false;
}
return startMs < dayEndMs && stopMs > dayStartMs;
});
const deduped = deduplicateProgramsByTimeSlot(
[...forDay].sort(
(left, right) =>
getProgramTimeMs(left.start, left.startTimestamp) -
getProgramTimeMs(right.start, right.startTimestamp)
)
);
const activeProgram = options.activeProgram;
return deduped.map((program, index) => {
const startMs = getProgramTimeMs(program.start, program.startTimestamp);
const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp);
const when = classifyTimelineWhen(startMs, stopMs, nowMs);
return {
program,
key: `${startMs}-${stopMs}-${index}`,
startMs,
stopMs,
when,
progress: liveProgress(when, startMs, stopMs, nowMs),
isActive: activeProgram
? areProgramsSame(program, activeProgram)
: false,
canCatchUp: canCatchUpProgramme(
when,
startMs,
options.archivePlaybackAvailable,
options.archiveDays,
nowMs
),
};
});
}
+11 -2
View File
@@ -133,13 +133,21 @@
flex: 0 0 var(--epg-inline-height, clamp(180px, 36vh, 264px));
}
// Vertical list view (epgViewMode = 'list'): rows need more height to
// be useful — raise the inline clamp only. Sets just the variable, so
// the later `.epg-collapsed` flex rule still wins when collapsed.
&.epg--list {
--epg-inline-height: clamp(280px, 46vh, 430px);
}
&.epg-collapsed {
flex: 0 0 56px;
min-height: 56px;
}
> app-live-epg-panel,
> app-epg-timeline {
> app-epg-timeline,
> app-epg-list-view {
flex: 1;
min-height: 0;
}
@@ -154,7 +162,8 @@
> app-epg-view,
> app-live-epg-panel,
> app-epg-timeline {
> app-epg-timeline,
> app-epg-list-view {
flex: 1;
min-height: 0;
}