mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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:
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>
|
||||
}
|
||||
|
||||
+28
-1
@@ -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'
|
||||
);
|
||||
|
||||
+42
-14
@@ -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>
|
||||
}
|
||||
|
||||
+46
-1
@@ -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');
|
||||
|
||||
+6
@@ -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());
|
||||
|
||||
+46
-19
@@ -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>
|
||||
+33
-2
@@ -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;
|
||||
|
||||
+6
@@ -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())
|
||||
|
||||
+42
-19
@@ -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>
|
||||
+31
-1
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+131
@@ -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
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user