-
+ @if (epgViewMode() === 'list') {
+
+ } @else {
+
+ }
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts
index 84d31d973..e32ac6a29 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts
@@ -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: `
{{ summary()?.title }}
@@ -230,10 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
};
const settingsStore = {
openStreamOnDoubleClick: signal(false),
+ resolvedEpgViewMode: 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.resolvedEpgViewMode.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.resolvedEpgViewMode.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.resolvedEpgViewMode.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;
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
index 4eba2e00e..e50766d38 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
@@ -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,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
+ /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
+ readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts
index 7de311c3d..c0dc55e9e 100644
--- a/libs/portal/xtream/feature/src/index.ts
+++ b/libs/portal/xtream/feature/src/index.ts
@@ -2,7 +2,6 @@ export * from './lib/account-info/account-info.component';
export * from './lib/category-management-dialog/category-management-dialog.component';
export * from './lib/global-search-results/global-search-results.component';
export * from './lib/live-stream-layout/live-stream-layout.component';
-export * from './lib/loading-overlay.component';
export * from './lib/portal-channels-list/portal-channels-list.component';
export * from './lib/recently-added/recently-added.component';
export * from './lib/search-results/search-results.component';
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html
index 16b343dc8..48c74e9c4 100644
--- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html
+++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html
@@ -109,6 +109,7 @@
-
+ @if (epgViewMode() === 'list') {
+
+ } @else {
+
+ }
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts
index 4abc1b0be..6a591be03 100644
--- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts
+++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts
@@ -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
();
}
+// 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: `
{{ summaryLabelKey() }}
@@ -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.
+ resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'),
};
const originalElectron = window.electron;
@@ -202,6 +208,7 @@ describe('LiveStreamLayoutComponent', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(fixedNow);
+ settingsStore.resolvedEpgViewMode.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.resolvedEpgViewMode.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.resolvedEpgViewMode.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;
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts
index 22ba83a40..51986f145 100644
--- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts
+++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts
@@ -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,8 @@ 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 = this.settingsStore.resolvedEpgViewMode;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(
diff --git a/libs/portal/xtream/feature/src/lib/loading-overlay.component.ts b/libs/portal/xtream/feature/src/lib/loading-overlay.component.ts
deleted file mode 100644
index 9f5192bb3..000000000
--- a/libs/portal/xtream/feature/src/lib/loading-overlay.component.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Component, input } from '@angular/core';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-
-@Component({
- selector: 'app-loading-overlay',
- imports: [MatProgressBarModule],
- template: `
-
-
-
Loading playlist...
- @if (current() !== 0 && total() !== 0) {
-
-
{{ current() }} / {{ total() }}
- } @else {
-
- }
-
-
- `,
- styles: [
- `
- .overlay {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.7);
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 1000;
- }
-
- .progress-container {
- padding: 2rem;
- border-radius: 8px;
- min-width: 300px;
- text-align: center;
-
- h3 {
- margin-top: 0;
- }
- }
- `,
- ],
-})
-export class LoadingOverlayComponent {
- current = input(0);
- total = input(0);
-}
diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts
index c5693c0b3..f65641b00 100644
--- a/libs/services/src/lib/settings-store.service.ts
+++ b/libs/services/src/lib/settings-store.service.ts
@@ -1,7 +1,8 @@
-import { inject } from '@angular/core';
+import { computed, inject } from '@angular/core';
import {
patchState,
signalStore,
+ withComputed,
withHooks,
withMethods,
withState,
@@ -11,6 +12,7 @@ import { firstValueFrom } from 'rxjs';
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
ElectronBridgeTrustOptions,
+ EpgViewMode,
Language,
Settings,
StartupBehavior,
@@ -43,6 +45,7 @@ const DEFAULT_SETTINGS: Settings = {
downloadFolder: '',
recordingFolder: '',
coverSize: 'medium',
+ epgViewMode: 'timeline',
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
@@ -91,6 +94,16 @@ function scheduleEmbeddedMpvPrepare(): void {
export const SettingsStore = signalStore(
{ providedIn: 'root' },
withState(DEFAULT_SETTINGS),
+ withComputed((store) => ({
+ /**
+ * Live EPG panel layout with the `'timeline'` default applied — the
+ * single source of truth for the four live hosts, so the fallback is
+ * not duplicated per call-site.
+ */
+ resolvedEpgViewMode: computed(
+ () => store.epgViewMode?.() ?? 'timeline'
+ ),
+ })),
withMethods((store, storage = inject(StorageMap)) => ({
async loadSettings() {
try {
@@ -173,6 +186,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?.()
),
diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts
index 302900767..9462bd409 100644
--- a/libs/shared/interfaces/src/lib/settings.interface.ts
+++ b/libs/shared/interfaces/src/lib/settings.interface.ts
@@ -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;
/**
diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts
index 41ef73a48..2d5ef66b2 100644
--- a/libs/ui/epg/src/index.ts
+++ b/libs/ui/epg/src/index.ts
@@ -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-item-description/epg-item-description.component';
+export * from './lib/epg-program-activation-event';
+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';
diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.html
similarity index 100%
rename from libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html
rename to libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.html
diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss
similarity index 100%
rename from libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.scss
rename to libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss
diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts
similarity index 100%
rename from libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts
rename to libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.spec.ts
diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts
similarity index 100%
rename from libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts
rename to libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts
new file mode 100644
index 000000000..2e126b3bd
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts
@@ -0,0 +1,218 @@
+import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { EpgListScrollController } from './epg-list-scroll.controller';
+
+function programAt(
+ startOffsetMin: number,
+ durationMin: number,
+ channel = 'ch'
+): 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,
+ title: 'P',
+ desc: null,
+ category: null,
+ };
+}
+
+describe('EpgListScrollController (channel-select auto-scroll)', () => {
+ let controller: EpgListScrollController;
+ let scrollSpy: jest.SpyInstance;
+ let rafSpy: jest.SpyInstance;
+ let hasProgramsToday: jest.Mock;
+ let commitToday: jest.Mock;
+
+ beforeEach(() => {
+ rafSpy = jest
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation((cb: FrameRequestCallback) => {
+ cb(0);
+ return 1;
+ });
+ hasProgramsToday = jest.fn(() => true);
+ commitToday = jest.fn();
+ controller = new EpgListScrollController({
+ list: () => undefined,
+ isViewToday: () => true,
+ setNowStripVisible: () => undefined,
+ hasProgramsToday,
+ commitToday,
+ });
+ scrollSpy = jest
+ .spyOn(controller, 'scrollNowIntoView')
+ .mockImplementation(() => undefined);
+ jest.spyOn(controller, 'focusNowAfterRender');
+ jest.spyOn(controller, 'updateNowStrip').mockImplementation(
+ () => undefined
+ );
+ });
+
+ afterEach(() => {
+ rafSpy.mockRestore();
+ });
+
+ it('scrolls the now row into view instantly on first load', () => {
+ controller.maybeAutoScroll(
+ {} as HTMLElement,
+ [programAt(0, 120)],
+ true,
+ 'ch'
+ );
+
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
+ expect(scrollSpy).toHaveBeenCalledWith(false); // instant, no animation
+ });
+
+ it('does not re-scroll while the same set stays loaded (now-tick / rollover)', () => {
+ // The 30s tick reclassifies past/now/future at every programme
+ // boundary, but the programme SET is unchanged — the viewport must
+ // stay put.
+ const list = {} as HTMLElement;
+ const programs = [programAt(-30, 60), programAt(30, 60)];
+ controller.maybeAutoScroll(list, programs, true, 'ch');
+ controller.maybeAutoScroll(list, programs, 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 programs = [programAt(0, 120)];
+ controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // mount
+ controller.maybeAutoScroll(undefined, programs, true, 'ch'); // collapsed
+ controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // expand
+
+ expect(scrollSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('re-scrolls when the channel changes', () => {
+ const list = {} as HTMLElement;
+ controller.maybeAutoScroll(list, [programAt(0, 120, 'a')], true, 'a');
+ controller.maybeAutoScroll(list, [programAt(0, 120, 'b')], true, 'b');
+
+ expect(scrollSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('returns to today when a new channel arrives while parked on another day', () => {
+ // Channel switch while the user navigated to yesterday: the new set
+ // must reset the view to today (when today has data) — otherwise the
+ // new channel opens on the stale day (timeline parity).
+ controller.maybeAutoScroll(
+ {} as HTMLElement,
+ [programAt(0, 120, 'b')],
+ false,
+ 'b'
+ );
+
+ expect(commitToday).toHaveBeenCalledTimes(1);
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves day navigation alone while the set is unchanged', () => {
+ // Same channel, user steps to yesterday: same set key → no snap back.
+ const list = {} as HTMLElement;
+ const programs = [programAt(0, 120)];
+ controller.maybeAutoScroll(list, programs, true, 'ch');
+ controller.maybeAutoScroll(list, programs, false, 'ch');
+
+ expect(commitToday).not.toHaveBeenCalled();
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not take over when the new set has nothing airing today', () => {
+ hasProgramsToday.mockReturnValue(false);
+ const programs = [programAt(3 * 1440, 60)];
+ controller.maybeAutoScroll({} as HTMLElement, programs, false, 'ch');
+
+ expect(commitToday).not.toHaveBeenCalled();
+ expect(scrollSpy).not.toHaveBeenCalled();
+
+ // The key was not stored — a later, fuller load retries the focus.
+ hasProgramsToday.mockReturnValue(true);
+ controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch');
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing for an empty programme set', () => {
+ controller.maybeAutoScroll({} as HTMLElement, [], true, 'ch');
+
+ expect(scrollSpy).not.toHaveBeenCalled();
+ expect(commitToday).not.toHaveBeenCalled();
+ });
+});
+
+describe('EpgListScrollController (now-strip visibility)', () => {
+ function stripStateFor(list: Partial): boolean | null {
+ let visible: boolean | null = null;
+ const controller = new EpgListScrollController({
+ list: () => list as HTMLElement,
+ isViewToday: () => true,
+ setNowStripVisible: (value) => (visible = value),
+ hasProgramsToday: () => true,
+ commitToday: () => undefined,
+ });
+ 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,
+ hasProgramsToday: () => true,
+ commitToday: () => undefined,
+ });
+
+ controller.scrollNowIntoView(false);
+
+ // 100 (current scroll) + (350 − 50) (row offset within view) − 8
+ expect(scrollTo).toHaveBeenCalledWith({ top: 392, behavior: 'auto' });
+ });
+});
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts
new file mode 100644
index 000000000..9b00c4e68
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts
@@ -0,0 +1,158 @@
+import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { programsFocusKey } from '../epg-timeline/epg-timeline-scroll.controller';
+
+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;
+ /** Whether the loaded window has any programme airing today. */
+ readonly hasProgramsToday: () => boolean;
+ /** Commit today as the viewed day (emits `selectedDateChange`). */
+ readonly commitToday: () => 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 (re)load. Keyed by
+ * the FULL programme-set identity (`programsFocusKey`, like the timeline) —
+ * stable across day navigation, 30s now-ticks, and programme rollovers, so
+ * the viewport is never yanked out from under the user. 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.
+ *
+ * When a NEW programme set arrives while the user is parked on another day
+ * (a channel switch), commit today first — otherwise the new channel opens
+ * on the stale day, possibly with nothing to show (timeline parity).
+ */
+ maybeAutoScroll(
+ list: HTMLElement | undefined,
+ programs: readonly EpgProgram[],
+ today: boolean,
+ channel: string
+ ): void {
+ const setKey = programsFocusKey(programs);
+ if (!setKey) {
+ return;
+ }
+ const key = `${channel}|${setKey}`;
+ if (key === this.autoScrollKey) {
+ if (!list) {
+ return;
+ }
+ 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;
+ }
+ // New programme set. Only take over when today actually has
+ // programmes; otherwise leave the user's day navigation alone (and
+ // don't store the key, so a later fuller load retries).
+ if (!this.deps.hasProgramsToday()) {
+ return;
+ }
+ this.autoScrollKey = key;
+ this.lastList = list ?? null;
+ if (!today) {
+ this.deps.commitToday();
+ }
+ 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('.g-row[data-when="now"]') ?? null
+ );
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.html b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.html
new file mode 100644
index 000000000..0ddeb29df
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.html
@@ -0,0 +1,61 @@
+@let r = row();
+
+
+ {{ r.startMs | date: 'HH:mm' }} – {{ r.stopMs | date: 'HH:mm' }}
+ @if (r.when === 'now') {
+ {{ 'EPG.TIMELINE.ON_NOW' | translate }}
+ }
+
+
+
+
{{ r.program.title }}
+ @if (r.program.desc) {
+
{{ r.program.desc }}
+ }
+
+ @if (r.when === 'now' && r.progress !== null) {
+
+
+
+ }
+ @if (isPlaying()) {
+
+ play_arrow
+ {{ 'EPG.TIMELINE.FROM_ARCHIVE' | translate }}
+
+ }
+
+
+
+ @if (r.when === 'now') {
+ @if (minutesLeft() !== null) {
+ {{ minutesLeft() }}
+ {{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}
+ }
+ } @else if (r.canCatchUp) {
+
+ }
+
+
+
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss
new file mode 100644
index 000000000..a9c06d62a
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss
@@ -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);
+}
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.spec.ts
new file mode 100644
index 000000000..d9320699a
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.spec.ts
@@ -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 {
+ 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;
+ 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();
+ });
+});
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.ts
new file mode 100644
index 000000000..e8a410ab6
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.ts
@@ -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();
+ 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();
+ readonly watch = output();
+ readonly info = output();
+
+ /** 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();
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html
new file mode 100644
index 000000000..b8559e77a
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html
@@ -0,0 +1,217 @@
+@let state = renderState();
+
+
+
+
+
+
+
+
+ @if (collapsed()) {
+
+ @if (loading()) {
+
{{
+ 'EPG.LOADING' | translate
+ }}
+ } @else if (hasSummary()) {
+ @if (isLivePlayback()) {
+
{{
+ 'EPG.TIMELINE.ON_NOW' | translate
+ }}
+
{{ summary()?.title }}
+ @if (progress() !== null) {
+
+ }
+ @if (minutesLeft() !== null) {
+
{{ minutesLeft() }}
+ {{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}
+ }
+ } @else {
+
+ play_arrow
+ {{ 'EPG.ARCHIVE_PLAYBACK' | translate }}
+
+
{{ summary()?.title }}
+ @if (hasTimeRange()) {
+
{{ summary()?.start | date: 'HH:mm' }} –
+ {{ summary()?.stop | date: 'HH:mm' }}
+ }
+ }
+ } @else {
+
{{
+ 'EPG.NO_PROGRAM_INFO' | translate
+ }}
+ }
+ } @else {
+ @if (!isLivePlayback()) {
+
+ }
+ @if (showJump()) {
+
+ }
+ @if (showDateStepper()) {
+
+
+
+
+ @if (isViewToday()) {
+ {{ 'EPG.TIMELINE.TODAY' | translate }}
+ } @else {
+ {{
+ viewDate()
+ | date: 'EEE' : '' : currentLocale()
+ }}
+ }
+
+ {{
+ viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
+ }}
+
+
+
+ }
+ }
+
+
+ @if (!collapsed()) {
+
+ @if (state === 'list' && !archivePlaybackAvailable()) {
+
+ info
+ {{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}
+
+ }
+
+ @switch (state) {
+ @case ('loading') {
+
+ @for (row of skeletonRows; track $index) {
+
+
+
+
+ }
+
+ }
+ @case ('list') {
+
+
+ @if (nowStripVisible() && nowRow(); as now) {
+
+ }
+
+
+ }
+ @default {
+
+ }
+ }
+ }
+
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss
new file mode 100644
index 000000000..ecc2cf272
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss
@@ -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;
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts
new file mode 100644
index 000000000..e5f77bba8
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts
@@ -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;
+ 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): 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);
+ });
+});
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts
new file mode 100644
index 000000000..ace1429e4
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts
@@ -0,0 +1,290 @@
+import { DatePipe } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ ElementRef,
+ inject,
+ input,
+ linkedSignal,
+ output,
+ signal,
+ 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-item-description/epg-item-description.component';
+import type { EpgProgramActivationEvent } from '../epg-program-activation-event';
+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 { registerEpgListViewEffects } from './epg-list-view.effects';
+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([]);
+ 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(null);
+ readonly isLivePlayback = input(true);
+ readonly loading = input(false);
+ readonly emptyReason = input('none');
+ readonly selectedDate = input(null);
+ readonly collapsed = input(false);
+ readonly summary = input(null);
+ readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
+
+ readonly programActivated = output();
+ readonly returnToLive = output();
+ readonly selectedDateChange = output();
+ readonly openEpgSettings = output();
+ readonly retry = output();
+ readonly collapsedChange = output();
+
+ private readonly programmeDialog = inject(EpgProgrammeDialogService);
+ private readonly translate = inject(TranslateService);
+
+ readonly list = viewChild>('list');
+ readonly nowMs = signal(Date.now());
+ readonly selectedKey = signal(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(() =>
+ buildEpgListRows(this.programs(), this.viewDayKey(), this.nowMs(), {
+ archivePlaybackAvailable: this.archivePlaybackAvailable(),
+ archiveDays: this.archiveDays(),
+ activeProgram: this.activeProgram(),
+ })
+ );
+ readonly nowRow = computed(
+ () => 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(() => {
+ 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(() => {
+ 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),
+ hasProgramsToday: () =>
+ hasProgramsForDateKey(this.programs(), getTodayEpgDateKey()),
+ commitToday: () => this.commitDay(getTodayEpgDateKey()),
+ });
+
+ constructor() {
+ registerEpgListViewEffects({
+ nowMs: this.nowMs,
+ list: () => this.list()?.nativeElement,
+ rows: () => this.rows(),
+ programs: () => this.programs(),
+ isViewToday: () => this.isViewToday(),
+ channelName: () => this.channelName(),
+ scroll: this.scroll,
+ });
+ }
+
+ 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);
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts
new file mode 100644
index 000000000..1b11114e4
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts
@@ -0,0 +1,52 @@
+import { effect, untracked, WritableSignal } from '@angular/core';
+import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { EpgListScrollController } from './epg-list-scroll.controller';
+import { EpgListRow } from './epg-list-view.utils';
+
+export interface EpgListViewEffectsContext {
+ /** Wall-clock signal driving past/now/future classification + progress. */
+ readonly nowMs: WritableSignal;
+ readonly list: () => HTMLElement | undefined;
+ readonly rows: () => readonly EpgListRow[];
+ readonly programs: () => readonly EpgProgram[];
+ readonly isViewToday: () => boolean;
+ readonly channelName: () => string;
+ readonly scroll: EpgListScrollController;
+}
+
+/**
+ * The list view's reactive plumbing, kept out of the component so it stays
+ * within the file-size guideline. Must be called from the component's
+ * constructor (an injection context — `effect()` requires one).
+ */
+export function registerEpgListViewEffects(
+ ctx: EpgListViewEffectsContext
+): void {
+ // 30s tick reclassifies past/now/future and refreshes progress. The list
+ // is a controlled component (activeProgram/isLivePlayback come from the
+ // host), so the tick never clobbers active archive playback.
+ effect((onCleanup) => {
+ const intervalId = window.setInterval(
+ () => ctx.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) — the vertical analogue of the ribbon's
+ // auto-focus. Tracks the `list` viewChild so a remount re-triggers, and
+ // `rows` so day changes and 30s ticks refresh the now-strip; the
+ // controller dedupes by programme-set identity so neither re-jumps the
+ // viewport.
+ effect(() => {
+ const list = ctx.list();
+ ctx.rows();
+ const programs = ctx.programs();
+ const today = ctx.isViewToday();
+ const channel = ctx.channelName();
+ untracked(() =>
+ ctx.scroll.maybeAutoScroll(list, programs, today, channel)
+ );
+ });
+}
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts
new file mode 100644
index 000000000..fdc42bfc0
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts
@@ -0,0 +1,155 @@
+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 {
+ 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('drops malformed programmes whose stop is not after their start', () => {
+ // Same as the timeline's buildTimelineBlocks: zero/negative durations
+ // from bad provider data must not render (or become catch-up playable).
+ const zeroDuration = programAt(-60, 0, 'Zero');
+ const negativeDuration = {
+ ...programAt(-60, 60, 'Negative'),
+ stop: new Date(NOW - 120 * 60_000).toISOString(),
+ };
+ const rows = buildEpgListRows(
+ [zeroDuration, negativeDuration, programAt(-30, 60, 'Now')],
+ TODAY,
+ NOW,
+ DEFAULT_OPTS
+ );
+
+ expect(rows.map((r) => r.program.title)).toEqual(['Now']);
+ });
+
+ 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');
+ });
+});
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts
new file mode 100644
index 000000000..2b0364112
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts
@@ -0,0 +1,123 @@
+import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { addDays } from 'date-fns';
+import { parseEpgDateKey } from '../epg-date';
+import {
+ areProgramsSame,
+ deduplicateProgramsByTimeSlot,
+ getProgramTimeMs,
+} from '../epg-program.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;
+ }
+ // Drop malformed programmes (stop not after start) — same as the
+ // timeline's buildTimelineBlocks; they would render impossible time
+ // ranges and could even be offered as catch-up playable.
+ if (stopMs <= startMs) {
+ 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
+ ),
+ };
+ });
+}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html
deleted file mode 100644
index 147733ab4..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html
+++ /dev/null
@@ -1,33 +0,0 @@
-{{ (item.startTimestamp ? item.startTimestamp * 1000 : item.start) | date: 'HH:mm' }}–{{ (item.stopTimestamp ? item.stopTimestamp * 1000 : item.stop) | date: 'HH:mm' }}
-@if (isLive) {
- live
-}
-
-@if (showArchiveBadge) {
-
- replay
- {{ 'XTREAM.PLAY' | translate }}
-
-}
-
-@if (item.desc) {
- info
-}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss
deleted file mode 100644
index 4b5787491..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss
+++ /dev/null
@@ -1,114 +0,0 @@
-:host {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- min-width: 0;
-}
-
-.time {
- font-size: 0.78rem;
- line-height: 1;
- font-weight: 600;
- letter-spacing: 0.02em;
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
- color: var(--app-muted-color);
-}
-
-.badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- min-width: 42px;
- padding: 2px 8px;
- border-radius: 999px;
- font-size: 0.68rem;
- line-height: 1.35;
- font-weight: 700;
- letter-spacing: 0.02em;
- border: 1px solid transparent;
- white-space: nowrap;
-}
-
-.live {
- color: var(--app-live-color);
- background: color-mix(in srgb, var(--app-live-color) 14%, transparent);
- border-color: color-mix(in srgb, var(--app-live-color) 32%, transparent);
- text-transform: uppercase;
- letter-spacing: 0.08em;
-}
-
-.archive-playback {
- color: color-mix(in srgb, var(--app-selection-color) 92%, white);
- background:
- linear-gradient(
- 135deg,
- color-mix(in srgb, var(--app-selection-color) 16%, transparent),
- color-mix(in srgb, var(--app-selection-color) 9%, transparent)
- ),
- color-mix(in srgb, var(--app-selection-surface) 42%, transparent);
- border-color: color-mix(
- in srgb,
- var(--app-selection-border) 78%,
- transparent
- );
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.08),
- 0 6px 14px -12px var(--app-selection-glow);
- padding-inline: 7px 10px;
-}
-
-.archive-playback--active {
- color: var(--app-selection-on-color);
- background: var(--app-selection-color);
- border-color: color-mix(
- in srgb,
- var(--app-selection-color) 88%,
- transparent
- );
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.18),
- 0 10px 18px -14px var(--app-selection-glow);
-}
-
-.archive-playback__icon {
- width: 14px;
- height: 14px;
- font-size: 14px;
- color: inherit;
- opacity: 0.92;
-}
-
-.archive-playback__label {
- font-size: 0.69rem;
- line-height: 1;
- font-weight: 700;
-}
-
-.description-icon {
- margin-inline-start: auto;
- width: 32px;
- height: 32px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border-radius: 10px;
- cursor: pointer;
- opacity: 0.7;
- transition:
- opacity 0.15s ease,
- background-color 0.15s ease;
-
- mat-icon {
- width: 18px;
- height: 18px;
- font-size: 18px;
- }
-
- &:hover {
- opacity: 1;
- background: color-mix(in srgb, currentColor 10%, transparent);
- }
-}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts
deleted file mode 100644
index f9c6da839..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
-import { MatDialog, MatDialogModule } from '@angular/material/dialog';
-import { MatIconModule } from '@angular/material/icon';
-import { MatListModule } from '@angular/material/list';
-import { MatTooltipModule } from '@angular/material/tooltip';
-import { TranslateModule, TranslateService } from '@ngx-translate/core';
-import { MockModule } from 'ng-mocks';
-import { EpgProgram } from '@iptvnator/shared/interfaces';
-import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
-import { EpgListItemComponent } from './epg-list-item.component';
-
-const EPG_PROGRAM_ITEM: EpgProgram = {
- start: '2026-04-05T11:30:00.000Z',
- stop: '2026-04-05T12:30:00.000Z',
- channel: '12345',
- title: 'NOW on PBS',
- desc: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
- category: 'Newsmagazine',
- episodeNum: '427',
- rating: 'TV-G',
- iconUrl:
- 'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg',
-};
-
-describe('EpgListItemComponent', () => {
- let component: EpgListItemComponent;
- let fixture: ComponentFixture;
- let dialog: MatDialog;
- let translate: TranslateService;
-
- beforeEach(waitForAsync(() => {
- TestBed.configureTestingModule({
- imports: [
- EpgListItemComponent,
- MockModule(MatDialogModule),
- MockModule(MatListModule),
- MockModule(MatIconModule),
- MockModule(MatTooltipModule),
- TranslateModule.forRoot(),
- ],
- }).compileComponents();
- }));
-
- beforeEach(() => {
- fixture = TestBed.createComponent(EpgListItemComponent);
- dialog = TestBed.inject(MatDialog);
- translate = TestBed.inject(TranslateService);
- translate.setTranslation(
- 'en',
- {
- EPG: {
- TIMESHIFT_AVAILABLE: 'Archive playback is available',
- },
- XTREAM: {
- PLAY: 'Play',
- },
- },
- true
- );
- translate.use('en');
- component = fixture.componentInstance;
- component.item = EPG_PROGRAM_ITEM;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-
- it('should trigger the function to open the details dialog', () => {
- jest.spyOn(dialog, 'open');
- component.showDescription({} as EpgProgram);
- expect(dialog.open).toHaveBeenCalledTimes(1);
- expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
- data: {},
- width: '800px',
- });
- });
-
- it('renders an archive playback chip with replay icon and label', () => {
- fixture = TestBed.createComponent(EpgListItemComponent);
- component = fixture.componentInstance;
- component.item = EPG_PROGRAM_ITEM;
- component.showArchiveBadge = true;
- fixture.detectChanges();
-
- const chip = fixture.nativeElement.querySelector('.archive-playback');
- const icon = fixture.nativeElement.querySelector(
- '.archive-playback__icon'
- );
-
- expect(chip).not.toBeNull();
- expect(chip.textContent).toContain('Play');
- expect(icon.textContent.trim()).toBe('replay');
- });
-
- it('marks the archive playback chip as active when the program is active', () => {
- fixture = TestBed.createComponent(EpgListItemComponent);
- component = fixture.componentInstance;
- component.item = EPG_PROGRAM_ITEM;
- component.showArchiveBadge = true;
- component.isActive = true;
- fixture.detectChanges();
-
- const chip = fixture.nativeElement.querySelector('.archive-playback');
-
- expect(chip.classList).toContain('archive-playback--active');
- });
-});
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts
deleted file mode 100644
index 2fe12b722..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { DatePipe } from '@angular/common';
-import { Component, inject, Input } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
-import { MatIcon } from '@angular/material/icon';
-import { MatTooltip } from '@angular/material/tooltip';
-import { TranslatePipe } from '@ngx-translate/core';
-import { EpgProgram } from '@iptvnator/shared/interfaces';
-import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
-
-@Component({
- imports: [DatePipe, MatIcon, MatTooltip, TranslatePipe],
- selector: 'app-epg-list-item',
- templateUrl: './epg-list-item.component.html',
- styleUrls: ['./epg-list-item.component.scss'],
-})
-export class EpgListItemComponent {
- private dialog = inject(MatDialog);
-
- /** EPG Program to render */
- @Input() item!: EpgProgram;
-
- /** Whether the program is currently live */
- @Input() isLive = false;
-
- /** Whether the program is the active playback target */
- @Input() isActive = false;
-
- /** Whether archive playback is available for the program */
- @Input() showArchiveBadge = false;
-
- /**
- * Opens the dialog with details about the selected program
- * @param program selected epg program
- */
- showDescription(program: EpgProgram): void {
- this.dialog.open(EpgItemDescriptionComponent, {
- width: '800px',
- data: program,
- });
- }
-}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html
deleted file mode 100644
index 6f56ef7eb..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html
+++ /dev/null
@@ -1,127 +0,0 @@
-@let items = filteredItems();
-@let timeshiftLimit = timeshiftUntil();
-
-@if (showDateNavigator()) {
-
-
-}
-
-@if (timeshiftLimit) {
-
-
-}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.scss b/libs/ui/epg/src/lib/epg-list/epg-list.component.scss
deleted file mode 100644
index 80928cf8c..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list.component.scss
+++ /dev/null
@@ -1,287 +0,0 @@
-:host {
- display: flex;
- flex-direction: column;
- width: 100%;
- height: 100%;
- min-height: 0;
- overflow-x: hidden;
- background: transparent;
-}
-
-#date-navigator {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px 10px 10px;
- gap: 8px;
-
- button {
- border-radius: 10px;
- opacity: 0.84;
- transition:
- opacity 0.15s ease,
- background-color 0.15s ease,
- transform 0.15s ease;
-
- &:hover:not([disabled]) {
- opacity: 1;
- background: color-mix(in srgb, currentColor 10%, transparent);
- transform: translateY(-1px);
- }
-
- &[disabled] {
- opacity: 0.3;
- }
- }
-}
-
-.selected-date {
- flex: 1;
- text-align: center;
- font-size: 0.83rem;
- line-height: 1.2;
- font-weight: 600;
- letter-spacing: 0.01em;
- opacity: 0.8;
-}
-
-#program-list {
- flex: 1;
- overflow-y: scroll;
- overflow-x: hidden;
- padding: 4px 8px 12px;
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-
-.program-item {
- position: relative;
- display: flex;
- flex-direction: column;
- gap: 5px;
- padding: 10px 12px;
- border: 1px solid transparent;
- border-radius: 12px;
- background: transparent;
- transition:
- background 0.15s ease,
- border-color 0.15s ease,
- box-shadow 0.18s ease,
- color 0.15s ease;
- cursor: default;
-
- & + .program-item {
- position: relative;
-
- &::before {
- content: '';
- position: absolute;
- top: 0;
- left: 12px;
- right: 12px;
- height: 1px;
- background: var(--app-separator);
- opacity: 0.5;
- }
- }
-
- &.current-program {
- background: color-mix(
- in srgb,
- var(--app-selection-color) 6%,
- transparent
- );
-
- &::before,
- & + .program-item::before {
- display: none;
- }
- }
-
- &.clickable {
- cursor: pointer;
-
- &:hover {
- background: color-mix(in srgb, currentColor 6%, transparent);
-
- &::before,
- & + .program-item::before {
- display: none;
- }
- }
-
- &.current-program:hover {
- background: color-mix(
- in srgb,
- var(--app-selection-color) 10%,
- transparent
- );
- }
-
- &:focus-visible {
- outline: 2px solid var(--app-selection-border);
- outline-offset: 2px;
- background: color-mix(
- in srgb,
- var(--app-selection-color) 8%,
- transparent
- );
-
- &::before,
- & + .program-item::before {
- display: none;
- }
- }
- }
-
- &.archive-program {
- &::after {
- content: '';
- position: absolute;
- inset-block: 12px;
- inset-inline-start: 0;
- width: 3px;
- border-radius: 999px;
- background: var(--app-selection-color);
- opacity: 0.48;
- transform: scaleY(0.68);
- transition:
- opacity 0.15s ease,
- transform 0.15s ease,
- box-shadow 0.15s ease;
- }
-
- &:hover::after,
- &:focus-visible::after,
- &.active::after {
- opacity: 1;
- transform: scaleY(1);
- box-shadow: 0 0 12px -4px var(--app-selection-glow);
- }
- }
-
- &.active {
- background: linear-gradient(
- 135deg,
- var(--app-selection-surface-strong),
- var(--app-selection-surface)
- );
- border-color: var(--app-selection-border);
- box-shadow:
- inset 0 0 0 1px var(--app-selection-border),
- 0 12px 20px -18px var(--app-selection-glow);
-
- .program-title {
- color: var(--app-selection-color);
- }
-
- &::before,
- & + .program-item::before {
- display: none;
- }
- }
-}
-
-.program-meta {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.program-title {
- font-size: 0.94rem;
- line-height: 1.3;
- font-weight: 600;
- letter-spacing: -0.01em;
- text-wrap: balance;
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
-}
-
-.epg-item__description {
- font-size: 0.82rem;
- line-height: 1.4;
- color: var(--app-body-color);
- text-wrap: pretty;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.program-progress {
- padding-top: 2px;
-}
-
-.epg-progress-track {
- position: relative;
- width: 100%;
- height: 6px;
- overflow: hidden;
- border-radius: 999px;
- background-color: rgba(127, 127, 127, 0.28);
- background-image: linear-gradient(
- 180deg,
- rgba(255, 255, 255, 0.12),
- rgba(255, 255, 255, 0)
- );
- box-shadow:
- inset 0 0 0 1px rgba(255, 255, 255, 0.12),
- inset 0 1px 1px rgba(0, 0, 0, 0.18);
-}
-
-.epg-progress-fill {
- position: absolute;
- inset-block: 0;
- inset-inline-start: 0;
- border-radius: inherit;
- background-color: var(--app-selection-color);
- background-image: linear-gradient(
- 90deg,
- rgba(255, 255, 255, 0.18),
- rgba(255, 255, 255, 0)
- );
- box-shadow:
- 0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
- 0 0 10px -2px var(--app-selection-glow);
- transition: width 0.35s ease;
-}
-
-.epg-empty-state {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 40px 24px;
- text-align: center;
- color: var(--app-muted-color);
-
- .empty-state-icon {
- width: 48px;
- height: 48px;
- font-size: 48px;
- opacity: 0.5;
- margin-bottom: 16px;
- }
-
- .empty-state-title {
- font-size: 1.05rem;
- font-weight: 520;
- letter-spacing: -0.01em;
- margin-bottom: 8px;
- text-wrap: balance;
- }
-
- .empty-state-hint {
- opacity: 0.65;
- font-size: 0.85rem;
- max-width: 240px;
- line-height: 1.4;
- text-wrap: pretty;
- }
-}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts
deleted file mode 100644
index 9db0c2721..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts
+++ /dev/null
@@ -1,508 +0,0 @@
-import { registerLocaleData } from '@angular/common';
-import { Component, input } from '@angular/core';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import localeDe from '@angular/common/locales/de';
-import { MockPipe } from 'ng-mocks';
-import { BehaviorSubject, of } from 'rxjs';
-import { EpgService } from '@iptvnator/epg/data-access';
-import { Store } from '@ngrx/store';
-import { TranslatePipe, TranslateService } from '@ngx-translate/core';
-import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
-import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
-import { EpgListComponent } from './epg-list.component';
-
-let requestAnimationFrameQueue: FrameRequestCallback[] = [];
-
-registerLocaleData(localeDe, 'de');
-
-@Component({
- selector: 'app-epg-list-item',
- standalone: true,
- template: '',
-})
-class StubEpgListItemComponent {
- readonly item = input();
- readonly isLive = input(false);
- readonly isActive = input(false);
- readonly showArchiveBadge = input(false);
-}
-
-describe('EpgListComponent', () => {
- let fixture: ComponentFixture;
- let component: EpgListComponent;
- let store: { select: jest.Mock; dispatch: jest.Mock };
- let epgService: { currentEpgPrograms$: BehaviorSubject };
- let translateService: {
- currentLang: string;
- defaultLang: string;
- onLangChange: BehaviorSubject;
- };
- let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
-
- const fixedNow = new Date('2026-04-05T12:00:00.000Z');
- const controlledChannel: Channel = {
- id: 'channel-101',
- name: 'Channel 101',
- url: 'https://example.com/live.m3u8',
- group: { title: 'News' },
- tvg: {
- id: 'channel-101',
- name: 'Channel 101',
- url: '',
- logo: 'channel-101.png',
- rec: '3',
- },
- http: { referrer: '', 'user-agent': '', origin: '' },
- radio: 'false',
- epgParams: '',
- };
-
- beforeEach(async () => {
- jest.useFakeTimers();
- jest.setSystemTime(fixedNow);
-
- originalRequestAnimationFrame = window.requestAnimationFrame;
- requestAnimationFrameQueue = [];
- window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
- requestAnimationFrameQueue.push(callback);
- return requestAnimationFrameQueue.length;
- }) as typeof window.requestAnimationFrame;
-
- store = {
- select: jest.fn().mockReturnValue(of(null)),
- dispatch: jest.fn(),
- };
- epgService = {
- currentEpgPrograms$: new BehaviorSubject([]),
- };
- translateService = {
- currentLang: 'en',
- defaultLang: 'en',
- onLangChange: new BehaviorSubject(null),
- };
-
- await TestBed.configureTestingModule({
- imports: [EpgListComponent, NoopAnimationsModule],
- providers: [
- { provide: Store, useValue: store },
- { provide: EpgService, useValue: epgService },
- { provide: TranslateService, useValue: translateService },
- ],
- })
- .overrideComponent(EpgListComponent, {
- remove: {
- imports: [EpgListItemComponent, TranslatePipe],
- },
- add: {
- imports: [
- StubEpgListItemComponent,
- MockPipe(
- TranslatePipe,
- (value: string | null | undefined) => value ?? ''
- ),
- ],
- },
- })
- .compileComponents();
-
- fixture = TestBed.createComponent(EpgListComponent);
- component = fixture.componentInstance;
- fixture.componentRef.setInput('controlledChannel', controlledChannel);
- fixture.componentRef.setInput('controlledArchiveDays', 3);
- });
-
- afterEach(() => {
- fixture.destroy();
- window.requestAnimationFrame = originalRequestAnimationFrame;
- jest.useRealTimers();
- });
-
- it('auto-scrolls once on the initial current-day load', () => {
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
-
- fixture.detectChanges();
- const scrollToSpy = configureScrollableProgramList(fixture);
-
- flushAnimationFrames();
-
- expect(scrollToSpy).toHaveBeenCalledTimes(1);
- expect(scrollToSpy).toHaveBeenCalledWith({ top: 172 });
- });
-
- it('does not re-trigger auto-scroll on timer-driven updates', () => {
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
-
- fixture.detectChanges();
- const scrollToSpy = configureScrollableProgramList(fixture);
- flushAnimationFrames();
-
- scrollToSpy.mockClear();
- jest.advanceTimersByTime(30_000);
- fixture.detectChanges();
- flushAnimationFrames();
-
- expect(scrollToSpy).not.toHaveBeenCalled();
- });
-
- it('keeps older same-day rows reachable after the initial scroll', () => {
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
-
- fixture.detectChanges();
- const scrollToSpy = configureScrollableProgramList(fixture);
- const programList = fixture.nativeElement.querySelector(
- '#program-list'
- ) as HTMLElement;
- flushAnimationFrames();
-
- scrollToSpy.mockClear();
- programList.scrollTop = 0;
- jest.advanceTimersByTime(30_000);
- fixture.detectChanges();
- flushAnimationFrames();
-
- expect(programList.scrollTop).toBe(0);
- expect(scrollToSpy).not.toHaveBeenCalled();
- });
-
- it('does not re-arm auto-scroll when changing dates for the same selection', () => {
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
-
- fixture.detectChanges();
- const scrollToSpy = configureScrollableProgramList(fixture);
- flushAnimationFrames();
-
- scrollToSpy.mockClear();
- component.changeDate('prev');
- fixture.detectChanges();
- component.changeDate('next');
- fixture.detectChanges();
- flushAnimationFrames();
-
- expect(scrollToSpy).not.toHaveBeenCalled();
- });
-
- it('re-arms auto-scroll when the effective selection changes', () => {
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
-
- fixture.detectChanges();
- let scrollToSpy = configureScrollableProgramList(fixture);
- flushAnimationFrames();
- scrollToSpy.mockClear();
-
- fixture.componentRef.setInput(
- 'controlledPrograms',
- buildPrograms({
- currentStart: '2026-04-05T11:45:00.000Z',
- currentStop: '2026-04-05T12:45:00.000Z',
- futureStart: '2026-04-05T12:45:00.000Z',
- futureStop: '2026-04-05T13:45:00.000Z',
- })
- );
- fixture.detectChanges();
- scrollToSpy = configureScrollableProgramList(fixture);
- flushAnimationFrames();
-
- expect(scrollToSpy).toHaveBeenCalledTimes(1);
- });
-
- it('shows archived rows as clickable only when archive playback is supported', () => {
- const programs = buildPrograms();
- fixture.componentRef.setInput('controlledPrograms', programs);
- fixture.componentRef.setInput('archivePlaybackAvailable', false);
-
- fixture.detectChanges();
-
- expect(component.canActivateProgram(programs[0])).toBe(false);
- expect(component.canActivateProgram(programs[1])).toBe(true);
- expect(
- fixture.nativeElement.querySelectorAll('.program-item.clickable')
- .length
- ).toBe(1);
-
- fixture.componentRef.setInput('archivePlaybackAvailable', true);
- fixture.detectChanges();
-
- expect(component.canActivateProgram(programs[0])).toBe(true);
- expect(
- fixture.nativeElement.querySelectorAll('.program-item.clickable')
- .length
- ).toBe(2);
- });
-
- it('highlights an externally active archived program without marking the live program active', () => {
- const programs = buildPrograms();
- fixture.componentRef.setInput('controlledPrograms', programs);
- fixture.componentRef.setInput('archivePlaybackAvailable', true);
- fixture.componentRef.setInput('activeProgram', programs[0]);
-
- fixture.detectChanges();
-
- const rows = fixture.nativeElement.querySelectorAll('.program-item');
-
- expect(rows[0].classList).toContain('active');
- expect(rows[1].classList).toContain('current-program');
- expect(rows[1].classList).not.toContain('active');
- });
-
- it('exposes playable rows as keyboard-activatable buttons', () => {
- const programs = buildPrograms();
- const emitted: unknown[] = [];
- component.programActivated.subscribe((event) => emitted.push(event));
- fixture.componentRef.setInput('controlledPrograms', programs);
- fixture.componentRef.setInput('archivePlaybackAvailable', true);
-
- fixture.detectChanges();
-
- const archivedRow = fixture.nativeElement.querySelector(
- '.program-item.clickable'
- ) as HTMLElement;
- archivedRow.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
- );
-
- expect(archivedRow.getAttribute('role')).toBe('button');
- expect(archivedRow.getAttribute('tabindex')).toBe('0');
- expect(emitted).toEqual([{ program: programs[0], type: 'timeshift' }]);
- });
-
- it('renders duplicate-start programs without duplicate track-key errors and keeps clicks bound to each row', () => {
- const programs = [
- buildProgram(
- 'duplicate-a',
- 'Duplicate Start A',
- '2026-04-05T09:00:00.000Z',
- '2026-04-05T09:30:00.000Z'
- ),
- buildProgram(
- 'duplicate-b',
- 'Duplicate Start B',
- '2026-04-05T09:00:00.000Z',
- '2026-04-05T10:00:00.000Z'
- ),
- buildProgram(
- 'current',
- 'Current Show',
- '2026-04-05T11:30:00.000Z',
- '2026-04-05T12:30:00.000Z'
- ),
- ];
- const emitted: unknown[] = [];
- const consoleErrorSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(() => undefined);
- component.programActivated.subscribe((event) => emitted.push(event));
- fixture.componentRef.setInput('controlledPrograms', programs);
- fixture.componentRef.setInput('archivePlaybackAvailable', true);
-
- try {
- expect(component.trackProgram(0, programs[0])).not.toBe(
- component.trackProgram(1, programs[1])
- );
-
- fixture.detectChanges();
-
- const duplicateTrackKeyError = consoleErrorSpy.mock.calls.some(
- (args) => args.some((arg) => String(arg).includes('NG0955'))
- );
- const rows =
- fixture.nativeElement.querySelectorAll(
- '.program-item.clickable'
- );
- rows[1].click();
-
- expect(duplicateTrackKeyError).toBe(false);
- expect(rows).toHaveLength(3);
- expect(emitted).toEqual([
- { program: programs[1], type: 'timeshift' },
- ]);
- } finally {
- consoleErrorSpy.mockRestore();
- }
- });
-
- it('shows one row per duplicated time slot and keeps the richer program entry', () => {
- const sparseProgram = buildProgram(
- 'duplicate-a',
- 'World Cup Breakfast',
- '2026-04-05T09:00:00.000Z',
- '2026-04-05T10:00:00.000Z',
- {
- channel: 'channel-101',
- desc: null,
- }
- );
- const richProgram = buildProgram(
- 'duplicate-b',
- 'World Cup Breakfast',
- '2026-04-05T09:00:00.000Z',
- '2026-04-05T10:00:00.000Z',
- {
- channel: 'channel-101',
- desc: 'A preview of the day ahead.',
- category: 'Sports',
- }
- );
- const nextProgram = buildProgram(
- 'next',
- 'World Cup Matchday',
- '2026-04-05T10:00:00.000Z',
- '2026-04-05T11:00:00.000Z',
- {
- channel: 'channel-101',
- }
- );
- const emitted: unknown[] = [];
- component.programActivated.subscribe((event) => emitted.push(event));
- fixture.componentRef.setInput('controlledPrograms', [
- sparseProgram,
- richProgram,
- nextProgram,
- ]);
- fixture.componentRef.setInput('archivePlaybackAvailable', true);
-
- fixture.detectChanges();
-
- const rows =
- fixture.nativeElement.querySelectorAll(
- '.program-item'
- );
- rows[0].click();
-
- expect(component.filteredItems()).toEqual([
- richProgram,
- nextProgram,
- ]);
- expect(rows).toHaveLength(2);
- expect(emitted).toEqual([{ program: richProgram, type: 'timeshift' }]);
- });
-
- it('updates the selected-day header when the app language changes', () => {
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
- fixture.detectChanges();
-
- translateService.currentLang = 'de';
- translateService.onLangChange.next({ lang: 'de' });
- fixture.detectChanges();
-
- expect(
- fixture.nativeElement
- .querySelector('.selected-date')
- .textContent.trim()
- ).toContain('Sonntag');
- });
-
- it('can hide its internal date navigator for a parent-owned toolbar', () => {
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
- fixture.componentRef.setInput('showDateNavigator', false);
- fixture.detectChanges();
-
- expect(
- fixture.nativeElement.querySelector('#channel-header')
- ).toBeNull();
- expect(
- fixture.nativeElement.querySelector('#program-list')
- ).not.toBeNull();
- });
-
- it('emits date changes when the selected date is controlled', () => {
- const emittedDates: string[] = [];
- component.selectedDateChange.subscribe((date) =>
- emittedDates.push(date)
- );
- fixture.componentRef.setInput('selectedDate', '2026-04-04');
- fixture.componentRef.setInput('controlledPrograms', buildPrograms());
- fixture.detectChanges();
-
- component.changeDate('next');
-
- expect(emittedDates).toEqual(['2026-04-05']);
- expect(component.selectedDateKey()).toBe('2026-04-04');
- });
-});
-
-function buildPrograms(overrides?: {
- currentStart?: string;
- currentStop?: string;
- futureStart?: string;
- futureStop?: string;
-}): EpgProgram[] {
- const pastStart = '2026-04-05T09:00:00.000Z';
- const pastStop = '2026-04-05T10:00:00.000Z';
- const currentStart = overrides?.currentStart ?? '2026-04-05T11:30:00.000Z';
- const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z';
- const futureStart = overrides?.futureStart ?? '2026-04-05T12:30:00.000Z';
- const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z';
-
- return [
- buildProgram('past', 'Past Show', pastStart, pastStop),
- buildProgram('current', 'Current Show', currentStart, currentStop),
- buildProgram('future', 'Future Show', futureStart, futureStop),
- ];
-}
-
-function buildProgram(
- channelSuffix: string,
- title: string,
- start: string,
- stop: string,
- overrides: Partial = {}
-): EpgProgram {
- return {
- start,
- stop,
- channel: `channel-${channelSuffix}`,
- title,
- desc: null,
- category: null,
- startTimestamp: Math.floor(Date.parse(start) / 1000),
- stopTimestamp: Math.floor(Date.parse(stop) / 1000),
- ...overrides,
- };
-}
-
-function configureScrollableProgramList(
- fixture: ComponentFixture
-): jest.Mock {
- const programList = fixture.nativeElement.querySelector(
- '#program-list'
- ) as HTMLElement;
- const currentProgram = fixture.nativeElement.querySelector(
- '.program-item.current-program'
- ) as HTMLElement;
-
- let scrollTop = 0;
- Object.defineProperty(programList, 'scrollTop', {
- configurable: true,
- get: () => scrollTop,
- set: (value: number) => {
- scrollTop = value;
- },
- });
- Object.defineProperty(programList, 'clientHeight', {
- configurable: true,
- value: 120,
- });
- Object.defineProperty(currentProgram, 'offsetTop', {
- configurable: true,
- value: 240,
- });
- Object.defineProperty(currentProgram, 'offsetHeight', {
- configurable: true,
- value: 36,
- });
-
- const scrollToSpy = jest.fn(({ top }: { top: number }) => {
- scrollTop = top;
- });
- programList.scrollTo = scrollToSpy as typeof programList.scrollTo;
-
- return scrollToSpy;
-}
-
-function flushAnimationFrames(): void {
- while (requestAnimationFrameQueue.length > 0) {
- const callback = requestAnimationFrameQueue.shift();
- callback?.(0);
- }
-}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts
deleted file mode 100644
index 739093f0f..000000000
--- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts
+++ /dev/null
@@ -1,383 +0,0 @@
-import { DatePipe } from '@angular/common';
-import {
- ChangeDetectionStrategy,
- Component,
- ElementRef,
- computed,
- effect,
- inject,
- input,
- output,
- signal,
- viewChild,
-} from '@angular/core';
-import { toSignal } from '@angular/core/rxjs-interop';
-import { MatIconButton } from '@angular/material/button';
-import { MatIcon } from '@angular/material/icon';
-import { MatTooltip } from '@angular/material/tooltip';
-import { EpgService } from '@iptvnator/epg/data-access';
-import { normalizeDateLocale } from '@iptvnator/pipes';
-import { Store } from '@ngrx/store';
-import { TranslatePipe, TranslateService } from '@ngx-translate/core';
-import { EpgActions, selectActive } from '@iptvnator/m3u-state';
-import { subDays } from 'date-fns';
-import { startWith } from 'rxjs';
-import { Channel, EpgChannel, EpgProgram } from '@iptvnator/shared/interfaces';
-import {
- EpgDateNavigationDirection,
- getTodayEpgDateKey,
- shiftEpgDateKey,
-} from '../epg-date';
-import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
-import {
- areProgramsSame,
- buildScrollContextKey,
- deduplicateProgramsByTimeSlot,
- getProgramDateKey,
- getProgramTimeMs,
- trackProgram,
-} from './epg-list.utils';
-
-export interface EpgProgramActivationEvent {
- program: EpgProgram;
- type: 'live' | 'timeshift';
-}
-
-@Component({
- imports: [
- DatePipe,
- EpgListItemComponent,
- MatIcon,
- MatIconButton,
- MatTooltip,
- TranslatePipe,
- ],
- selector: 'app-epg-list',
- templateUrl: './epg-list.component.html',
- styleUrls: ['./epg-list.component.scss'],
- changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class EpgListComponent {
- readonly controlledChannel = input(null);
- readonly controlledPrograms = input(null);
- readonly controlledArchiveDays = input(null);
- readonly archivePlaybackAvailable = input(null);
- readonly activeProgram = input(null);
- readonly selectedDateInput = input(null, {
- alias: 'selectedDate',
- });
- readonly showDateNavigator = input(true);
- readonly programActivated = output();
- readonly selectedDateChange = output();
- readonly trackProgram = trackProgram;
-
- private readonly store = inject(Store);
- private readonly epgService = inject(EpgService);
- private readonly translate = inject(TranslateService);
-
- private readonly activeChannel = toSignal(this.store.select(selectActive), {
- initialValue: null,
- });
- private readonly servicePrograms = toSignal(
- this.epgService.currentEpgPrograms$,
- { initialValue: [] as EpgProgram[] }
- );
- private readonly languageTick = toSignal(
- this.translate.onLangChange.pipe(startWith(null)),
- { initialValue: null }
- );
-
- readonly programList = viewChild>('programList');
- readonly internalSelectedDate = signal(getTodayEpgDateKey());
- readonly selectedDateKey = computed(
- () => this.selectedDateInput() ?? this.internalSelectedDate()
- );
- readonly timeNow = signal(new Date().toISOString());
- readonly currentTimeMs = computed(() => Date.parse(this.timeNow()));
- readonly currentLocale = computed(() => {
- this.languageTick();
- return normalizeDateLocale(
- this.translate.currentLang || this.translate.defaultLang
- );
- });
-
- readonly isControlled = computed(
- () =>
- this.controlledChannel() !== null ||
- this.controlledPrograms() !== null
- );
- readonly displayChannel = computed(
- () => this.controlledChannel() ?? this.activeChannel() ?? null
- );
- readonly channel = computed(() => {
- const channel = this.displayChannel();
- if (!channel) {
- return null;
- }
-
- return {
- id: channel.tvg?.id || '',
- displayName: channel.name
- ? [{ lang: '', value: channel.name }]
- : [],
- url: channel.url ? [channel.url] : [],
- icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [],
- };
- });
- readonly items = computed(
- () => this.controlledPrograms() ?? this.servicePrograms() ?? []
- );
- readonly archiveDays = computed(() => {
- const controlledArchiveDays = this.controlledArchiveDays();
- if (controlledArchiveDays !== null) {
- return Math.max(0, controlledArchiveDays);
- }
-
- const channel = this.displayChannel();
- const value =
- channel?.tvg?.rec || channel?.timeshift || channel?.catchup?.days;
- return Math.max(0, Number(value ?? 0) || 0);
- });
- readonly timeshiftUntil = computed(() =>
- subDays(new Date(), this.archiveDays()).toISOString()
- );
- readonly archivePlaybackEnabled = computed(
- () => this.archivePlaybackAvailable() ?? this.archiveDays() > 0
- );
- readonly filteredItems = computed(() =>
- deduplicateProgramsByTimeSlot(
- [...this.items()]
- .filter(
- (item) =>
- getProgramDateKey(item.start, item.startTimestamp) ===
- this.selectedDateKey()
- )
- .sort(
- (left, right) =>
- getProgramTimeMs(left.start, left.startTimestamp) -
- getProgramTimeMs(right.start, right.startTimestamp)
- )
- )
- );
-
- private scrollScheduled = false;
- private activeScrollContextKey: string | null = null;
- private lastAutoScrolledContextKey: string | null = null;
-
- constructor() {
- effect((onCleanup) => {
- const intervalId = window.setInterval(() => {
- this.timeNow.set(new Date().toISOString());
- }, 30_000);
-
- onCleanup(() => clearInterval(intervalId));
- });
-
- effect(() => {
- const programs = this.items();
- const channel = this.displayChannel();
- this.currentTimeMs();
-
- if (!this.isControlled()) {
- this.store.dispatch(
- EpgActions.setEpgAvailableFlag({
- value: programs.length > 0,
- })
- );
-
- if (programs.length > 0) {
- const currentProgram = this.findCurrentProgram(programs);
- if (currentProgram) {
- this.store.dispatch(
- EpgActions.setCurrentEpgProgram({
- program: currentProgram,
- })
- );
- } else {
- this.store.dispatch(EpgActions.resetActiveEpgProgram());
- }
- } else {
- this.store.dispatch(EpgActions.resetActiveEpgProgram());
- }
- }
-
- if (!channel && !this.isControlled()) {
- this.store.dispatch(EpgActions.resetActiveEpgProgram());
- }
-
- this.updateScrollContext(channel, programs);
- this.scheduleScrollToCurrentProgram();
- });
- }
-
- changeDate(direction: EpgDateNavigationDirection): void {
- const nextDate = shiftEpgDateKey(this.selectedDateKey(), direction);
-
- if (this.selectedDateInput() == null) {
- this.internalSelectedDate.set(nextDate);
- }
-
- this.selectedDateChange.emit(nextDate);
- }
-
- activateProgram(program: EpgProgram): void {
- const isLive = this.isProgramPlaying(program);
- const isTimeshift = this.canPlayArchivedProgram(program);
-
- if (!isLive && !isTimeshift) {
- return;
- }
-
- if (this.isControlled()) {
- this.programActivated.emit({
- program,
- type: isLive ? 'live' : 'timeshift',
- });
- this.timeNow.set(new Date().toISOString());
- return;
- }
-
- if (isLive) {
- this.store.dispatch(EpgActions.returnToLivePlayback());
- } else {
- this.store.dispatch(EpgActions.setActiveEpgProgram({ program }));
- }
-
- this.timeNow.set(new Date().toISOString());
- }
-
- activateProgramFromKeyboard(event: Event, program: EpgProgram): void {
- event.preventDefault();
- this.activateProgram(program);
- }
-
- canActivateProgram(program: EpgProgram): boolean {
- return (
- this.isProgramPlaying(program) ||
- this.canPlayArchivedProgram(program)
- );
- }
-
- canPlayArchivedProgram(program: EpgProgram): boolean {
- return this.archivePlaybackEnabled() && this.isProgramArchived(program);
- }
-
- calculateProgress(program: EpgProgram): number {
- const now = this.currentTimeMs();
- const start = getProgramTimeMs(program.start, program.startTimestamp);
- const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
- const total = stop - start;
- const elapsed = now - start;
-
- return total > 0
- ? Math.min(100, Math.max(0, (elapsed / total) * 100))
- : 0;
- }
-
- isProgramPlaying(program: EpgProgram): boolean {
- const now = this.currentTimeMs();
- const start = getProgramTimeMs(program.start, program.startTimestamp);
- const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
- return now >= start && now <= stop;
- }
-
- isProgramArchived(program: EpgProgram): boolean {
- if (this.archiveDays() <= 0) {
- return false;
- }
-
- const now = this.currentTimeMs();
- const start = getProgramTimeMs(program.start, program.startTimestamp);
- const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
- const archiveLimit = Date.parse(this.timeshiftUntil());
-
- return stop < now && start >= archiveLimit;
- }
-
- isProgramActive(program: EpgProgram): boolean {
- const activeProgram = this.activeProgram();
- return activeProgram
- ? areProgramsSame(program, activeProgram)
- : this.isProgramPlaying(program);
- }
-
- private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined {
- const now = this.currentTimeMs();
- return programs.find((program) => {
- const start = getProgramTimeMs(
- program.start,
- program.startTimestamp
- );
- const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
- return now >= start && now <= stop;
- });
- }
-
- private updateScrollContext(
- channel: Channel | null,
- programs: EpgProgram[]
- ): void {
- const nextContextKey = buildScrollContextKey(channel, programs);
- if (nextContextKey === this.activeScrollContextKey) {
- return;
- }
-
- this.activeScrollContextKey = nextContextKey;
- this.scrollScheduled = false;
- }
-
- private scheduleScrollToCurrentProgram(): void {
- if (this.selectedDateKey() !== getTodayEpgDateKey()) {
- return;
- }
-
- const scrollContextKey = this.activeScrollContextKey;
- if (
- !scrollContextKey ||
- this.lastAutoScrolledContextKey === scrollContextKey
- ) {
- return;
- }
-
- if (this.scrollScheduled) {
- return;
- }
-
- this.scrollScheduled = true;
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- this.scrollScheduled = false;
- this.scrollCurrentProgramIntoView(scrollContextKey);
- });
- });
- }
-
- private scrollCurrentProgramIntoView(scrollContextKey: string): void {
- const container = this.programList()?.nativeElement;
- const currentProgram = container?.querySelector(
- '.program-item.current-program'
- );
-
- if (!container || !currentProgram) {
- return;
- }
-
- this.lastAutoScrolledContextKey = scrollContextKey;
-
- const viewTop = container.scrollTop;
- const viewBottom = viewTop + container.clientHeight;
- const elementTop = currentProgram.offsetTop;
- const elementBottom = elementTop + currentProgram.offsetHeight;
-
- if (elementTop < viewTop) {
- container.scrollTo({ top: Math.max(elementTop - 16, 0) });
- return;
- }
-
- if (elementBottom > viewBottom) {
- container.scrollTo({
- top: elementBottom - container.clientHeight + 16,
- });
- }
- }
-}
diff --git a/libs/ui/epg/src/lib/epg-program-activation-event.ts b/libs/ui/epg/src/lib/epg-program-activation-event.ts
new file mode 100644
index 000000000..9eef35b22
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-program-activation-event.ts
@@ -0,0 +1,6 @@
+import type { EpgProgram } from '@iptvnator/shared/interfaces';
+
+export interface EpgProgramActivationEvent {
+ program: EpgProgram;
+ type: 'live' | 'timeshift';
+}
diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts b/libs/ui/epg/src/lib/epg-program.utils.ts
similarity index 69%
rename from libs/ui/epg/src/lib/epg-list/epg-list.utils.ts
rename to libs/ui/epg/src/lib/epg-program.utils.ts
index 463181137..b7fa29caa 100644
--- a/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts
+++ b/libs/ui/epg/src/lib/epg-program.utils.ts
@@ -1,59 +1,6 @@
import { format } from 'date-fns';
-import type { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
-import { EPG_DATE_KEY_FORMAT } from '../epg-date';
-
-export function trackProgram(index: number, program: EpgProgram): string {
- const start = getProgramTimeMs(program.start, program.startTimestamp);
- const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
-
- return [
- program.channel ?? '',
- Number.isFinite(start) ? start : program.start,
- Number.isFinite(stop) ? stop : program.stop,
- program.title ?? '',
- index,
- ].join('|');
-}
-
-export function deduplicateProgramsByTimeSlot(
- programs: EpgProgram[]
-): EpgProgram[] {
- const programsByTimeSlot = new Map();
-
- for (const program of programs) {
- const timeSlotKey = buildProgramTimeSlotKey(program);
- const existingProgram = programsByTimeSlot.get(timeSlotKey);
-
- programsByTimeSlot.set(
- timeSlotKey,
- existingProgram
- ? selectMoreInformativeProgram(existingProgram, program)
- : program
- );
- }
-
- return Array.from(programsByTimeSlot.values());
-}
-
-export function buildScrollContextKey(
- channel: Channel | null,
- programs: EpgProgram[]
-): string | null {
- if (!channel && programs.length === 0) {
- return null;
- }
-
- const channelKey =
- channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel';
- const programKey = programs
- .map(
- (program) =>
- `${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}`
- )
- .join('|');
-
- return `${channelKey}:${programKey}`;
-}
+import type { EpgProgram } from '@iptvnator/shared/interfaces';
+import { EPG_DATE_KEY_FORMAT } from './epg-date';
export function getProgramTimeMs(
isoValue: string,
@@ -79,6 +26,26 @@ export function getProgramDateKey(
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
}
+export function deduplicateProgramsByTimeSlot(
+ programs: EpgProgram[]
+): EpgProgram[] {
+ const programsByTimeSlot = new Map();
+
+ for (const program of programs) {
+ const timeSlotKey = buildProgramTimeSlotKey(program);
+ const existingProgram = programsByTimeSlot.get(timeSlotKey);
+
+ programsByTimeSlot.set(
+ timeSlotKey,
+ existingProgram
+ ? selectMoreInformativeProgram(existingProgram, program)
+ : program
+ );
+ }
+
+ return Array.from(programsByTimeSlot.values());
+}
+
export function areProgramsSame(
left: EpgProgram,
right: EpgProgram
diff --git a/libs/ui/epg/src/lib/epg-programme-dialog.service.ts b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts
index 14deeda08..8c396de5b 100644
--- a/libs/ui/epg/src/lib/epg-programme-dialog.service.ts
+++ b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts
@@ -5,7 +5,7 @@ import {
EpgItemDescriptionComponent,
EpgItemDialogAction,
EpgItemDialogData,
-} from './epg-list/epg-item-description/epg-item-description.component';
+} from './epg-item-description/epg-item-description.component';
/**
* Opens the shared programme-details dialog and returns the chosen action.
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts
index 17f0ce75b..a529ce6d2 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts
@@ -1,4 +1,4 @@
-import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component';
+import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
import { TimelineWhen } from './epg-timeline.utils';
const DAY_MS = 24 * 60 * 60_000;
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts
index ef5bd719b..efd599e9b 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts
@@ -11,7 +11,7 @@ import {
import { MatIcon } from '@angular/material/icon';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslatePipe } from '@ngx-translate/core';
-import { areProgramsSame } from '../epg-list/epg-list.utils';
+import { areProgramsSame } from '../epg-program.utils';
import {
TimelineBlock,
TimelineDayDivider,
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts
index 6f70851ae..9ecc95204 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts
@@ -26,8 +26,8 @@ import {
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 { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
+import type { EpgProgramActivationEvent } from '../epg-program-activation-event';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import {
EpgTimelineSummary,
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts
index 0b7411e36..e5565e8cf 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts
@@ -1,7 +1,7 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { addDays, addMinutes, startOfDay } from 'date-fns';
import { parseEpgDateKey } from '../epg-date';
-import { getProgramDateKey, getProgramTimeMs } from '../epg-list/epg-list.utils';
+import { getProgramDateKey, getProgramTimeMs } from '../epg-program.utils';
export const TIMELINE_MINUTE_MS = 60_000;
/** Pixels per minute on the ribbon (matches the prototype's SCALE). */
diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts
index 5c363c18f..796fde481 100644
--- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts
+++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts
@@ -30,7 +30,7 @@ import {
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
-import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
+import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
type MultiEpgChannel = ElectronBridgeEpgChannelWithPrograms;
diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts
index 42d2800c3..f3a4b4e7b 100644
--- a/libs/ui/playback/src/index.ts
+++ b/libs/ui/playback/src/index.ts
@@ -10,6 +10,5 @@ export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/portal-inline-player/series-playback-navigation';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/video-player/sidebar/sidebar.component';
-export * from './lib/video-player/toolbar/toolbar.component';
export * from './lib/vod-details/vod-details.component';
export * from './lib/web-player-view/web-player-view.component';
diff --git a/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.html b/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.html
deleted file mode 100644
index 16009270a..000000000
--- a/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.html
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
- {{ activeChannel.name }}
-
-
-
- @let isEpgAvailable = isEpgAvailable$ | async;
- @if (isEpgAvailable) {
-
- }
-
diff --git a/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.scss b/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.scss
deleted file mode 100644
index 58fec352e..000000000
--- a/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.scss
+++ /dev/null
@@ -1,26 +0,0 @@
-mat-toolbar {
- app-region: drag;
-
- button {
- app-region: no-drag;
- }
-}
-
-.spacer {
- flex: 1 1 auto;
-}
-
-.mat-toolbar.mat-primary {
- background: var(--mdc-primary-100);
- color: #ccc;
-
- button {
- color: #ccc;
- }
-}
-
-@media only screen and (max-width: 599px) {
- .sidenav-toggle-button {
- display: none;
- }
-}
diff --git a/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.ts b/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.ts
deleted file mode 100644
index 4614afd37..000000000
--- a/libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { AsyncPipe } from '@angular/common';
-import { Component, inject, Input, output } from '@angular/core';
-import { MatButton, MatIconButton } from '@angular/material/button';
-import { MatDialogModule } from '@angular/material/dialog';
-import { MatIcon } from '@angular/material/icon';
-import { MatToolbarModule } from '@angular/material/toolbar';
-import { MatTooltip } from '@angular/material/tooltip';
-import { Store } from '@ngrx/store';
-import { TranslatePipe } from '@ngx-translate/core';
-import {
- FavoritesActions,
- selectFavorites,
- selectIsEpgAvailable,
-} from '@iptvnator/m3u-state';
-import { Channel } from '@iptvnator/shared/interfaces';
-
-@Component({
- imports: [
- AsyncPipe,
- MatButton,
- MatDialogModule,
- MatIcon,
- MatIconButton,
- MatToolbarModule,
- MatTooltip,
- TranslatePipe,
- ],
- selector: 'app-toolbar',
- templateUrl: './toolbar.component.html',
- styleUrls: ['./toolbar.component.scss'],
-})
-export class ToolbarComponent {
- @Input() activeChannel!: Channel;
- @Input() isLeftDrawerOpened = true;
- readonly infoOverlayClicked = output();
- readonly multiEpgClicked = output();
- readonly settingsClicked = output();
- readonly toggleLeftDrawerClicked = output();
- readonly toggleRightDrawerClicked = output();
-
- private readonly store = inject(Store);
-
- readonly favorites$ = this.store.select(selectFavorites);
- readonly isEpgAvailable$ = this.store.select(selectIsEpgAvailable);
-
- updateFavoriteStatus(channel: Channel) {
- this.store.dispatch(FavoritesActions.updateFavorites({ channel }));
- }
-}
diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts
index 79c057901..27ccf4f0c 100644
--- a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts
+++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts
@@ -1,4 +1,4 @@
-import { Injectable } from '@angular/core';
+import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@@ -25,8 +25,7 @@ export interface RemoteControlStatus {
})
export class RemoteControlService {
private readonly apiBaseUrl = '/api/remote-control';
-
- constructor(private http: HttpClient) {}
+ private readonly http = inject(HttpClient);
/**
* Changes to the next channel
diff --git a/libs/ui/shared-portals/src/index.ts b/libs/ui/shared-portals/src/index.ts
index a59872e5a..9b08824e3 100644
--- a/libs/ui/shared-portals/src/index.ts
+++ b/libs/ui/shared-portals/src/index.ts
@@ -1,2 +1 @@
-export * from './lib/epg-view/epg-view.component';
-export * from './lib/live-epg-panel/live-epg-panel.component';
+export * from './lib/live-epg-panel-summary';
diff --git a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html
deleted file mode 100644
index bac4881c4..000000000
--- a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html
+++ /dev/null
@@ -1,52 +0,0 @@
-{{ 'EPG.TITLE' | translate }}
-
-
- @for (item of epgItems; track $index) {
-
-
-
- {{ item.title }}
-
- @if (item.description) {
-
- {{ item.description }}
-
- }
-
- @if (isCurrentProgram(item)) {
-
- }
-
- }
-
- @if (epgItems.length === 0) {
-
- {{ 'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate }}
-
- }
-
diff --git a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss
deleted file mode 100644
index df8a8aa08..000000000
--- a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss
+++ /dev/null
@@ -1,188 +0,0 @@
-:host {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- overflow: hidden;
-}
-
-.epg-title {
- margin: 0;
- padding: 14px 16px 10px;
- font-size: 1.02rem;
- font-weight: 620;
- letter-spacing: 0.01em;
- flex-shrink: 0;
- position: sticky;
- top: 0;
- z-index: 2;
-}
-
-.epg-list {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 10px 10px 22px;
- box-sizing: border-box;
- scrollbar-gutter: stable;
-}
-
-.epg-item {
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 10px 14px 16px;
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 14px;
- background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)),
- rgba(127, 127, 127, 0.045);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.04),
- 0 10px 20px -20px rgba(0, 0, 0, 0.35);
- transition:
- background 0.15s ease,
- border-color 0.15s ease,
- box-shadow 0.18s ease,
- transform 0.18s ease;
-
- &:hover {
- background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0)),
- rgba(127, 127, 127, 0.065);
- transform: translateY(-1px);
- }
-
- &.current-program {
- background: linear-gradient(
- 135deg,
- var(--app-selection-surface-strong),
- var(--app-selection-surface)
- );
- border-color: var(--app-selection-border);
- box-shadow:
- inset 0 0 0 1px var(--app-selection-border),
- 0 12px 20px -18px var(--app-selection-glow);
-
- .epg-item__title,
- .epg-item__time,
- .epg-item__description,
- .epg-item__info {
- color: var(--app-selection-color);
- }
- }
-}
-
-.epg-item__meta {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.epg-item__time {
- font-size: 0.78rem;
- line-height: 1;
- font-weight: 600;
- letter-spacing: 0.02em;
- font-variant-numeric: tabular-nums;
- opacity: 0.76;
-}
-
-.epg-item__badge {
- padding: 2px 8px;
- border-radius: 999px;
- font-size: 0.68rem;
- line-height: 1.4;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- background: color-mix(in srgb, var(--app-selection-color) 18%, transparent);
- color: var(--app-selection-color);
- border: 1px solid color-mix(in srgb, var(--app-selection-border) 86%, transparent);
-}
-
-.epg-item__info {
- margin-inline-start: auto;
- width: 34px;
- height: 34px;
- border-radius: 10px;
- color: inherit;
- opacity: 0.76;
- transition:
- opacity 0.15s ease,
- background-color 0.15s ease,
- transform 0.15s ease;
-
- &:hover {
- opacity: 1;
- background: color-mix(in srgb, currentColor 10%, transparent);
- transform: translateY(-1px);
- }
-}
-
-.epg-item__title {
- font-size: 0.94rem;
- line-height: 1.35;
- font-weight: 560;
- letter-spacing: 0.005em;
-}
-
-.epg-item__description {
- font-size: 0.82rem;
- line-height: 1.4;
- opacity: 0.74;
- display: -webkit-box;
- overflow: hidden;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
-}
-
-.epg-item__progress {
- padding-top: 4px;
-}
-
-.epg-progress-track {
- position: relative;
- width: 100%;
- height: 6px;
- overflow: hidden;
- border-radius: 999px;
- background-color: rgba(127, 127, 127, 0.28);
- background-image: linear-gradient(
- 180deg,
- rgba(255, 255, 255, 0.12),
- rgba(255, 255, 255, 0)
- );
- box-shadow:
- inset 0 0 0 1px rgba(255, 255, 255, 0.12),
- inset 0 1px 1px rgba(0, 0, 0, 0.18);
-}
-
-.epg-progress-fill {
- position: absolute;
- inset-block: 0;
- inset-inline-start: 0;
- border-radius: inherit;
- background-color: var(--app-selection-color);
- background-image: linear-gradient(
- 90deg,
- rgba(255, 255, 255, 0.18),
- rgba(255, 255, 255, 0)
- );
- box-shadow:
- 0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
- 0 0 10px -2px var(--app-selection-glow);
- transition: width 0.35s ease;
-}
-
-.epg-empty {
- padding: 22px;
- border: 1px dashed rgba(255, 255, 255, 0.12);
- border-radius: 14px;
- text-align: center;
- font-size: 0.88rem;
- opacity: 0.66;
-}
diff --git a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.spec.ts b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.spec.ts
deleted file mode 100644
index 86ee7eec1..000000000
--- a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.spec.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { MatDialog } from '@angular/material/dialog';
-import { TranslateModule } from '@ngx-translate/core';
-import { EpgItem } from '@iptvnator/shared/interfaces';
-import { EpgViewComponent } from './epg-view.component';
-
-describe('EpgViewComponent', () => {
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [EpgViewComponent, TranslateModule.forRoot()],
- providers: [
- {
- provide: MatDialog,
- useValue: {
- open: jest.fn(),
- },
- },
- ],
- }).compileComponents();
-
- fixture = TestBed.createComponent(EpgViewComponent);
- });
-
- afterEach(() => {
- fixture.destroy();
- });
-
- it('renders the end timestamp when stop is absent at runtime', () => {
- fixture.componentInstance.epgItems = [
- {
- id: 'epg-1',
- epg_id: 'channel-1',
- title: 'Fallback program',
- lang: 'en',
- start: '2026-04-05T11:00:00',
- end: '2026-04-05T12:30:00',
- description: 'Program description',
- channel_id: 'channel-1',
- start_timestamp: '1775386800',
- stop_timestamp: '1775392200',
- } as EpgItem,
- ];
-
- fixture.detectChanges();
-
- expect(fixture.nativeElement.textContent).toContain('11:00 - 12:30');
- });
-});
diff --git a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.ts b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.ts
deleted file mode 100644
index fe260272f..000000000
--- a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { DatePipe } from '@angular/common';
-import { Component, Input, inject } from '@angular/core';
-import { MatIconButton } from '@angular/material/button';
-import { MatDialog } from '@angular/material/dialog';
-import { MatIcon } from '@angular/material/icon';
-import { MatListModule } from '@angular/material/list';
-import { TranslateModule } from '@ngx-translate/core';
-import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
-import { EpgItem } from '@iptvnator/shared/interfaces';
-
-@Component({
- selector: 'app-epg-view',
- templateUrl: './epg-view.component.html',
- imports: [DatePipe, MatIconButton, MatIcon, MatListModule, TranslateModule],
- styleUrls: ['./epg-view.component.scss'],
-})
-export class EpgViewComponent {
- @Input() epgItems: EpgItem[] = [];
-
- dialog = inject(MatDialog);
-
- isCurrentProgram(item: EpgItem): boolean {
- const end = this.getProgramEnd(item);
- const now = new Date().getTime();
- const start = new Date(item.start).getTime();
- const stop = new Date(end).getTime();
- return now >= start && now <= stop;
- }
-
- getProgress(item: EpgItem): number {
- const now = new Date().getTime();
- const start = new Date(item.start).getTime();
- const end = new Date(this.getProgramEnd(item)).getTime();
-
- const total = end - start;
- const current = now - start;
-
- return Math.min(Math.max((current / total) * 100, 0), 100);
- }
-
- getProgramEnd(item: EpgItem): string {
- return item.stop ?? item.end;
- }
-
- showDetails(item: EpgItem) {
- this.dialog.open(EpgItemDescriptionComponent, {
- width: '600px',
- data: {
- title: item.title ?? 'No title',
- desc: item.description ?? 'No description',
- },
- });
- }
-}
diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts b/libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts
new file mode 100644
index 000000000..c5b4e613e
--- /dev/null
+++ b/libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts
@@ -0,0 +1,6 @@
+export interface LiveEpgPanelSummary {
+ readonly title?: string | null;
+ readonly start?: string | number | Date | null;
+ readonly stop?: string | number | Date | null;
+ readonly progress?: number | null;
+}
diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html
deleted file mode 100644
index 768fe12b4..000000000
--- a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html
+++ /dev/null
@@ -1,169 +0,0 @@
-
-
-
-
-
-
- {{ summaryLabelKey() | translate }}
-
-
-
- @if (loading()) {
-
- {{ 'EPG.LOADING' | translate }}
-
- } @else if (hasSummary()) {
-
- {{ summary()?.title }}
-
-
- @if (hasTimeRange()) {
-
- @if (summary()?.start) {
- {{ summary()?.start | date: 'HH:mm' }}
- }
- @if (summary()?.start && summary()?.stop) {
- -
- }
- @if (summary()?.stop) {
- {{ summary()?.stop | date: 'HH:mm' }}
- }
-
- }
- } @else {
-
- {{ 'EPG.NO_PROGRAM_INFO' | translate }}
-
- }
-
-
-
- @if (showReturnToLive()) {
-
- }
-
- @if (showDateNavigator()) {
- @if (!collapsed()) {
-
-
-
- {{
- selectedDateValue()
- | date
- : 'MMMM d, EEEE'
- : undefined
- : currentLocale()
- }}
-
-
-
-
-
-
-
-
-
-
- }
- }
-
- @if (progress() !== null) {
-
- }
-
-
-
-
-
-
diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss
deleted file mode 100644
index 42fa6a207..000000000
--- a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss
+++ /dev/null
@@ -1,291 +0,0 @@
-:host {
- display: block;
- height: 100%;
- min-height: 0;
-}
-
-.live-epg-panel {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- background: var(--mat-sys-surface);
-}
-
-.live-epg-panel__summary-bar {
- position: relative;
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto auto;
- align-items: center;
- column-gap: 8px;
- min-height: 56px;
- padding: 7px 12px 9px 8px;
- flex-shrink: 0;
- border-bottom: 1px solid var(--app-separator);
- background: var(
- --app-widget-header-bg,
- var(--mat-sys-surface-container-high)
- );
- box-sizing: border-box;
-}
-
-.live-epg-panel__toggle {
- width: 40px;
- height: 40px;
- border-radius: 10px;
- color: var(--mat-sys-on-surface-variant);
- app-region: no-drag;
-
- &:hover {
- color: var(--mat-sys-on-surface);
- background: color-mix(in srgb, currentColor 10%, transparent);
- }
-}
-
-.live-epg-panel__summary {
- min-width: 0;
- display: grid;
- align-content: center;
- row-gap: 3px;
-}
-
-.live-epg-panel__program-line {
- min-width: 0;
- display: flex;
- align-items: baseline;
- gap: 8px;
-}
-
-.live-epg-panel__label {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 0.61rem;
- line-height: 1;
- font-weight: 700;
- letter-spacing: 0.09em;
- text-transform: uppercase;
- color: var(--app-selection-color);
-}
-
-.live-epg-panel__title {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 0.94rem;
- line-height: 1.25;
- font-weight: 600;
- color: var(--mat-sys-on-surface);
-}
-
-.live-epg-panel__title--muted {
- font-weight: 500;
- color: var(--mat-sys-on-surface-variant);
-}
-
-.live-epg-panel__time {
- flex-shrink: 0;
- font-size: 0.78rem;
- line-height: 1;
- font-weight: 600;
- font-variant-numeric: tabular-nums;
- color: var(--mat-sys-on-surface-variant);
-}
-
-.live-epg-panel__return-live {
- min-width: 0;
- height: 36px;
- padding-inline: 10px 12px;
- border-radius: 10px;
- color: var(--app-selection-color);
- background: color-mix(
- in srgb,
- var(--app-selection-color) 10%,
- transparent
- );
- app-region: no-drag;
-
- mat-icon {
- flex-shrink: 0;
- width: 18px;
- height: 18px;
- font-size: 18px;
- }
-
- .live-epg-panel__return-live-text {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 0.78rem;
- font-weight: 700;
- }
-
- &:hover {
- color: var(--mat-sys-on-primary-container);
- background: color-mix(
- in srgb,
- var(--app-selection-color) 16%,
- transparent
- );
- }
-}
-
-.live-epg-panel__date-nav {
- display: flex;
- align-items: center;
- gap: 6px;
- min-width: 0;
- app-region: no-drag;
-
- button {
- width: 40px;
- height: 40px;
- border-radius: 10px;
- color: var(--mat-sys-on-surface-variant);
-
- &:hover:not([disabled]) {
- color: var(--mat-sys-on-surface);
- background: color-mix(in srgb, currentColor 10%, transparent);
- }
- }
-}
-
-.live-epg-panel__selected-date {
- min-width: 150px;
- max-width: 220px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- text-align: center;
- font-size: 0.83rem;
- line-height: 1.2;
- font-weight: 600;
- color: var(--mat-sys-on-surface-variant);
-}
-
-.live-epg-panel__date-chip {
- display: none;
- min-width: 0;
- max-width: 150px;
- height: 36px;
- padding-inline: 10px;
- border-radius: 10px;
- color: var(--mat-sys-on-surface-variant);
- background: color-mix(in srgb, currentColor 7%, transparent);
- app-region: no-drag;
-
- mat-icon {
- flex-shrink: 0;
- width: 18px;
- height: 18px;
- font-size: 18px;
- }
-
- span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 0.78rem;
- font-weight: 600;
- }
-}
-
-.live-epg-panel__progress {
- position: absolute;
- inset-inline: 8px 12px;
- bottom: 5px;
- height: 4px;
- overflow: hidden;
- border-radius: 999px;
- background: color-mix(in srgb, var(--mat-sys-on-surface) 14%, transparent);
-}
-
-.live-epg-panel__progress-fill {
- height: 100%;
- border-radius: inherit;
- background: var(--app-selection-color);
- box-shadow: 0 0 8px -2px var(--app-selection-glow);
- transition: width 0.35s ease;
-}
-
-.live-epg-panel__body {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- transition:
- opacity 0.16s ease,
- block-size 0.16s ease;
-}
-
-.live-epg-panel--collapsed {
- .live-epg-panel__summary-bar {
- border-bottom-color: transparent;
- }
-
- .live-epg-panel__date-nav {
- display: none;
- }
-
- .live-epg-panel__summary {
- display: flex;
- align-items: baseline;
- gap: 8px;
- }
-
- .live-epg-panel__label {
- flex-shrink: 0;
- font-size: 0.7rem;
- letter-spacing: 0.08em;
- }
-
- .live-epg-panel__program-line {
- gap: 8px;
- }
-
- .live-epg-panel__body {
- flex-basis: 0;
- block-size: 0;
- min-block-size: 0;
- opacity: 0;
- visibility: hidden;
- pointer-events: none;
- }
-}
-
-@media only screen and (max-width: 720px) {
- .live-epg-panel__date-nav {
- display: none;
- }
-
- .live-epg-panel__date-chip {
- display: inline-flex;
- }
-}
-
-@media only screen and (max-width: 640px) {
- .live-epg-panel__return-live {
- width: 36px;
- padding-inline: 0;
-
- .live-epg-panel__return-live-text {
- display: none;
- }
- }
-}
-
-@media only screen and (max-width: 560px) {
- .live-epg-panel__label {
- display: none;
- }
-
- .live-epg-panel--collapsed .live-epg-panel__label {
- display: none;
- }
-}
diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts
deleted file mode 100644
index 13b012812..000000000
--- a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Component } from '@angular/core';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { TranslateModule } from '@ngx-translate/core';
-import {
- LiveEpgPanelComponent,
- LiveEpgPanelSummary,
-} from './live-epg-panel.component';
-
-@Component({
- standalone: true,
- imports: [LiveEpgPanelComponent],
- template: `
-
- Projected EPG
-
- `,
-})
-class HostComponent {
- collapsed = false;
- loading = false;
- summaryLabelKey = 'EPG.CURRENT_PROGRAM';
- showDateNavigator = false;
- showReturnToLive = false;
- selectedDate = '2026-04-05';
- dateDirection: 'next' | 'prev' | null = null;
- returnToLiveCount = 0;
- summary: LiveEpgPanelSummary | null = {
- title: 'Current Show',
- start: '2026-04-05T11:30:00.000Z',
- stop: '2026-04-05T12:30:00.000Z',
- };
-}
-
-describe('LiveEpgPanelComponent', () => {
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- jest.useFakeTimers();
- jest.setSystemTime(new Date('2026-04-05T12:00:00.000Z'));
-
- await TestBed.configureTestingModule({
- imports: [
- HostComponent,
- NoopAnimationsModule,
- TranslateModule.forRoot(),
- ],
- }).compileComponents();
-
- fixture = TestBed.createComponent(HostComponent);
- });
-
- afterEach(() => {
- fixture.destroy();
- jest.useRealTimers();
- });
-
- it('renders the current program summary and progress', () => {
- fixture.detectChanges();
-
- expect(
- fixture.nativeElement.querySelector('.live-epg-panel__title')
- .textContent
- ).toContain('Current Show');
- expect(
- fixture.nativeElement.querySelector('.live-epg-panel__label')
- .textContent
- ).toContain('EPG.CURRENT_PROGRAM');
- expect(
- fixture.nativeElement.querySelector(
- '.live-epg-panel__progress-fill'
- ).style.width
- ).toBe('50%');
- });
-
- it('emits and applies collapsed state from the toggle button', () => {
- fixture.detectChanges();
-
- fixture.nativeElement.querySelector('button').click();
- fixture.detectChanges();
-
- expect(fixture.componentInstance.collapsed).toBe(true);
- expect(
- fixture.nativeElement
- .querySelector('.live-epg-panel')
- .classList.contains('live-epg-panel--collapsed')
- ).toBe(true);
- });
-
- it('keeps projected EPG content mounted while collapsed and makes it inert', () => {
- fixture.componentInstance.collapsed = true;
- fixture.detectChanges();
-
- const body = fixture.nativeElement.querySelector(
- '.live-epg-panel__body'
- );
-
- expect(
- fixture.nativeElement.querySelector('.projected-content')
- .textContent
- ).toContain('Projected EPG');
- expect(body.getAttribute('aria-hidden')).toBe('true');
- expect(body.hasAttribute('inert')).toBe(true);
- });
-
- it('renders the fallback text when there is no current program', () => {
- fixture.componentInstance.summary = null;
- fixture.detectChanges();
-
- expect(
- fixture.nativeElement.querySelector('.live-epg-panel__title')
- .textContent
- ).toContain('EPG.NO_PROGRAM_INFO');
- });
-
- it('renders date navigation in the unified toolbar and emits day changes', () => {
- fixture.componentInstance.showDateNavigator = true;
- fixture.detectChanges();
-
- expect(
- fixture.nativeElement.querySelector('.selected-date').textContent
- ).toContain('April 5, Sunday');
-
- fixture.nativeElement.querySelector('.next-day').click();
-
- expect(fixture.componentInstance.dateDirection).toBe('next');
- });
-
- it('hides date navigation controls while collapsed', () => {
- fixture.componentInstance.collapsed = true;
- fixture.componentInstance.showDateNavigator = true;
- fixture.detectChanges();
-
- expect(
- fixture.nativeElement.querySelector('.live-epg-panel__date-chip')
- ).toBeNull();
- expect(
- fixture.nativeElement.querySelector('.live-epg-panel__date-nav')
- ).toBeNull();
- });
-
- it('renders archive playback state and emits return-to-live requests', () => {
- fixture.componentInstance.summaryLabelKey = 'EPG.ARCHIVE_PLAYBACK';
- fixture.componentInstance.showReturnToLive = true;
- fixture.detectChanges();
-
- expect(
- fixture.nativeElement.querySelector('.live-epg-panel__label')
- .textContent
- ).toContain('EPG.ARCHIVE_PLAYBACK');
-
- const returnButton = fixture.nativeElement.querySelector(
- '.live-epg-panel__return-live'
- ) as HTMLButtonElement | null;
- expect(returnButton).not.toBeNull();
- expect(returnButton?.textContent).toContain('EPG.RETURN_TO_LIVE');
-
- returnButton?.click();
-
- expect(fixture.componentInstance.returnToLiveCount).toBe(1);
- });
-});
diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts
deleted file mode 100644
index 8053dded3..000000000
--- a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { DatePipe } from '@angular/common';
-import {
- ChangeDetectionStrategy,
- Component,
- computed,
- effect,
- inject,
- input,
- output,
- signal,
-} from '@angular/core';
-import { toSignal } from '@angular/core/rxjs-interop';
-import { MatButton, MatIconButton } from '@angular/material/button';
-import { MatIcon } from '@angular/material/icon';
-import { MatMenuModule } from '@angular/material/menu';
-import { MatTooltip } from '@angular/material/tooltip';
-import { normalizeDateLocale } from '@iptvnator/pipes';
-import {
- EpgDateNavigationDirection,
- parseEpgDateKey,
-} from '@iptvnator/ui/epg/date';
-import { TranslatePipe, TranslateService } from '@ngx-translate/core';
-import { startWith } from 'rxjs';
-
-export interface LiveEpgPanelSummary {
- readonly title?: string | null;
- readonly start?: string | number | Date | null;
- readonly stop?: string | number | Date | null;
- readonly progress?: number | null;
-}
-
-@Component({
- selector: 'app-live-epg-panel',
- imports: [
- DatePipe,
- MatButton,
- MatIcon,
- MatIconButton,
- MatMenuModule,
- MatTooltip,
- TranslatePipe,
- ],
- templateUrl: './live-epg-panel.component.html',
- styleUrl: './live-epg-panel.component.scss',
- changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class LiveEpgPanelComponent {
- readonly collapsed = input(false);
- readonly summary = input(null);
- readonly loading = input(false);
- readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
- readonly showDateNavigator = input(false);
- readonly selectedDate = input(null);
- readonly showReturnToLive = input(false);
- readonly collapsedChange = output();
- readonly dateNavigation = output();
- readonly returnToLive = output();
-
- private readonly translate = inject(TranslateService);
- private readonly currentTimeMs = signal(Date.now());
- private readonly languageTick = toSignal(
- this.translate.onLangChange.pipe(startWith(null)),
- { initialValue: null }
- );
-
- readonly hasSummary = computed(() => {
- const title = this.summary()?.title;
- return typeof title === 'string' && title.trim().length > 0;
- });
-
- readonly hasTimeRange = computed(() => {
- const summary = this.summary();
- return !!summary?.start || !!summary?.stop;
- });
-
- readonly progress = computed(() => {
- const summary = this.summary();
- if (!summary) {
- return null;
- }
-
- const explicitProgress = Number(summary.progress);
- if (Number.isFinite(explicitProgress)) {
- return clampProgress(explicitProgress);
- }
-
- const startMs = toTimeMs(summary.start);
- const stopMs = toTimeMs(summary.stop);
- if (startMs === null || stopMs === null || stopMs <= startMs) {
- return null;
- }
-
- const elapsed = this.currentTimeMs() - startMs;
- return clampProgress((elapsed / (stopMs - startMs)) * 100);
- });
- readonly selectedDateValue = computed(() =>
- parseEpgDateKey(this.selectedDate())
- );
- readonly currentLocale = computed(() => {
- this.languageTick();
- return normalizeDateLocale(
- this.translate.currentLang || this.translate.defaultLang
- );
- });
-
- constructor() {
- effect((onCleanup) => {
- const intervalId = window.setInterval(() => {
- this.currentTimeMs.set(Date.now());
- }, 30_000);
-
- onCleanup(() => clearInterval(intervalId));
- });
- }
-
- toggleCollapsed(): void {
- this.collapsedChange.emit(!this.collapsed());
- }
-
- navigateDate(direction: EpgDateNavigationDirection): void {
- this.dateNavigation.emit(direction);
- }
-}
-
-function toTimeMs(
- value: string | number | Date | null | undefined
-): number | null {
- if (value === null || value === undefined || value === '') {
- return null;
- }
-
- const parsed =
- value instanceof Date
- ? value.getTime()
- : typeof value === 'number'
- ? value
- : Date.parse(value);
- return Number.isFinite(parsed) ? parsed : null;
-}
-
-function clampProgress(value: number): number {
- return Math.min(100, Math.max(0, value));
-}
diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss
index 7293d319d..f2338e805 100644
--- a/libs/ui/styles/_portal-layout.scss
+++ b/libs/ui/styles/_portal-layout.scss
@@ -133,13 +133,20 @@
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;
}
@@ -152,9 +159,8 @@
display: flex;
flex-direction: column;
- > app-epg-view,
- > app-live-epg-panel,
- > app-epg-timeline {
+ > app-epg-timeline,
+ > app-epg-list-view {
flex: 1;
min-height: 0;
}
diff --git a/libs/workspace/shell/util/project.json b/libs/workspace/shell/util/project.json
index 09e65ab5d..61a84736b 100644
--- a/libs/workspace/shell/util/project.json
+++ b/libs/workspace/shell/util/project.json
@@ -4,7 +4,7 @@
"sourceRoot": "libs/workspace/shell/util/src",
"prefix": "lib",
"projectType": "library",
- "tags": ["scope:workspace", "domain:workspace", "type:util"],
+ "tags": ["scope:workspace", "domain:workspace", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
diff --git a/package.json b/package.json
index 2f017d3e7..e28337b07 100644
--- a/package.json
+++ b/package.json
@@ -32,11 +32,12 @@
"test:frontend": "nx test web",
"test:backend": "nx test electron-backend",
"test:unit:all": "nx run-many --target=test --all --parallel=3",
- "test:unit:ci": "nx run-many --target=test --projects=electron-backend,web,services,m3u-state,portal-stalker-data-access,portal-shared-util,playlist-shared-ui,portal-xtream-data-access,portal-xtream-feature,workspace-dashboard-data-access,components,@iptvnator/pipes,packaging,epg-data-access,workspace-shell-util,database --parallel=3 --output-style=static",
+ "test:unit:ci": "nx run-many --target=test --all --parallel=3 --output-style=static",
"coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs",
"coverage:merge": "node tools/coverage/merge-coverage.mjs",
"coverage:health": "node tools/coverage/coverage-health.mjs",
- "coverage:ci": "pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report",
+ "coverage:policy:check": "node tools/coverage/check-coverage-policy.mjs",
+ "coverage:ci": "pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report",
"coverage:e2e:summary": "node tools/coverage/e2e-semantic-summary.mjs",
"coverage:e2e:v8:web": "node tools/coverage/e2e-v8-web.mjs",
"typecheck:web": "tsc -p apps/web/tsconfig.app.json --noEmit",
@@ -59,7 +60,7 @@
"release:artwork:manifest": "tsx tools/release/generate-marketing-artwork.ts --manifest",
"release:artwork:generate": "tsx tools/release/generate-marketing-artwork.ts --generate",
"release:artwork:validate": "tsx tools/release/generate-marketing-artwork.ts --validate",
- "lint": "nx lint electron-backend",
+ "lint": "nx run-many --target=lint --all",
"build": "nx build electron-backend"
},
"private": true,
@@ -75,7 +76,6 @@
"@angular/platform-browser": "21.2.9",
"@angular/platform-browser-dynamic": "21.2.9",
"@angular/router": "21.2.9",
- "@ngrx/component-store": "21.0.1",
"@ngrx/effects": "21.0.1",
"@ngrx/entity": "21.0.1",
"@ngrx/router-store": "21.0.1",
@@ -83,7 +83,6 @@
"@ngrx/store": "21.0.1",
"@ngrx/store-devtools": "21.0.1",
"@ngx-pwa/local-storage": "21.0.0",
- "@videojs/http-streaming": "2.15.0",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"angularx-qrcode": "21.0.4",
"artplayer": "5.3.0",
@@ -98,7 +97,6 @@
"fix-path": "5.0.0",
"hls.js": "1.6.13",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
- "lodash": "4.18.1",
"marked": "18.0.5",
"mpegts.js": "1.8.0",
"ms": "2.1.3",
@@ -106,7 +104,6 @@
"ngx-skeleton-loader": "11.3.0",
"rxjs": "7.8.2",
"saxes": "6.0.0",
- "semver": "7.7.3",
"uuid": "9.0.0",
"video.js": "8.23.4",
"videojs-contrib-quality-levels": "4.1.0",
@@ -170,7 +167,6 @@
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/jest": "^30.0.0",
- "@types/lodash": "4.17.20",
"@types/mocha": "9.0.0",
"@types/node": "20.19.9",
"@types/uuid": "^10.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5fcf722a4..81ef8fde5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -65,9 +65,6 @@ importers:
'@angular/router':
specifier: 21.2.9
version: 21.2.9(@angular/common@21.2.9(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.9(@angular/animations@21.2.9(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@21.2.9(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
- '@ngrx/component-store':
- specifier: 21.0.1
- version: 21.0.1(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@ngrx/effects':
specifier: 21.0.1
version: 21.0.1(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2)
@@ -89,9 +86,6 @@ importers:
'@ngx-pwa/local-storage':
specifier: 21.0.0
version: 21.0.0(@angular/common@21.2.9(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
- '@videojs/http-streaming':
- specifier: 2.15.0
- version: 2.15.0(video.js@8.23.4)
'@yangkghjh/videojs-aspect-ratio-panel':
specifier: 0.0.1
version: 0.0.1
@@ -134,9 +128,6 @@ importers:
iptv-playlist-parser:
specifier: github:4gray/iptv-playlist-parser
version: https://codeload.github.com/4gray/iptv-playlist-parser/tar.gz/38a62d0b7c98f442bfa102ee8e1fe14169ae9386
- lodash:
- specifier: 4.18.1
- version: 4.18.1
marked:
specifier: 18.0.5
version: 18.0.5
@@ -158,9 +149,6 @@ importers:
saxes:
specifier: 6.0.0
version: 6.0.0
- semver:
- specifier: 7.7.3
- version: 7.7.3
uuid:
specifier: 9.0.0
version: 9.0.0
@@ -324,9 +312,6 @@ importers:
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
- '@types/lodash':
- specifier: 4.17.20
- version: 4.17.20
'@types/mocha':
specifier: 9.0.0
version: 9.0.0
@@ -3357,12 +3342,6 @@ packages:
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
- '@ngrx/component-store@21.0.1':
- resolution: {integrity: sha512-aOfudK8MqCZQUuBjMnXAXRxRcr9Qu/+9/2iIh4aLhmGXC5JCpkDi1a82A/39EUFZy2xBbI4lPI7P817b1PQRvQ==}
- peerDependencies:
- '@angular/core': ^21.0.0
- rxjs: ^6.5.3 || ^7.5.0
-
'@ngrx/effects@21.0.1':
resolution: {integrity: sha512-hSdpToAiSYa5FJ/CAygQHpnCaF2S1HO7q/57ob3XvNTWmkofa0VqI/IIe4W57bojh2YOWCJ91SCn3kAjymaV3g==}
peerDependencies:
@@ -4631,9 +4610,6 @@ packages:
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
- '@types/lodash@4.17.20':
- resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
-
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -4908,12 +4884,6 @@ packages:
cpu: [x64]
os: [win32]
- '@videojs/http-streaming@2.15.0':
- resolution: {integrity: sha512-uRW62ybZOP4Osw24/yT79vjRkxqAyoB/wtVV6lroAaPHYUXCz9//VUQAC6xZn2z6JeCoBz0pQHGlxYb8JRFj6Q==}
- engines: {node: '>=8', npm: '>=5'}
- peerDependencies:
- video.js: ^6 || ^7
-
'@videojs/http-streaming@2.16.3':
resolution: {integrity: sha512-91CJv5PnFBzNBvyEjt+9cPzTK/xoVixARj2g7ZAvItA+5bx8VKdk5RxCz/PP2kdzz9W+NiDUMPkdmTsosmy69Q==}
engines: {node: '>=8', npm: '>=5'}
@@ -4991,11 +4961,6 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
- '@xmldom/xmldom@0.7.13':
- resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
- engines: {node: '>=10.0.0'}
- deprecated: this version has critical issues, please update to the latest version
-
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
@@ -8908,10 +8873,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
- mpd-parser@0.22.0:
- resolution: {integrity: sha512-P2GSZGTlud9zGr1IU/wd+dwK5v+cdeHd9ys+8y0Yo4kADfkKyYET37oIvOoQiLGp6k7CTNO7navdyV2mfG0yzw==}
- hasBin: true
-
mpd-parser@0.22.1:
resolution: {integrity: sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==}
hasBin: true
@@ -13760,7 +13721,7 @@ snapshots:
make-fetch-happen: 10.2.1
nopt: 6.0.0
proc-log: 2.0.1
- semver: 7.7.3
+ semver: 7.7.4
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
@@ -15388,12 +15349,6 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@ngrx/component-store@21.0.1(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)':
- dependencies:
- '@angular/core': 21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1)
- rxjs: 7.8.2
- tslib: 2.8.1
-
'@ngrx/effects@21.0.1(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(@ngrx/store@21.0.1(@angular/core@21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(rxjs@7.8.2)':
dependencies:
'@angular/core': 21.2.9(@angular/compiler@21.2.9)(rxjs@7.8.2)(zone.js@0.15.1)
@@ -15489,7 +15444,7 @@ snapshots:
'@npmcli/fs@2.1.2':
dependencies:
'@gar/promisify': 1.1.3
- semver: 7.7.3
+ semver: 7.7.4
'@npmcli/fs@5.0.0':
dependencies:
@@ -16921,8 +16876,6 @@ snapshots:
dependencies:
'@types/node': 20.19.9
- '@types/lodash@4.17.20': {}
-
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -17186,17 +17139,6 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
- '@videojs/http-streaming@2.15.0(video.js@8.23.4)':
- dependencies:
- '@babel/runtime': 7.28.4
- '@videojs/vhs-utils': 3.0.5
- aes-decrypter: 3.1.3
- global: 4.4.0
- m3u8-parser: 4.8.0
- mpd-parser: 0.22.0
- mux.js: 6.0.1
- video.js: 8.23.4
-
'@videojs/http-streaming@2.16.3(video.js@7.21.7)':
dependencies:
'@babel/runtime': 7.28.4
@@ -17322,8 +17264,6 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2
- '@xmldom/xmldom@0.7.13': {}
-
'@xmldom/xmldom@0.8.11': {}
'@xtuc/ieee754@1.2.0': {}
@@ -22438,13 +22378,6 @@ snapshots:
mkdirp@1.0.4: {}
- mpd-parser@0.22.0:
- dependencies:
- '@babel/runtime': 7.28.4
- '@videojs/vhs-utils': 3.0.5
- '@xmldom/xmldom': 0.7.13
- global: 4.4.0
-
mpd-parser@0.22.1:
dependencies:
'@babel/runtime': 7.28.4
diff --git a/tools/coverage/check-coverage-policy.mjs b/tools/coverage/check-coverage-policy.mjs
new file mode 100644
index 000000000..3fe6e5bf0
--- /dev/null
+++ b/tools/coverage/check-coverage-policy.mjs
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+
+/**
+ * Guards tools/coverage/coverage-policy.json against drift.
+ *
+ * Every Nx project with a `test` target must be classified in a coverage
+ * tier (A, B, or C). This makes CI fail loudly when a new library is added
+ * without deciding how it is tested, instead of silently skipping it.
+ *
+ * Usage:
+ * node tools/coverage/check-coverage-policy.mjs
+ * Validate the policy. Exits non-zero on unclassified projects, stale
+ * entries, or Tier A entries without a test target.
+ * node tools/coverage/check-coverage-policy.mjs --run-non-tier-a
+ * Run the validation command for each Tier B/C project. Uses the
+ * entry's `validationCommand` when present, falling back to
+ * `pnpm nx test `. Projects with an `e2e` target are skipped
+ * because the E2E workflow already runs them on every PR.
+ */
+
+import { execFileSync, spawnSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+
+const workspaceRoot = process.cwd();
+const policyPath = path.join(workspaceRoot, 'tools/coverage/coverage-policy.json');
+const policy = JSON.parse(readFileSync(policyPath, 'utf8'));
+
+const tiers = {
+ tierA: policy.unitCoverage.tierA ?? [],
+ tierB: policy.unitCoverage.tierB ?? [],
+ tierC: policy.unitCoverage.tierC ?? [],
+};
+
+function showProjects(extraArgs = []) {
+ const output = execFileSync(
+ 'pnpm',
+ ['nx', 'show', 'projects', '--json', ...extraArgs],
+ { cwd: workspaceRoot, encoding: 'utf8' }
+ );
+ const start = output.indexOf('[');
+ if (start === -1) {
+ throw new Error(
+ `nx show projects --json produced no JSON array.\nRaw output:\n${output}`
+ );
+ }
+ return new Set(JSON.parse(output.slice(start)));
+}
+
+const testProjects = showProjects(['--withTarget', 'test']);
+const allProjects = showProjects();
+const e2eProjects = showProjects(['--withTarget', 'e2e']);
+
+function runNonTierA() {
+ let failed = false;
+
+ for (const project of [...tiers.tierB, ...tiers.tierC]) {
+ if (e2eProjects.has(project.name)) {
+ console.log(
+ `==> Skipping ${project.name}: covered by the E2E workflow.`
+ );
+ continue;
+ }
+
+ const command =
+ project.validationCommand ??
+ (testProjects.has(project.name)
+ ? `pnpm nx test ${project.name}`
+ : null);
+ if (!command) {
+ console.log(
+ `==> Skipping ${project.name}: no validation command or test target.`
+ );
+ continue;
+ }
+
+ console.log(`\n==> Validating ${project.name}: ${command}`);
+ const result = spawnSync(command, {
+ cwd: workspaceRoot,
+ shell: true,
+ stdio: 'inherit',
+ });
+ if (result.status !== 0) {
+ failed = true;
+ }
+ }
+
+ process.exit(failed ? 1 : 0);
+}
+
+if (process.argv.includes('--run-non-tier-a')) {
+ runNonTierA();
+}
+
+const classified = new Map(
+ Object.entries(tiers).flatMap(([tier, projects]) =>
+ projects.map((project) => [project.name, tier])
+ )
+);
+
+const unclassified = [...testProjects].filter((name) => !classified.has(name));
+const stale = [...classified.keys()].filter((name) => !allProjects.has(name));
+const tierAWithoutTest = tiers.tierA
+ .map((project) => project.name)
+ .filter((name) => !testProjects.has(name));
+
+if (unclassified.length > 0) {
+ console.error(
+ 'Coverage policy is missing projects that have a test target:\n' +
+ unclassified.map((name) => ` - ${name}`).join('\n') +
+ '\n\nAdd each project to a tier in tools/coverage/coverage-policy.json:' +
+ '\n tierA: runs in CI with coverage (Codecov)' +
+ '\n tierB/tierC: runs in CI without coverage'
+ );
+ process.exit(1);
+}
+
+if (stale.length > 0) {
+ console.error(
+ 'Coverage policy lists projects that do not exist in the workspace:\n' +
+ stale.map((name) => ` - ${name}`).join('\n') +
+ '\n\nRemove them from tools/coverage/coverage-policy.json.'
+ );
+ process.exit(1);
+}
+
+if (tierAWithoutTest.length > 0) {
+ console.error(
+ 'Tier A projects must have a test target, but these do not:\n' +
+ tierAWithoutTest.map((name) => ` - ${name}`).join('\n') +
+ '\n\nRestore the test target or move the project to Tier B/C in ' +
+ 'tools/coverage/coverage-policy.json.'
+ );
+ process.exit(1);
+}
+
+console.log(
+ `Coverage policy OK: ${testProjects.size} test projects classified ` +
+ `(${tiers.tierA.length} Tier A, ${tiers.tierB.length} Tier B, ${tiers.tierC.length} Tier C).`
+);
diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json
index d97aa5185..d29c4c0a7 100644
--- a/tools/coverage/coverage-policy.json
+++ b/tools/coverage/coverage-policy.json
@@ -184,13 +184,6 @@
"validationCommand": "pnpm nx test ui-playback",
"e2eTags": ["@playback", "@m3u"]
},
- {
- "name": "shared-portals",
- "root": "libs/ui/shared-portals",
- "sourceRoot": "libs/ui/shared-portals/src",
- "validationCommand": "pnpm nx test shared-portals",
- "e2eTags": ["@xtream", "@stalker"]
- },
{
"name": "workspace-dashboard-data-access",
"root": "libs/workspace/dashboard/data-access",
@@ -277,6 +270,11 @@
"name": "remote-control",
"root": "libs/ui/remote-control",
"reason": "No unit specs yet; validate through remote-control flows or add focused tests when behavior changes directly."
+ },
+ {
+ "name": "shared-portals",
+ "root": "libs/ui/shared-portals",
+ "reason": "Type-only library after the dead epg-view/live-epg-panel components were removed; only the LiveEpgPanelSummary interface remains, so there is no runtime code to unit test."
}
]
},
diff --git a/tools/eslint/generate-max-lines-baseline.mjs b/tools/eslint/generate-max-lines-baseline.mjs
new file mode 100644
index 000000000..a92204f12
--- /dev/null
+++ b/tools/eslint/generate-max-lines-baseline.mjs
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+
+/**
+ * Regenerates tools/eslint/max-lines-baseline.mjs: the list of TypeScript
+ * files that already exceed the max-lines limit enforced in eslint.config.mjs.
+ * Baselined files are exempt from the rule; the list should only shrink.
+ *
+ * Usage: node tools/eslint/generate-max-lines-baseline.mjs
+ */
+
+import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+
+export const MAX_LINES = 400;
+
+const workspaceRoot = process.cwd();
+const scanRoots = ['apps', 'libs', 'tools'];
+const skipDirs = new Set(['node_modules', 'dist', '.nx', 'coverage']);
+
+function collectTsFiles(dir, results) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.isDirectory()) {
+ if (!skipDirs.has(entry.name)) {
+ collectTsFiles(path.join(dir, entry.name), results);
+ }
+ } else if (
+ (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) &&
+ !entry.name.endsWith('.d.ts')
+ ) {
+ results.push(path.join(dir, entry.name));
+ }
+ }
+ return results;
+}
+
+function countLines(filePath) {
+ const content = readFileSync(filePath, 'utf8');
+ if (content.length === 0) {
+ return 0;
+ }
+ const lines = content.split('\n').length;
+ return content.endsWith('\n') ? lines - 1 : lines;
+}
+
+const offenders = scanRoots
+ .flatMap((root) => collectTsFiles(path.join(workspaceRoot, root), []))
+ .map((filePath) => ({
+ file: path.relative(workspaceRoot, filePath).split(path.sep).join('/'),
+ lines: countLines(filePath),
+ }))
+ .filter(({ lines }) => lines > MAX_LINES)
+ .sort((a, b) => a.file.localeCompare(b.file));
+
+const banner = `// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand.
+// TypeScript files that predate the max-lines (${MAX_LINES}) ESLint rule.
+// This list should only shrink: split a file below the limit, rerun the
+// generator, and commit the result. Never add new files here.
+`;
+
+const body = offenders.map(({ file }) => ` '${file}',`).join('\n');
+
+writeFileSync(
+ path.join(workspaceRoot, 'tools/eslint/max-lines-baseline.mjs'),
+ `${banner}export const maxLinesBaseline = [\n${body}\n];\n`
+);
+
+console.log(`Baselined ${offenders.length} files over ${MAX_LINES} lines.`);
diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs
new file mode 100644
index 000000000..4ccadc2cf
--- /dev/null
+++ b/tools/eslint/max-lines-baseline.mjs
@@ -0,0 +1,139 @@
+// Generated by tools/eslint/generate-max-lines-baseline.mjs — do not edit by hand.
+// TypeScript files that predate the max-lines (400) ESLint rule.
+// This list should only shrink: split a file below the limit, rerun the
+// generator, and commit the result. Never add new files here.
+export const maxLinesBaseline = [
+ 'apps/electron-backend-e2e/src/catalog-sorting.e2e.ts',
+ 'apps/electron-backend-e2e/src/category-management.e2e.ts',
+ 'apps/electron-backend-e2e/src/electron-test-fixtures.ts',
+ 'apps/electron-backend-e2e/src/favorites.e2e.ts',
+ 'apps/electron-backend-e2e/src/playlist-switcher.e2e.ts',
+ 'apps/electron-backend-e2e/src/portal-mock-fixtures.ts',
+ 'apps/electron-backend-e2e/src/recent.e2e.ts',
+ 'apps/electron-backend-e2e/src/search.e2e.ts',
+ 'apps/electron-backend-e2e/src/settings.e2e.ts',
+ 'apps/electron-backend-e2e/src/sources.e2e.ts',
+ 'apps/electron-backend/src/app/api/main.preload.spec-data.ts',
+ 'apps/electron-backend/src/app/api/main.preload.ts',
+ 'apps/electron-backend/src/app/app.ts',
+ 'apps/electron-backend/src/app/database/operations/content.operations.spec.ts',
+ 'apps/electron-backend/src/app/database/operations/content.operations.ts',
+ 'apps/electron-backend/src/app/database/operations/playlist.operations.ts',
+ 'apps/electron-backend/src/app/events/epg-query.service.spec.ts',
+ 'apps/electron-backend/src/app/events/epg-query.service.ts',
+ 'apps/electron-backend/src/app/events/epg-worker.service.ts',
+ 'apps/electron-backend/src/app/events/epg.events.spec.ts',
+ 'apps/electron-backend/src/app/events/mpv-session.service.ts',
+ 'apps/electron-backend/src/app/events/player.events.spec.ts',
+ 'apps/electron-backend/src/app/events/playlist.events.spec.ts',
+ 'apps/electron-backend/src/app/events/vlc-session.service.ts',
+ 'apps/electron-backend/src/app/services/app-update.service.spec.ts',
+ 'apps/electron-backend/src/app/services/app-update.service.ts',
+ 'apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts',
+ 'apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts',
+ 'apps/electron-backend/src/app/services/embedded-mpv-native.service.ts',
+ 'apps/electron-backend/src/app/util/validated-axios.spec.ts',
+ 'apps/electron-backend/src/app/workers/database.worker.ts',
+ 'apps/electron-backend/src/app/workers/epg-parser.worker.ts',
+ 'apps/stalker-mock-server/src/app/data-generator.ts',
+ 'apps/web-backend/src/app/web-backend-app.spec.ts',
+ 'apps/web-backend/src/app/web-backend-app.ts',
+ 'apps/web-e2e/src/stalker.e2e.ts',
+ 'apps/web-e2e/src/xtream.e2e.ts',
+ 'apps/web/src/app/services/electron.service.ts',
+ 'apps/web/src/app/services/pwa.service.ts',
+ 'apps/web/src/app/settings/settings.component.spec.ts',
+ 'apps/web/src/app/settings/settings.component.ts',
+ 'apps/xtream-mock-server/src/app/generators/marketing.generator.ts',
+ 'libs/epg/data-access/src/lib/epg.service.spec.ts',
+ 'libs/epg/data-access/src/lib/epg.service.ts',
+ 'libs/m3u-state/src/lib/effects.ts',
+ 'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts',
+ 'libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts',
+ 'libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts',
+ 'libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts',
+ 'libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts',
+ 'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts',
+ 'libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts',
+ 'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts',
+ 'libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts',
+ 'libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts',
+ 'libs/playlist/shared/util/src/lib/playlist-context.facade.ts',
+ 'libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts',
+ 'libs/portal/downloads/feature/src/lib/downloads.component.ts',
+ 'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts',
+ 'libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts',
+ 'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts',
+ 'libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts',
+ 'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts',
+ 'libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts',
+ 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts',
+ 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts',
+ 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts',
+ 'libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts',
+ 'libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts',
+ 'libs/portal/stalker/data-access/src/lib/stalker-session.service.ts',
+ 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts',
+ 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts',
+ 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts',
+ 'libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts',
+ 'libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts',
+ 'libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts',
+ 'libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts',
+ 'libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts',
+ 'libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts',
+ 'libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts',
+ 'libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts',
+ 'libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts',
+ 'libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts',
+ 'libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts',
+ 'libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts',
+ 'libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts',
+ 'libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts',
+ 'libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts',
+ 'libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts',
+ 'libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts',
+ 'libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts',
+ 'libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts',
+ 'libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts',
+ 'libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts',
+ 'libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts',
+ 'libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts',
+ 'libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts',
+ 'libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts',
+ 'libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts',
+ 'libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts',
+ 'libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts',
+ 'libs/services/src/lib/database-electron.service.ts',
+ 'libs/services/src/lib/downloads.service.ts',
+ 'libs/services/src/lib/playlist-backup.service.ts',
+ 'libs/services/src/lib/playlists.service.spec.ts',
+ 'libs/services/src/lib/playlists.service.ts',
+ 'libs/services/src/lib/runtime-capabilities.service.spec.ts',
+ 'libs/shared/database/src/lib/connection.spec.ts',
+ 'libs/shared/database/src/lib/connection.ts',
+ 'libs/shared/interfaces/src/lib/electron-api.interface.ts',
+ 'libs/shared/m3u-utils/src/lib/playlist.utils.ts',
+ 'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts',
+ 'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts',
+ 'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts',
+ 'libs/ui/components/src/lib/season-container/season-container.component.ts',
+ 'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
+ 'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
+ 'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts',
+ 'libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts',
+ 'libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts',
+ 'libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts',
+ 'libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts',
+ 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts',
+ 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts',
+ 'libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts',
+ 'libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts',
+ 'libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts',
+ 'libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts',
+ 'libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts',
+ 'libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts',
+ 'libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts',
+ 'tools/release/capture-v020-screenshots.ts',
+ 'tools/release/generate-marketing-artwork.ts',
+];