From c4890748e0bf8ec4bc2b222647c032aacd746cef Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 2 Jul 2026 00:29:52 +0200 Subject: [PATCH 1/3] feat(epg): add vertical list view for the live EPG panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an EPG list view — a vertical, single-day programme list — as an alternative rendering of the live EPG panel, selectable via a new Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list', default 'timeline' so existing users see no change). - New EpgListViewComponent (app-epg-list-view) mirrors EpgTimelineComponent's input/output contract 1:1, so all four live hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel with a plain @if and identical bindings. - Reuses the shared view-agnostic EPG modules (classifyTimelineWhen, hasProgramsForDateKey, epg-archive.util catch-up gating, epg-summary.util collapsed-summary maths, epg-date helpers, EpgProgrammeDialogService, app-epg-timeline-empty-state) — no duplicated logic. - Rows show time range, title, optional description, live progress on the on-air row, catch-up "Watch" on past rows when archive playback is available, and a details dialog; keyboard activation guards nested buttons (target === currentTarget). - Auto-focuses the on-air row on channel select, restores it across collapse/expand remounts, and shows a sticky in-flow "On now" strip (never overlaying rows) when the current programme is scrolled away; all scroll maths is rect-based relative to the scroller. - List mode raises only the inline panel height via an epg--list modifier (--epg-inline-height clamp); timeline and collapsed heights are unchanged. - Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS → SettingsStore/StorageMap → segmented control in the EPG section); Electron-only UI, PWA stays on the timeline default. i18n keys added to all 18 locales. - Tests: new component/row/utils/scroll-controller specs, settings persistence spec, swap tests in all four host specs, and Electron E2E for the settings round-trip and the rendered list view. Docs updated (m3u-playlist-module.md). Co-Authored-By: Claude Opus 4.8 --- apps/electron-backend-e2e/src/settings.e2e.ts | 32 ++ .../src/xtream-epg.e2e.ts | 61 +++ .../settings-epg-section.component.html | 34 ++ .../settings-epg-section.component.ts | 4 + .../src/app/settings/settings-form.utils.ts | 10 +- apps/web/src/app/settings/settings-options.ts | 15 + .../src/app/settings/settings.component.html | 2 + .../app/settings/settings.component.spec.ts | 18 + .../src/app/settings/settings.component.ts | 14 + apps/web/src/app/settings/settings.models.ts | 7 + apps/web/src/assets/i18n/ar.json | 4 + apps/web/src/assets/i18n/ary.json | 4 + apps/web/src/assets/i18n/by.json | 4 + apps/web/src/assets/i18n/de.json | 4 + apps/web/src/assets/i18n/el.json | 4 + apps/web/src/assets/i18n/en.json | 4 + apps/web/src/assets/i18n/es.json | 4 + apps/web/src/assets/i18n/fr.json | 4 + apps/web/src/assets/i18n/it.json | 4 + apps/web/src/assets/i18n/ja.json | 4 + apps/web/src/assets/i18n/ko.json | 4 + apps/web/src/assets/i18n/nl.json | 4 + apps/web/src/assets/i18n/pl.json | 4 + apps/web/src/assets/i18n/pt.json | 4 + apps/web/src/assets/i18n/ru.json | 4 + apps/web/src/assets/i18n/tr.json | 4 + apps/web/src/assets/i18n/zh.json | 4 + apps/web/src/assets/i18n/zhtw.json | 4 + docs/architecture/m3u-playlist-module.md | 56 ++- .../video-player/video-player.component.html | 75 ++- .../video-player.component.spec.ts | 29 +- .../video-player/video-player.component.ts | 6 + .../unified-live-tab.component.html | 56 ++- .../unified-live-tab.component.spec.ts | 47 +- .../unified-live-tab.component.ts | 6 + .../stalker-live-stream-layout.component.html | 65 ++- ...alker-live-stream-layout.component.spec.ts | 35 +- .../stalker-live-stream-layout.component.ts | 6 + .../live-stream-layout.component.html | 61 ++- .../live-stream-layout.component.spec.ts | 32 +- .../live-stream-layout.component.ts | 6 + .../src/lib/settings-store.service.ts | 3 + .../interfaces/src/lib/settings.interface.ts | 5 + libs/ui/epg/src/index.ts | 3 + .../epg-list-scroll.controller.spec.ts | 190 ++++++++ .../epg-list-scroll.controller.ts | 144 ++++++ .../epg-list-view-row.component.html | 61 +++ .../epg-list-view-row.component.scss | 224 +++++++++ .../epg-list-view-row.component.spec.ts | 131 ++++++ .../epg-list-view-row.component.ts | 90 ++++ .../epg-list-view.component.html | 217 +++++++++ .../epg-list-view.component.scss | 440 ++++++++++++++++++ .../epg-list-view.component.spec.ts | 247 ++++++++++ .../epg-list-view/epg-list-view.component.ts | 304 ++++++++++++ .../epg-list-view/epg-list-view.utils.spec.ts | 137 ++++++ .../lib/epg-list-view/epg-list-view.utils.ts | 117 +++++ libs/ui/styles/_portal-layout.scss | 13 +- 57 files changed, 2983 insertions(+), 92 deletions(-) create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.html create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.scss create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view-row/epg-list-view-row.component.ts create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index ecb32572b..532d62d38 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -231,6 +231,38 @@ test.describe('Electron Settings', () => { } }); + test('@settings @persistence @electron persists the EPG view mode across app restart', async ({ + dataDir, + }) => { + const firstLaunch = await launchElectronApp(dataDir); + + try { + await openSettings(firstLaunch.mainWindow); + const listToggle = firstLaunch.mainWindow.locator( + '[data-test-id="epg-view-mode-list"]' + ); + await expect(listToggle).toBeVisible(); + await listToggle.click(); + await expect(listToggle).toHaveAttribute('aria-checked', 'true'); + await saveSettings(firstLaunch.mainWindow); + } finally { + await closeElectronApp(firstLaunch); + } + + const secondLaunch = await launchElectronApp(dataDir); + + try { + await openSettings(secondLaunch.mainWindow); + await expect( + secondLaunch.mainWindow.locator( + '[data-test-id="epg-view-mode-list"]' + ) + ).toHaveAttribute('aria-checked', 'true'); + } finally { + await closeElectronApp(secondLaunch); + } + }); + test('@settings @electron starts on sources when dashboard is disabled', async ({ dataDir }) => { const firstLaunch = await launchElectronApp(dataDir); diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 2510ea692..979323fbb 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -4,9 +4,12 @@ import { clickCategoryByNameExact, closeElectronApp, expect, + goToDashboard, launchElectronApp, + openSettings, openWorkspaceSection, resetMockServers, + saveSettings, test, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; @@ -121,6 +124,64 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { }); } +test('@epg @xtream @electron renders the vertical list view when the setting is "list"', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const currentProgram = fixture.shortEpg[0]; + if (!currentProgram) { + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); + } + const app = await launchElectronApp(dataDir); + + try { + // Opt into the list view first (from the fresh workspace) so the portal + // → Live TV → channel flow afterwards mirrors the timeline test exactly. + await openSettings(app.mainWindow); + await app.mainWindow + .locator('[data-test-id="epg-view-mode-list"]') + .click(); + await saveSettings(app.mainWindow); + await goToDashboard(app.mainWindow); + + await addXtreamPortal(app.mainWindow, { + name: `${epgPortalName} List`, + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + const channelRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + await expect(channelRow).toBeVisible({ timeout: 20000 }); + await channelRow.click(); + + // The list view renders instead of the timeline. + await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({ + timeout: 20000, + }); + await expect( + app.mainWindow.locator('app-epg-timeline') + ).toHaveCount(0); + + // The on-air programme is the highlighted "now" row. + await expect( + app.mainWindow + .locator('app-epg-list-view .g-row[data-when="now"] .title') + .first() + ).toHaveText(currentProgram.title); + } finally { + await closeElectronApp(app); + } +}); + function formatTimeInZone(timestampSeconds: number, timeZone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone, diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index dec5beaa6..d4153604e 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -14,6 +14,40 @@

{{ 'SETTINGS.EPG_SOURCES' | translate }}

+
+
+

{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}

+

{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}

+
+
+
+ @for (option of epgViewModeOptions(); track option.value) { + + } +
+
+
+
diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index beb0e7fa3..4fbd8aea0 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -7,8 +7,10 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { EpgViewMode } from '@iptvnator/shared/interfaces'; import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; import { TranslateModule } from '@ngx-translate/core'; +import { EpgViewModeOption } from './settings.models'; @Component({ selector: 'app-settings-epg-section', @@ -33,10 +35,12 @@ export class SettingsEpgSectionComponent { readonly activeSection = input.required(); readonly epgUrl = input.required(); readonly isClearingEpgData = input(false); + readonly epgViewModeOptions = input.required(); readonly refreshEpg = output(); readonly removeEpgSource = output(); readonly addEpgSource = output(); readonly refreshAllEpg = output(); readonly clearEpgData = output(); + readonly selectEpgViewMode = output(); } diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index cbdaf7674..5a49d8abd 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -7,6 +7,7 @@ import { import { CoverSize, DEFAULT_DASHBOARD_RAILS_SETTINGS, + EpgViewMode, Language, normalizeDashboardRailsSettings, normalizeExternalPlayerArguments, @@ -70,7 +71,12 @@ export function createSettingsForm( ], recordingFolder: '', coverSize: 'medium' as CoverSize, - ...(supportsEpg ? { preferUploadedEpgOverXtream: false } : {}), + ...(supportsEpg + ? { + preferUploadedEpgOverXtream: false, + epgViewMode: 'timeline' as EpgViewMode, + } + : {}), }); } @@ -129,6 +135,8 @@ export function createSettingsFromFormValue( value.preferUploadedEpgOverXtream ?? currentSettings.preferUploadedEpgOverXtream ?? false, + epgViewMode: + value.epgViewMode ?? currentSettings.epgViewMode ?? 'timeline', trustedPrivateNetworkEpgUrls: currentSettings.trustedPrivateNetworkEpgUrls ?? [], trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [], diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 8cf1572f1..bbeb6adbc 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -1,11 +1,13 @@ import { CoverSize, + EpgViewMode, StartupBehavior, Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; import { CoverSizeOption, + EpgViewModeOption, SettingsPlayerOption, SettingsSection, StartupBehaviorOption, @@ -48,6 +50,19 @@ export const SETTINGS_COVER_SIZE_OPTIONS: CoverSizeOption[] = [ }, ]; +export const SETTINGS_EPG_VIEW_MODE_OPTIONS: EpgViewModeOption[] = [ + { + value: 'timeline' satisfies EpgViewMode, + icon: 'view_timeline', + labelKey: 'SETTINGS.EPG_VIEW_MODE_TIMELINE', + }, + { + value: 'list' satisfies EpgViewMode, + icon: 'view_list', + labelKey: 'SETTINGS.EPG_VIEW_MODE_LIST', + }, +]; + export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [ { value: StartupBehavior.FirstView, diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 55cf815ad..0369da0ac 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -59,11 +59,13 @@

{{ 'SETTINGS.GENERAL' | translate }}

[activeSection]="activeSection()" [epgUrl]="epgUrl" [isClearingEpgData]="isClearingEpgData()" + [epgViewModeOptions]="epgViewModeOptions" (refreshEpg)="refreshEpg($event)" (removeEpgSource)="removeEpgSource($event)" (addEpgSource)="addEpgSource()" (refreshAllEpg)="refreshAllEpg()" (clearEpgData)="clearEpgData()" + (selectEpgViewMode)="selectEpgViewMode($event)" /> } diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index fbb7f6fce..19a5d9ed3 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -104,6 +104,7 @@ const DEFAULT_SETTINGS = { coverSize: 'medium', dashboardRails: DEFAULT_DASHBOARD_RAILS, preferUploadedEpgOverXtream: false, + epgViewMode: 'timeline', }; const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = { @@ -1295,6 +1296,23 @@ describe('SettingsComponent', () => { }); }); + it('updates the EPG view mode through the epg section output', () => { + const mockStore = settingsStore as unknown as MockSettingsStore; + const listButton = ( + fixture.nativeElement as HTMLElement + ).querySelector( + '[data-test-id="epg-view-mode-list"]' + ) as HTMLButtonElement; + + listButton.click(); + fixture.detectChanges(); + + expect(component.settingsForm.value.epgViewMode).toBe('list'); + expect(mockStore.updateSettings).toHaveBeenCalledWith({ + epgViewMode: 'list', + }); + }); + it('renders dashboard controls with the expected defaults', () => { const nativeElement = fixture.nativeElement as HTMLElement; diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 060dc5860..e25d5516e 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -39,6 +39,7 @@ import { CoverSize, ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ElectronBridgeAppUpdateStatus, + EpgViewMode, Language, StreamFormat, Theme, @@ -69,6 +70,7 @@ import { buildSettingsSectionNavItems, SETTINGS_COVER_SIZE_OPTIONS, SETTINGS_EMBEDDED_PLAYER_OPTIONS, + SETTINGS_EPG_VIEW_MODE_OPTIONS, SETTINGS_OS_PLAYER_OPTIONS, SETTINGS_STARTUP_BEHAVIOR_OPTIONS, SETTINGS_THEME_OPTIONS, @@ -203,6 +205,7 @@ export class SettingsComponent implements OnInit, OnDestroy { readonly themeOptions = SETTINGS_THEME_OPTIONS; readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS; readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS; + readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS; /** Settings form object */ settingsForm = createSettingsForm(this.formBuilder, this.supportsEpg); @@ -507,6 +510,17 @@ export class SettingsComponent implements OnInit, OnDestroy { this.settingsStore.updateSettings({ coverSize: size }); } + selectEpgViewMode(mode: EpgViewMode): void { + if (this.settingsForm.value.epgViewMode === mode) { + return; + } + + this.settingsForm.patchValue({ epgViewMode: mode }); + this.settingsForm.get('epgViewMode')?.markAsDirty(); + this.settingsForm.markAsDirty(); + this.settingsStore.updateSettings({ epgViewMode: mode }); + } + async selectRecordingFolder(): Promise { if ( !this.isDesktop || diff --git a/apps/web/src/app/settings/settings.models.ts b/apps/web/src/app/settings/settings.models.ts index a618a96ac..36f4d18b4 100644 --- a/apps/web/src/app/settings/settings.models.ts +++ b/apps/web/src/app/settings/settings.models.ts @@ -1,5 +1,6 @@ import { CoverSize, + EpgViewMode, StartupBehavior, Theme, VideoPlayer, @@ -34,6 +35,12 @@ export interface CoverSizeOption { labelKey: string; } +export interface EpgViewModeOption { + value: EpgViewMode; + icon: string; + labelKey: string; +} + export interface SettingsPlayerOption { id: VideoPlayer; labelKey: string; diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8a35c14a5..e6d1e0c4e 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "صغير", "COVER_SIZE_MEDIUM": "متوسط", "COVER_SIZE_LARGE": "كبير", + "EPG_VIEW_MODE": "عرض الدليل", + "EPG_VIEW_MODE_DESCRIPTION": "اختر طريقة عرض دليل البرامج أسفل المشغل: شريط جدول زمني أفقي، أو قائمة رأسية ليوم واحد.", + "EPG_VIEW_MODE_TIMELINE": "الجدول الزمني", + "EPG_VIEW_MODE_LIST": "قائمة", "STARTUP_BEHAVIOR": "عرض البدء", "STARTUP_BEHAVIOR_DESCRIPTION": "اختر ما إذا كان IPTVnator يفتح أول عرض متاح في مساحة العمل أو يستعيد آخر عرض على مستوى القسم. العرض الأول يعني لوحة التحكم عند تفعيلها، وإلا فالمصادر.", "STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متاح", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c4a702eac..770562434 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "صغير", "COVER_SIZE_MEDIUM": "متوسط", "COVER_SIZE_LARGE": "كبير", + "EPG_VIEW_MODE": "عرض الدليل", + "EPG_VIEW_MODE_DESCRIPTION": "اختار كيفاش يبان دليل البرامج تحت المشغل: شريط زمني أفقي ولا لائحة عمودية ديال نهار واحد.", + "EPG_VIEW_MODE_TIMELINE": "الخط الزمني", + "EPG_VIEW_MODE_LIST": "لائحة", "STARTUP_BEHAVIOR": "العرض عند البدء", "STARTUP_BEHAVIOR_DESCRIPTION": "اختار واش IPTVnator يحل أول عرض متوفر في مساحة العمل ولا يسترجع آخر عرض على مستوى القسم. أول عرض كيعني لوحة التحكم إلى كانت مفعلة، إلا فالا فهي Sources.", "STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متوفر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e2142f5e7..85d705a62 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Малы", "COVER_SIZE_MEDIUM": "Сярэдні", "COVER_SIZE_LARGE": "Вялікі", + "EPG_VIEW_MODE": "Выгляд праграмы перадач", + "EPG_VIEW_MODE_DESCRIPTION": "Выберыце, як паказваць праграму перадач пад прайгравальнікам: гарызантальнай стужкай часу або вертыкальным спісам на адзін дзень.", + "EPG_VIEW_MODE_TIMELINE": "Стужка часу", + "EPG_VIEW_MODE_LIST": "Спіс", "STARTUP_BEHAVIOR": "Старт праграмы", "STARTUP_BEHAVIOR_DESCRIPTION": "Выберыце, ці адкрывае IPTVnator першы даступны выгляд працоўнай прасторы, ці аднаўляе ваш апошні выгляд на ўзроўні раздзела. Першы выгляд азначае «Панэль», калі ўключана, інакш «Крыніцы».", "STARTUP_BEHAVIOR_FIRST_VIEW": "Першы даступны выгляд", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 25438869f..03e0b879b 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Klein", "COVER_SIZE_MEDIUM": "Mittel", "COVER_SIZE_LARGE": "Groß", + "EPG_VIEW_MODE": "Programmführer-Ansicht", + "EPG_VIEW_MODE_DESCRIPTION": "Wählen Sie, wie der Programmführer unter dem Player angezeigt wird: als horizontales Zeitleisten-Band oder als vertikale Tagesliste.", + "EPG_VIEW_MODE_TIMELINE": "Zeitleiste", + "EPG_VIEW_MODE_LIST": "Liste", "STARTUP_BEHAVIOR": "Startansicht", "STARTUP_BEHAVIOR_DESCRIPTION": "Legen Sie fest, ob IPTVnator die erste verfügbare Workspace-Ansicht oder Ihre zuletzt geöffnete Abschnittsansicht startet. Erste Ansicht bedeutet Dashboard, wenn aktiviert, sonst Quellen.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Erste verfügbare Ansicht", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index c552f9991..192b67c7a 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Μικρό", "COVER_SIZE_MEDIUM": "Μεσαίο", "COVER_SIZE_LARGE": "Μεγάλο", + "EPG_VIEW_MODE": "Προβολή οδηγού", + "EPG_VIEW_MODE_DESCRIPTION": "Επιλέξτε πώς εμφανίζεται ο οδηγός προγράμματος κάτω από το πρόγραμμα αναπαραγωγής: ως οριζόντια λωρίδα χρονολογίου ή ως κατακόρυφη λίστα μίας ημέρας.", + "EPG_VIEW_MODE_TIMELINE": "Χρονολόγιο", + "EPG_VIEW_MODE_LIST": "Λίστα", "STARTUP_BEHAVIOR": "Προβολή εκκίνησης", "STARTUP_BEHAVIOR_DESCRIPTION": "Επιλέξτε αν το IPTVnator ανοίγει την πρώτη διαθέσιμη προβολή του χώρου εργασίας ή επαναφέρει την τελευταία προβολή σας. Πρώτη προβολή σημαίνει ο πίνακας ελέγχου, όταν είναι ενεργοποιημένος, διαφορετικά οι Πηγές.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Πρώτη διαθέσιμη προβολή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index fcf8eedfa..465ef8138 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Small", "COVER_SIZE_MEDIUM": "Medium", "COVER_SIZE_LARGE": "Large", + "EPG_VIEW_MODE": "Guide view", + "EPG_VIEW_MODE_DESCRIPTION": "Choose how the programme guide under the player is shown: a horizontal timeline ribbon or a vertical, single-day list.", + "EPG_VIEW_MODE_TIMELINE": "Timeline", + "EPG_VIEW_MODE_LIST": "List", "STARTUP_BEHAVIOR": "Startup view", "STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.", "STARTUP_BEHAVIOR_FIRST_VIEW": "First available view", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index ef4d64e29..eecd7a3a0 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Pequeño", "COVER_SIZE_MEDIUM": "Mediano", "COVER_SIZE_LARGE": "Grande", + "EPG_VIEW_MODE": "Vista de la guía", + "EPG_VIEW_MODE_DESCRIPTION": "Elige cómo se muestra la guía de programas bajo el reproductor: una cinta de cronología horizontal o una lista vertical de un solo día.", + "EPG_VIEW_MODE_TIMELINE": "Cronología", + "EPG_VIEW_MODE_LIST": "Lista", "STARTUP_BEHAVIOR": "Vista al iniciar", "STARTUP_BEHAVIOR_DESCRIPTION": "Elige si IPTVnator abre la primera vista disponible del espacio de trabajo o restaura la última vista a nivel de sección. Primera vista significa Panel cuando está activado, de lo contrario Fuentes.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Primera vista disponible", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 16be1d351..cc6466791 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Petite", "COVER_SIZE_MEDIUM": "Moyenne", "COVER_SIZE_LARGE": "Grande", + "EPG_VIEW_MODE": "Vue du guide", + "EPG_VIEW_MODE_DESCRIPTION": "Choisissez comment le guide des programmes sous le lecteur est affiché : une frise chronologique horizontale ou une liste verticale d'une seule journée.", + "EPG_VIEW_MODE_TIMELINE": "Chronologie", + "EPG_VIEW_MODE_LIST": "Liste", "STARTUP_BEHAVIOR": "Vue de démarrage", "STARTUP_BEHAVIOR_DESCRIPTION": "Choisissez si IPTVnator ouvre la première vue disponible de l'espace de travail ou restaure votre dernière vue de section. Première vue signifie Tableau de bord lorsqu'il est activé, sinon Sources.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Première vue disponible", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 77ba6bd57..3e0e80495 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Piccola", "COVER_SIZE_MEDIUM": "Media", "COVER_SIZE_LARGE": "Grande", + "EPG_VIEW_MODE": "Vista guida", + "EPG_VIEW_MODE_DESCRIPTION": "Scegli come mostrare la guida programmi sotto il lettore: una fascia con timeline orizzontale o un elenco verticale di un singolo giorno.", + "EPG_VIEW_MODE_TIMELINE": "Timeline", + "EPG_VIEW_MODE_LIST": "Elenco", "STARTUP_BEHAVIOR": "Vista all'avvio", "STARTUP_BEHAVIOR_DESCRIPTION": "Scegli se IPTVnator apre la prima vista dell'area di lavoro disponibile o ripristina l'ultima vista a livello di sezione. La prima vista è la dashboard se attiva, altrimenti Sorgenti.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Prima vista disponibile", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 05abf58c3..d02b5c4b3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "小", "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", + "EPG_VIEW_MODE": "番組表の表示", + "EPG_VIEW_MODE_DESCRIPTION": "プレーヤー下部の番組表の表示方法を選択します。横方向のタイムライン形式か、縦方向の1日分のリスト形式かを選べます。", + "EPG_VIEW_MODE_TIMELINE": "タイムライン", + "EPG_VIEW_MODE_LIST": "リスト", "STARTUP_BEHAVIOR": "起動時の表示", "STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnatorを起動したときに最初に利用可能なワークスペースビューを開くか、最後に表示していたセクションを復元するかを選択します。「最初のビュー」は、ダッシュボードが有効な場合はダッシュボード、無効な場合はソースを意味します。", "STARTUP_BEHAVIOR_FIRST_VIEW": "最初に利用可能なビュー", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d8f4ddab9..ea5b7d755 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "작게", "COVER_SIZE_MEDIUM": "중간", "COVER_SIZE_LARGE": "크게", + "EPG_VIEW_MODE": "가이드 보기", + "EPG_VIEW_MODE_DESCRIPTION": "플레이어 아래 프로그램 가이드를 표시하는 방식을 선택합니다: 가로 타임라인 리본 또는 하루 단위의 세로 목록입니다.", + "EPG_VIEW_MODE_TIMELINE": "타임라인", + "EPG_VIEW_MODE_LIST": "목록", "STARTUP_BEHAVIOR": "시작 화면", "STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator를 처음 시작할 때 첫 번째로 사용 가능한 작업 공간 화면을 열지, 마지막으로 본 섹션 화면을 복원할지 선택합니다. 첫 번째 화면은 활성화된 경우 대시보드, 그렇지 않으면 소스를 의미합니다.", "STARTUP_BEHAVIOR_FIRST_VIEW": "첫 번째로 사용 가능한 화면", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 820747178..73bd5d709 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Klein", "COVER_SIZE_MEDIUM": "Gemiddeld", "COVER_SIZE_LARGE": "Groot", + "EPG_VIEW_MODE": "Gidsweergave", + "EPG_VIEW_MODE_DESCRIPTION": "Kies hoe de programmagids onder de speler wordt getoond: een horizontale tijdlijn of een verticale lijst voor één dag.", + "EPG_VIEW_MODE_TIMELINE": "Tijdlijn", + "EPG_VIEW_MODE_LIST": "Lijst", "STARTUP_BEHAVIOR": "Opstartweergave", "STARTUP_BEHAVIOR_DESCRIPTION": "Kies of IPTVnator de eerste beschikbare werkruimteweergave opent of je laatste sectieweergave herstelt. Eerste weergave betekent het dashboard wanneer ingeschakeld, anders Bronnen.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Eerste beschikbare weergave", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 536538da8..bc425a666 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Mały", "COVER_SIZE_MEDIUM": "Średni", "COVER_SIZE_LARGE": "Duży", + "EPG_VIEW_MODE": "Widok przewodnika", + "EPG_VIEW_MODE_DESCRIPTION": "Wybierz sposób wyświetlania przewodnika po programach pod odtwarzaczem: pozioma oś czasu lub pionowa lista dla jednego dnia.", + "EPG_VIEW_MODE_TIMELINE": "Oś czasu", + "EPG_VIEW_MODE_LIST": "Lista", "STARTUP_BEHAVIOR": "Widok startowy", "STARTUP_BEHAVIOR_DESCRIPTION": "Wybierz, czy IPTVnator otwiera pierwszy dostępny widok obszaru roboczego, czy przywraca ostatni widok na poziomie sekcji. Pierwszy widok oznacza pulpit, jeśli jest włączony, w przeciwnym razie Źródła.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Pierwszy dostępny widok", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 6a17c720b..cb0b9c1fc 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Pequeno", "COVER_SIZE_MEDIUM": "Médio", "COVER_SIZE_LARGE": "Grande", + "EPG_VIEW_MODE": "Visualização do guia", + "EPG_VIEW_MODE_DESCRIPTION": "Escolha como o guia de programação abaixo do reprodutor é exibido: uma faixa horizontal em linha do tempo ou uma lista vertical de um único dia.", + "EPG_VIEW_MODE_TIMELINE": "Linha do tempo", + "EPG_VIEW_MODE_LIST": "Lista", "STARTUP_BEHAVIOR": "Visualização inicial", "STARTUP_BEHAVIOR_DESCRIPTION": "Escolha se o IPTVnator abre a primeira visualização disponível do espaço de trabalho ou restaura sua última visualização. Primeira visualização significa Dashboard quando ativada, caso contrário, Fontes.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Primeira visualização disponível", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8e33f5544..26b7d37c9 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Маленький", "COVER_SIZE_MEDIUM": "Средний", "COVER_SIZE_LARGE": "Большой", + "EPG_VIEW_MODE": "Вид программы передач", + "EPG_VIEW_MODE_DESCRIPTION": "Выберите, как отображается программа передач под плеером: горизонтальная лента-таймлайн или вертикальный список на один день.", + "EPG_VIEW_MODE_TIMELINE": "Таймлайн", + "EPG_VIEW_MODE_LIST": "Список", "STARTUP_BEHAVIOR": "Начальный экран", "STARTUP_BEHAVIOR_DESCRIPTION": "Выберите, должен ли IPTVnator открывать первый доступный экран рабочего пространства или восстанавливать последний экран уровня раздела. Первый экран означает Dashboard, если он включён, иначе Источники.", "STARTUP_BEHAVIOR_FIRST_VIEW": "Первый доступный экран", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 983481f44..359254d20 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "Küçük", "COVER_SIZE_MEDIUM": "Orta", "COVER_SIZE_LARGE": "Büyük", + "EPG_VIEW_MODE": "Rehber görünümü", + "EPG_VIEW_MODE_DESCRIPTION": "Oynatıcının altındaki program rehberinin nasıl gösterileceğini seçin: yatay bir zaman çizelgesi şeridi veya dikey, tek günlük bir liste.", + "EPG_VIEW_MODE_TIMELINE": "Zaman çizelgesi", + "EPG_VIEW_MODE_LIST": "Liste", "STARTUP_BEHAVIOR": "Başlangıç görünümü", "STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator'un ilk kullanılabilir çalışma alanı görünümünü mü açacağını yoksa bölüm düzeyindeki son görünümünüzü mü geri yükleyeceğini seçin. İlk görünüm, Pano etkinleştirildiğinde Pano, aksi takdirde Kaynaklar anlamına gelir.", "STARTUP_BEHAVIOR_FIRST_VIEW": "İlk kullanılabilir görünüm", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index c867c4c64..57b605e67 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "小", "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", + "EPG_VIEW_MODE": "节目指南视图", + "EPG_VIEW_MODE_DESCRIPTION": "选择播放器下方的节目指南如何显示:横向时间轴条,或纵向的单日列表。", + "EPG_VIEW_MODE_TIMELINE": "时间轴", + "EPG_VIEW_MODE_LIST": "列表", "STARTUP_BEHAVIOR": "启动视图", "STARTUP_BEHAVIOR_DESCRIPTION": "选择 IPTVnator 启动时是打开第一个可用的工作区视图,还是恢复您上次的章节级视图。第一个视图在启用时为仪表盘,否则为「源」。", "STARTUP_BEHAVIOR_FIRST_VIEW": "首个可用视图", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index a88dcda85..2a1049c00 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -292,6 +292,10 @@ "COVER_SIZE_SMALL": "小", "COVER_SIZE_MEDIUM": "中", "COVER_SIZE_LARGE": "大", + "EPG_VIEW_MODE": "節目表檢視方式", + "EPG_VIEW_MODE_DESCRIPTION": "選擇播放器下方節目表的顯示方式:橫向時間軸條,或縱向的單日清單。", + "EPG_VIEW_MODE_TIMELINE": "時間軸", + "EPG_VIEW_MODE_LIST": "清單", "STARTUP_BEHAVIOR": "啟動畫面", "STARTUP_BEHAVIOR_DESCRIPTION": "選擇 IPTVnator 啟動時要開啟第一個可用的工作區畫面,或還原您上次瀏覽的區段層級畫面。第一個畫面在啟用儀表板時為儀表板,否則為「來源」。", "STARTUP_BEHAVIOR_FIRST_VIEW": "第一個可用畫面", diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index be56e4055..aa79be91b 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -266,16 +266,33 @@ per-tab EPG logic: favorites). Callers read their `progressTick()` signal first so the computed re-runs on the ~30s tick. -### EPG Timeline Panel - -The programme guide under the player is a horizontal **timeline ribbon** -(`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`) shared by all four live -surfaces: the M3U video player, the unified live tab, and the Xtream and Stalker -live-stream layouts. It replaces the former vertical `app-epg-list` / -`app-epg-view`. - -The component stays presentation-focused; the reusable, view-agnostic pieces -(so a future list view can share them) are split out and re-exported from +### EPG Panel (Timeline & List views) + +The programme guide under the player renders in one of **two interchangeable +views**, chosen by the **`epgViewMode`** setting (`'timeline'` default, or +`'list'`; Settings → EPG → *Guide view*): + +- **Timeline** — a horizontal **ribbon** (`app-epg-timeline`, + `libs/ui/epg/src/lib/epg-timeline/`). +- **List** — a vertical, single-day **programme list** (`app-epg-list-view`, + `libs/ui/epg/src/lib/epg-list-view/`) with a prev/today/next stepper. + +Both are shared by all four live surfaces: the M3U video player, the unified live +tab, and the Xtream and Stalker live-stream layouts (replacing the former +vertical `app-epg-list` / `app-epg-view`). `EpgListViewComponent` mirrors +`EpgTimelineComponent`'s input/output contract **1:1**, so each host swaps them +with a plain `@if (epgViewMode() === 'list') { } @else { + }` — identical bindings in both branches. Hosts read +`epgViewMode` from `SettingsStore` (a signal), so flipping the setting swaps the +panel live. The setting flows end-to-end (`Settings.epgViewMode` → +`DEFAULT_SETTINGS` → `SettingsStore`/`StorageMap` → the segmented control in +`settings-epg-section`) and needs no backend change. The control is +**Electron-only in practice** — the EPG settings section (and the form control) +is gated behind `supportsEpg`, which is false in PWA; there the stored value +simply stays at the `'timeline'` default. + +Both components stay presentation-focused; the reusable, view-agnostic pieces +(shared by the timeline and the list) are split out and re-exported from `@iptvnator/ui/epg`: - `epg-timeline.utils.ts` (axis/blocks/date helpers) + `epg-timeline-render.util.ts` @@ -290,6 +307,19 @@ The component stays presentation-focused; the reusable, view-agnostic pieces scrolling + channel-select auto-focus); timeline-specific, kept out of the component so it stays under the line ceiling. +The **list view** (`epg-list-view/`) composes those same shared modules — it does +**not** duplicate classification or gating logic. It reuses `classifyTimelineWhen` +/ `hasProgramsForDateKey` / `nearestDateKeyWithPrograms`, `epg-archive.util`, +`epg-summary.util`, the `epg-date` helpers, `EpgProgrammeDialogService`, and the +shared `app-epg-timeline-empty-state` — and drops all ribbon geometry, zoom, and +horizontal scroll. It filters the loaded window to the selected day (overlap-based, +matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure +`buildEpgListRows` (`epg-list-view.utils.ts`); renders each row through the dumb +`app-epg-list-view-row`; and delegates its own vertical auto-focus + sticky +"now" strip to `EpgListScrollController` (`epg-list-scroll.controller.ts`). Render +states, the collapsed inline summary, the date stepper, catch-up/timeshift +activation, and the details dialog behave identically to the timeline. + - **One channel, preloaded window.** The panel always shows a single channel. Each provider returns a multi-day window in roughly one call (M3U `GET_CHANNEL_PROGRAMS`; Stalker `get_epg_info`; Xtream `get_simple_data_table`), @@ -370,7 +400,11 @@ The component stays presentation-focused; the reusable, view-agnostic pieces tag / "Watch") stays pinned at the bottom. With an inline player the guide is a compact panel (`.epg.epg--inline` → `flex: 0 0 clamp(180px, 36vh, 264px)` in `_portal-layout.scss`) so the player stays dominant; with an external - player the guide keeps `flex: 1` and fills the whole content area. + player the guide keeps `flex: 1` and fills the whole content area. In **list + mode** the hosts also set `.epg--list`, which raises only the inline clamp + (`--epg-inline-height: clamp(280px, 46vh, 430px)`) — vertical rows need more + height than the ribbon; the timeline height is unchanged, and the collapsed + 56px clamp still wins because the modifier sets just the CSS variable. - **Wide-tier description preview.** When a block is the `wide` tier (rendered width ≥ `132px`, i.e. long programmes and/or zoomed in) **and** the programme has a `desc`, a dimmed (`--text-secondary`) preview of the description renders diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index fad6768b3..4d9d98dd8 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -94,32 +94,65 @@
- + @if (epgViewMode() === 'list') { + + } @else { + + }
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index bca91c1f5..0ebd8193d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -121,8 +121,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: '', }) @@ -189,6 +191,7 @@ describe('VideoPlayerComponent', () => { const player = signal(VideoPlayer.VideoJs); const showCaptions = signal(false); + const epgViewMode = signal<'timeline' | 'list'>('timeline'); const originalElectron = window.electron; const overlayRef = { @@ -386,6 +389,7 @@ describe('VideoPlayerComponent', () => { useValue: { player, showCaptions, + epgViewMode, }, }, { @@ -469,6 +473,29 @@ describe('VideoPlayerComponent', () => { ).not.toBeNull(); }); + it('swaps the timeline for the list view when epgViewMode is "list"', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + epgViewMode.set('list'); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-epg-list-view') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); + // Taller inline panel for the list view (see _portal-layout.scss). + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg--list') + ).toBe(true); + + epgViewMode.set('timeline'); // restore for sibling tests + }); + it('hides EPG controls and the multi-EPG header action in browser/PWA playback', () => { fixture.destroy(); window.electron = undefined as unknown as typeof window.electron; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index edda8c7f4..4e8543f92 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -29,6 +29,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { COMPONENT_OVERLAY_REF, EpgDateNavigationDirection, + EpgListViewComponent, EpgProgramActivationEvent, EpgTimelineComponent, getTodayEpgDateKey, @@ -122,6 +123,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460; AudioPlayerComponent, ChannelListLoadingStateComponent, CommonModule, + EpgListViewComponent, EpgTimelineComponent, MatButtonModule, MatIconModule, @@ -273,6 +275,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { restoreLiveEpgPanelState() ); readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = computed( + () => this.settingsStore.epgViewMode?.() ?? 'timeline' + ); readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 5f42b7ef3..edc6f56b2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -73,25 +73,53 @@
- + @if (epgViewMode() === 'list') { + + } @else { + + }
} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index a053e3579..980d763c4 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -17,6 +17,7 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { + EpgListViewComponent, EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, @@ -78,8 +79,10 @@ class StubGlobalFavoritesListComponent { readonly removeRequested = 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', template: '
', changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -127,6 +130,7 @@ describe('UnifiedLiveTabComponent', () => { let fixture: ComponentFixture; let component: UnifiedLiveTabComponent; let player: ReturnType>; + let epgViewMode: ReturnType>; let streamResolver: { resolveLiveDetail: jest.Mock; resolveM3uPlaybackDetail: jest.Mock; @@ -160,6 +164,7 @@ describe('UnifiedLiveTabComponent', () => { recordLivePlayback: jest.fn(), }; player = signal(VideoPlayer.VideoJs); + epgViewMode = signal<'timeline' | 'list'>('timeline'); portalPlayer = { isEmbeddedPlayer: jest.fn().mockReturnValue(false), openResolvedPlayback: jest.fn(), @@ -184,6 +189,7 @@ describe('UnifiedLiveTabComponent', () => { useValue: { openStreamOnDoubleClick: signal(false), player, + epgViewMode, }, }, { provide: PORTAL_PLAYER, useValue: portalPlayer }, @@ -193,6 +199,7 @@ describe('UnifiedLiveTabComponent', () => { remove: { imports: [ AudioPlayerComponent, + EpgListViewComponent, EpgTimelineComponent, GlobalFavoritesListComponent, ResizableDirective, @@ -447,6 +454,44 @@ describe('UnifiedLiveTabComponent', () => { expect(timeline.selectedDate()).toBe(nextDate); }); + it('swaps the timeline for the list view when epgViewMode is "list"', async () => { + epgViewMode.set('list'); + const item = buildLiveItem('xtream'); + streamResolver.resolveLiveDetail.mockResolvedValue({ + epgMode: 'portal', + playback: { + streamUrl: 'https://example.com/xtream.m3u8', + title: 'Xtream Live', + }, + epgItems: [buildCurrentEpgItem('Xtream Now')], + }); + recentData.recordLivePlayback.mockResolvedValue({ + ...item, + viewedAt: '2026-03-26T12:00:00.000Z', + }); + + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + + await component.onChannelSelected(component.channelsForList()[0]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect( + fixture.nativeElement.querySelector('app-epg-list-view') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); + // Taller inline panel for the list view (see _portal-layout.scss). + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg--list') + ).toBe(true); + }); + it('renders inline portal EPG in the timeline and flows collapse state through', async () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(true); const item = buildLiveItem('xtream'); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 005f59f05..a582efa81 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -36,6 +36,7 @@ import { } from '@iptvnator/portal/shared/data-access'; import { EpgDateNavigationDirection, + EpgListViewComponent, EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, @@ -59,6 +60,7 @@ import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AudioPlayerComponent, + EpgListViewComponent, EpgTimelineComponent, GlobalFavoritesListComponent, MatButtonModule, @@ -191,6 +193,10 @@ export class UnifiedLiveTabComponent { readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = computed( + () => this.settingsStore.epgViewMode?.() ?? 'timeline' + ); readonly liveEpgPanelSummary = computed(() => { this.progressTick(); return this.getLiveEpgPanelSummary(this.activeDetail()); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 9736602cd..876213d48 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -141,6 +141,7 @@

- + @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..8b92f50d8 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), + epgViewMode: signal<'timeline' | 'list'>('timeline'), }; const originalElectron = window.electron; beforeEach(async () => { + // The store mock is module-scoped: reset so a failed test can't leak + // 'list' into siblings. + settingsStore.epgViewMode.set('timeline'); window.electron = { platform: 'darwin', updateRemoteControlStatus: jest.fn(), @@ -341,6 +350,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ ChannelListItemComponent, AudioPlayerComponent, + EpgListViewComponent, EpgTimelineComponent, PortalEmptyStateComponent, ResizableDirective, @@ -386,6 +396,27 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBeNull(); }); + it('swaps the timeline for the list view when epgViewMode is "list"', () => { + settingsStore.epgViewMode.set('list'); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-epg-list-view') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); + // Taller inline panel for the list view (see _portal-layout.scss). + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg--list') + ).toBe(true); + + settingsStore.epgViewMode.set('timeline'); // restore for sibling tests + }); + it('does not request or render EPG in browser/PWA playback', async () => { fixture.destroy(); window.electron = undefined as unknown as typeof window.electron; 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..1dee75e43 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,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = computed( + () => this.settingsStore.epgViewMode?.() ?? 'timeline' + ); readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary(this.currentProgram()) 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 @@

All Items

- + @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..3a2919016 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. + epgViewMode: signal<'timeline' | 'list'>('timeline'), }; const originalElectron = window.electron; @@ -202,6 +208,7 @@ describe('LiveStreamLayoutComponent', () => { beforeEach(async () => { jest.useFakeTimers(); jest.setSystemTime(fixedNow); + settingsStore.epgViewMode.set('timeline'); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY); @@ -301,6 +308,7 @@ describe('LiveStreamLayoutComponent', () => { .overrideComponent(LiveStreamLayoutComponent, { remove: { imports: [ + EpgListViewComponent, EpgTimelineComponent, GridListComponent, PortalChannelsListComponent, @@ -359,6 +367,28 @@ describe('LiveStreamLayoutComponent', () => { ).not.toBeNull(); }); + it('swaps the timeline for the list view when epgViewMode is "list"', () => { + settingsStore.epgViewMode.set('list'); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-epg-list-view') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); + // Taller inline panel for the list view (see _portal-layout.scss). + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg--list') + ).toBe(true); + + settingsStore.epgViewMode.set('timeline'); // restore for sibling tests + }); + it('hides the EPG panel in browser/PWA playback', () => { fixture.destroy(); window.electron = undefined as unknown as typeof window.electron; 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..cd2cbe75c 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,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = computed( + () => this.settingsStore.epgViewMode?.() ?? 'timeline' + ); readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary( diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index c5693c0b3..a0349af47 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -43,6 +43,7 @@ const DEFAULT_SETTINGS: Settings = { downloadFolder: '', recordingFolder: '', coverSize: 'medium', + epgViewMode: 'timeline', dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS, preferUploadedEpgOverXtream: false, trustedPrivateNetworkEpgUrls: [], @@ -173,6 +174,8 @@ export const SettingsStore = signalStore( store.recordingFolder?.() ?? DEFAULT_SETTINGS.recordingFolder, coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize, + epgViewMode: + store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode, dashboardRails: normalizeDashboardRailsSettings( store.dashboardRails?.() ), 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..7098eca37 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-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-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..056545c61 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.spec.ts @@ -0,0 +1,190 @@ +import { EpgListScrollController } from './epg-list-scroll.controller'; +import { EpgListRow } from './epg-list-view.utils'; + +function rowAt(when: EpgListRow['when'], key = `${when}-row`): EpgListRow { + const startMs = Date.now(); + return { + program: { + start: new Date(startMs).toISOString(), + stop: new Date(startMs + 60 * 60_000).toISOString(), + channel: 'ch', + title: 'P', + desc: null, + category: null, + }, + key, + startMs, + stopMs: startMs + 60 * 60_000, + when, + progress: when === 'now' ? 50 : null, + isActive: false, + canCatchUp: false, + }; +} + +describe('EpgListScrollController (channel-select auto-scroll)', () => { + let controller: EpgListScrollController; + let scrollSpy: jest.SpyInstance; + let rafSpy: jest.SpyInstance; + + beforeEach(() => { + rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + controller = new EpgListScrollController({ + list: () => undefined, + isViewToday: () => true, + setNowStripVisible: () => undefined, + }); + scrollSpy = jest + .spyOn(controller, 'scrollNowIntoView') + .mockImplementation(() => undefined); + jest.spyOn(controller, 'updateNowStrip').mockImplementation( + () => undefined + ); + }); + + afterEach(() => { + rafSpy.mockRestore(); + }); + + it('scrolls the now row into view instantly on first load', () => { + controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], true, 'ch'); + + expect(scrollSpy).toHaveBeenCalledTimes(1); + expect(scrollSpy).toHaveBeenCalledWith(false); // instant, no animation + }); + + it('does not re-scroll while the same channel stays loaded (now-tick / re-emit)', () => { + const list = {} as HTMLElement; + const rows = [rowAt('now')]; + controller.maybeAutoScroll(list, rows, true, 'ch'); + controller.maybeAutoScroll(list, rows, true, 'ch'); + + expect(scrollSpy).toHaveBeenCalledTimes(1); + // The dedup path still refreshes the now-strip — layout can change + // without scroll events (e.g. the panel grew and nothing scrolls). + expect(controller.updateNowStrip).toHaveBeenCalled(); + }); + + it('restores the now row when the same channel list remounts (collapse then expand)', () => { + const rows = [rowAt('now')]; + controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // mount + controller.maybeAutoScroll(undefined, rows, true, 'ch'); // collapsed + controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // expand + + expect(scrollSpy).toHaveBeenCalledTimes(2); + }); + + it('does not re-scroll when the on-air programme rolls over within the same set', () => { + // The 30s tick reclassifies `when` at every programme boundary; the + // programme SET is unchanged, so the viewport must stay put. + const list = {} as HTMLElement; + const a = rowAt('now', 'a'); + const b = { + ...rowAt('future', 'b'), + startMs: a.stopMs, + stopMs: a.stopMs + 3_600_000, + }; + controller.maybeAutoScroll(list, [a, b], true, 'ch'); + + const rolled = [ + { ...a, when: 'past' as const }, + { ...b, when: 'now' as const }, + ]; + controller.maybeAutoScroll(list, rolled, true, 'ch'); + + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + + it('re-scrolls when the channel changes', () => { + const list = {} as HTMLElement; + controller.maybeAutoScroll(list, [rowAt('now', 'a')], true, 'alpha'); + controller.maybeAutoScroll(list, [rowAt('now', 'b')], true, 'beta'); + + expect(scrollSpy).toHaveBeenCalledTimes(2); + }); + + it('leaves a non-today day alone (no snap back to now)', () => { + controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], false, 'ch'); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); + + it('does nothing without an on-air row or a mounted list', () => { + controller.maybeAutoScroll({} as HTMLElement, [rowAt('past')], true, 'ch'); + controller.maybeAutoScroll(undefined, [rowAt('now')], true, 'ch'); + + expect(scrollSpy).not.toHaveBeenCalled(); + }); +}); + +describe('EpgListScrollController (now-strip visibility)', () => { + function stripStateFor(list: Partial): boolean | null { + let visible: boolean | null = null; + const controller = new EpgListScrollController({ + list: () => list as HTMLElement, + isViewToday: () => true, + setNowStripVisible: (value) => (visible = value), + }); + controller.updateNowStrip(); + return visible; + } + + /** Row 500–560px below the list's viewport top (out of a 0–200 view). */ + const offscreenRow = { + getBoundingClientRect: () => ({ top: 500, bottom: 560, height: 60 }), + }; + + it('hides the strip when the list does not scroll (nothing can be scrolled away)', () => { + expect( + stripStateFor({ + scrollHeight: 200, + clientHeight: 200, + getBoundingClientRect: () => ({ top: 0, bottom: 200 }), + querySelector: () => offscreenRow, + } as unknown as HTMLElement) + ).toBe(false); + }); + + it('shows the strip when the on-air row is scrolled out of view', () => { + expect( + stripStateFor({ + scrollHeight: 600, + clientHeight: 200, + getBoundingClientRect: () => ({ top: 0, bottom: 200 }), + querySelector: () => offscreenRow, + } as unknown as HTMLElement) + ).toBe(true); + }); +}); + +describe('EpgListScrollController (scroll target maths)', () => { + it('scrolls relative to the list scroller, not the offset parent', () => { + // Regression: the rows have no positioned ancestor inside the list, so + // offsetTop-based maths measured against a far ancestor (including the + // player above) and scrolled to a wildly wrong position. + const scrollTo = jest.fn(); + const list = { + scrollTop: 100, + scrollTo, + getBoundingClientRect: () => ({ top: 50 }), + querySelector: () => ({ + getBoundingClientRect: () => ({ top: 350 }), + }), + } as unknown as HTMLElement; + const controller = new EpgListScrollController({ + list: () => list, + isViewToday: () => true, + setNowStripVisible: () => undefined, + }); + + controller.scrollNowIntoView(false); + + // 100 (current scroll) + (350 − 50) (row offset within view) − 8 + expect(scrollTo).toHaveBeenCalledWith({ top: 392, behavior: 'auto' }); + }); +}); 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..6583fe783 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-scroll.controller.ts @@ -0,0 +1,144 @@ +import { EpgListRow } from './epg-list-view.utils'; + +export interface EpgListScrollDeps { + /** The scrollable `.g-list` element (undefined before first render). */ + readonly list: () => HTMLElement | undefined; + /** Whether the viewed day is today (auto-focus/now-strip are today-only). */ + readonly isViewToday: () => boolean; + /** Toggle the sticky now-strip's visibility. */ + readonly setNowStripVisible: (visible: boolean) => void; +} + +/** + * Vertical-list scroll behaviour extracted from the component: auto-focus the + * on-air row when a channel's EPG (re)loads, keep the sticky "now" strip in sync + * with scroll position, and reset scroll on day changes. The vertical analogue + * of the timeline's `TimelineScrollController`. + */ +export class EpgListScrollController { + private autoScrollKey: string | null = null; + private lastList: HTMLElement | null = null; + + constructor(private readonly deps: EpgListScrollDeps) {} + + /** + * Scroll the on-air row into view once per channel/EPG load, today only. + * A *new* list element under an unchanged key means the body was unmounted + * and remounted (the inline panel was collapsed and re-expanded), which + * resets scrollTop to 0 — restore the now-row instead of stranding the user + * at the top of the day. The same element (data re-emit / 30s now-tick) is + * left alone so the viewport is never yanked out from under the user. + */ + maybeAutoScroll( + list: HTMLElement | undefined, + rows: EpgListRow[], + today: boolean, + channel: string + ): void { + if (!list || !today) { + return; + } + const now = rows.find((row) => row.when === 'now'); + if (!now) { + return; + } + // Programme-SET identity (like the timeline's `programsFocusKey`), NOT + // the on-air row's key: the latter changes at every programme rollover + // (the 30s tick reclassifies `when`), which would re-trigger the scroll + // and yank the viewport away from wherever the user scrolled to. + const first = rows[0]; + const last = rows[rows.length - 1]; + const key = `${channel}|${rows.length}|${first.startMs}|${last.stopMs}`; + if (key === this.autoScrollKey) { + if (list !== this.lastList) { + this.lastList = list; + this.focusNowAfterRender(); + } else { + // No scroll — but keep the now-strip honest: layout can change + // without scroll events (panel resize, rows re-render), and a + // non-scrollable list never fires scroll to self-correct. + this.updateNowStrip(); + } + return; + } + this.autoScrollKey = key; + this.lastList = list; + this.focusNowAfterRender(); + } + + /** + * Scroll to the now-row after the next render. Public for the toolbar + * "Now" jump: committing today re-renders the rows, so a synchronous DOM + * query would still see the previous day's rows and find no now-row — + * the deferral lets change detection paint today first. + */ + focusNowAfterRender(animate = false): void { + requestAnimationFrame(() => + requestAnimationFrame(() => { + this.scrollNowIntoView(animate); + this.updateNowStrip(); + }) + ); + } + + scrollNowIntoView(animate: boolean): void { + const list = this.deps.list(); + const nowRow = this.nowRowElement(list); + if (list && nowRow) { + // Rect-based, relative to the scroller itself: `offsetTop` is + // measured against the nearest *positioned* ancestor, which is + // outside the list (the rows/list are not positioned), so it would + // include the player above and scroll to a wildly wrong spot. + const listRect = list.getBoundingClientRect(); + const rowRect = nowRow.getBoundingClientRect(); + const top = list.scrollTop + (rowRect.top - listRect.top) - 8; + list.scrollTo({ + top: Math.max(0, top), + behavior: animate ? 'smooth' : 'auto', + }); + } + } + + resetListScroll(): void { + const list = this.deps.list(); + if (list) { + list.scrollTop = 0; + } + this.deps.setNowStripVisible(false); + } + + /** Show the condensed now-strip only when the on-air row is scrolled away. */ + updateNowStrip(): void { + const list = this.deps.list(); + const nowRow = this.nowRowElement(list); + if (!list || !nowRow || !this.deps.isViewToday()) { + this.deps.setNowStripVisible(false); + return; + } + // A list that doesn't scroll can't have scrolled the row away — and it + // also never fires scroll events to clear a stale strip. + if (list.scrollHeight <= list.clientHeight) { + this.deps.setNowStripVisible(false); + return; + } + // Rect-based for the same reason as scrollNowIntoView. + const listRect = list.getBoundingClientRect(); + const rowRect = nowRow.getBoundingClientRect(); + if (rowRect.height <= 0) { + this.deps.setNowStripVisible(false); + return; + } + const visible = + Math.min(rowRect.bottom, listRect.bottom) - + Math.max(rowRect.top, listRect.top); + this.deps.setNowStripVisible(visible / rowRect.height < 0.5); + } + + private nowRowElement( + list: HTMLElement | undefined + ): HTMLElement | null { + return ( + list?.querySelector('.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) { + + } +
+ @for (row of rows(); track row.key) { + + } +
+
+ } + @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..e46470475 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts @@ -0,0 +1,304 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + inject, + input, + linkedSignal, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; +import { + EpgDateNavigationDirection, + getTodayEpgDateKey, + parseEpgDateKey, + shiftEpgDateKey, +} from '../epg-date'; +import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component'; +import type { EpgProgramActivationEvent } from '../epg-list/epg-list.component'; +import { EpgProgrammeDialogService } from '../epg-programme-dialog.service'; +import { epgDialogActionFor } from '../epg-timeline/epg-archive.util'; +import { + EpgTimelineSummary, + summaryHasTimeRange, + summaryHasTitle, + summaryMinutesLeft, + summaryProgress, +} from '../epg-timeline/epg-summary.util'; +import { + EpgTimelineEmptyReason, + EpgTimelineEmptyStateComponent, +} from '../epg-timeline/epg-timeline-empty-state.component'; +import { + hasProgramsForDateKey, + nearestDateKeyWithPrograms, +} from '../epg-timeline/epg-timeline.utils'; +import { EpgListScrollController } from './epg-list-scroll.controller'; +import { EpgListViewRowComponent } from './epg-list-view-row/epg-list-view-row.component'; +import { buildEpgListRows, EpgListRow } from './epg-list-view.utils'; + +type RenderState = 'loading' | 'list' | EpgTimelineEmptyReason; + +/** + * Vertical, single-day EPG list — a drop-in alternative to `app-epg-timeline` + * with an identical controlled input/output contract, so hosts swap the two + * with a plain `@if`. Reuses the timeline's view-agnostic modules (archive + * gating, summary maths, date helpers, empty-state, details dialog); drops all + * ribbon geometry, zoom, and horizontal scroll. + */ +@Component({ + selector: 'app-epg-list-view', + templateUrl: './epg-list-view.component.html', + styleUrl: './epg-list-view.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DatePipe, + EpgListViewRowComponent, + EpgTimelineEmptyStateComponent, + MatIcon, + MatTooltip, + TranslatePipe, + ], +}) +export class EpgListViewComponent { + readonly programs = input([]); + 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), + }); + + constructor() { + // 30s tick reclassifies past/now/future and refreshes progress. This is + // a controlled component (activeProgram/isLivePlayback come from the + // host), so the tick never clobbers active archive playback. + effect((onCleanup) => { + const intervalId = window.setInterval( + () => this.nowMs.set(Date.now()), + 30_000 + ); + onCleanup(() => clearInterval(intervalId)); + }); + + // Auto-focus the on-air row when a channel's EPG (re)loads or the list + // (re)mounts (collapse → expand) while viewing today — the vertical + // analogue of the ribbon's auto-focus. Tracks the `list` viewChild so a + // remount re-triggers; deduped inside the controller so 30s ticks and + // data re-emits don't re-jump. + effect(() => { + const list = this.list()?.nativeElement; + const rows = this.rows(); + const today = this.isViewToday(); + const channel = this.channelName(); + untracked(() => + this.scroll.maybeAutoScroll(list, rows, today, channel) + ); + }); + } + + toggleCollapsed(): void { + this.collapsedChange.emit(!this.collapsed()); + } + + stepDay(direction: EpgDateNavigationDirection): void { + this.commitDay(shiftEpgDateKey(this.viewDayKey(), direction)); + this.scroll.resetListScroll(); + } + + jumpToNow(): void { + this.commitDay(getTodayEpgDateKey()); + // Deferred: when jumping from another day, today's rows only exist + // after the next render. Deliberate user action → animate. + this.scroll.focusNowAfterRender(true); + } + + jumpToNearestDay(): void { + const nearest = nearestDateKeyWithPrograms( + this.programs(), + this.nowMs() + ); + if (nearest) { + this.commitDay(nearest); + this.scroll.resetListScroll(); + } + } + + onRowActivate(row: EpgListRow): void { + this.selectedKey.set(row.key); + if (row.when === 'now') { + this.returnToLive.emit(); + return; + } + if (row.canCatchUp) { + this.programActivated.emit({ + program: row.program, + type: 'timeshift', + }); + } + } + + onWatch(row: EpgListRow): void { + this.selectedKey.set(row.key); + this.programActivated.emit({ program: row.program, type: 'timeshift' }); + } + + openDetails(row: EpgListRow): void { + this.programmeDialog + .open({ + ...row.program, + channelName: this.channelName(), + channelLogo: this.channelLogo(), + primaryAction: epgDialogActionFor(row.when, row.canCatchUp), + archiveUnavailableNote: + row.when === 'past' && !this.archivePlaybackAvailable(), + }) + .subscribe((result: EpgItemDialogAction | undefined) => { + if (result === 'live') { + this.returnToLive.emit(); + } else if (result === 'timeshift') { + this.selectedKey.set(row.key); + this.programActivated.emit({ + program: row.program, + type: 'timeshift', + }); + } + }); + } + + onListScroll(): void { + this.scroll.updateNowStrip(); + } + + private commitDay(dayKey: string): void { + if (dayKey === this.viewDayKey()) { + return; + } + this.viewDayKey.set(dayKey); + this.selectedDateChange.emit(dayKey); + } +} 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..50d90dd6a --- /dev/null +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.spec.ts @@ -0,0 +1,137 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { buildEpgListRows } from './epg-list-view.utils'; + +// Anchor "now" at local noon so ±3h fixtures stay inside the same local day, +// keeping the overlap-based day filter deterministic regardless of run time. +const NOON = new Date(); +NOON.setHours(12, 0, 0, 0); +const NOW = NOON.getTime(); + +function programAt( + startOffsetMin: number, + durationMin: number, + title = 'P', + extra: Partial = {} +): EpgProgram { + const start = new Date(NOW + startOffsetMin * 60_000); + const stop = new Date(start.getTime() + durationMin * 60_000); + return { + start: start.toISOString(), + stop: stop.toISOString(), + channel: 'ch', + title, + desc: null, + category: null, + ...extra, + }; +} + +function dayKey(ms: number): string { + const d = new Date(ms); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +const TODAY = dayKey(NOW); +const DEFAULT_OPTS = { + archivePlaybackAvailable: false, + archiveDays: 0, + activeProgram: null, +}; + +describe('buildEpgListRows', () => { + it('keeps only programmes overlapping the selected day, sorted by start', () => { + const rows = buildEpgListRows( + [ + programAt(90, 60, 'Later'), + programAt(-30, 60, 'Now'), + programAt(3 * 1440, 60, 'ThreeDaysOut'), + ], + TODAY, + NOW, + DEFAULT_OPTS + ); + + expect(rows.map((r) => r.program.title)).toEqual(['Now', 'Later']); + }); + + it('classifies past / now / future relative to nowMs', () => { + const rows = buildEpgListRows( + [ + programAt(-180, 60, 'Past'), + programAt(-30, 60, 'Now'), + programAt(120, 60, 'Future'), + ], + TODAY, + NOW, + DEFAULT_OPTS + ); + + expect(rows.map((r) => r.when)).toEqual(['past', 'now', 'future']); + }); + + it('computes live progress only for the now row', () => { + const rows = buildEpgListRows( + [programAt(-30, 60, 'Now'), programAt(120, 60, 'Future')], + TODAY, + NOW, + DEFAULT_OPTS + ); + + const now = rows.find((r) => r.when === 'now'); + const future = rows.find((r) => r.when === 'future'); + expect(now?.progress).toBeGreaterThan(40); + expect(now?.progress).toBeLessThan(60); + expect(future?.progress).toBeNull(); + }); + + it('gates catch-up on archive availability + window', () => { + const past = [programAt(-180, 60, 'Past')]; + + const withArchive = buildEpgListRows(past, TODAY, NOW, { + archivePlaybackAvailable: true, + archiveDays: 7, + activeProgram: null, + }); + expect(withArchive[0].canCatchUp).toBe(true); + + const noArchive = buildEpgListRows(past, TODAY, NOW, DEFAULT_OPTS); + expect(noArchive[0].canCatchUp).toBe(false); + }); + + it('marks the active programme', () => { + const active = programAt(-180, 60, 'Past'); + const rows = buildEpgListRows( + [active, programAt(-30, 60, 'Now')], + TODAY, + NOW, + { + archivePlaybackAvailable: true, + archiveDays: 7, + activeProgram: active, + } + ); + + expect(rows.find((r) => r.program.title === 'Past')?.isActive).toBe( + true + ); + expect(rows.find((r) => r.program.title === 'Now')?.isActive).toBe( + false + ); + }); + + it('deduplicates programmes sharing a time slot, keeping the richer one', () => { + const rows = buildEpgListRows( + [ + programAt(-30, 60, 'Now'), + programAt(-30, 60, 'Now', { desc: 'With description' }), + ], + TODAY, + NOW, + DEFAULT_OPTS + ); + + expect(rows).toHaveLength(1); + expect(rows[0].program.desc).toBe('With description'); + }); +}); 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..9c9162a6c --- /dev/null +++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.utils.ts @@ -0,0 +1,117 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { addDays } from 'date-fns'; +import { parseEpgDateKey } from '../epg-date'; +import { + areProgramsSame, + deduplicateProgramsByTimeSlot, + getProgramTimeMs, +} from '../epg-list/epg-list.utils'; +import { canCatchUpProgramme } from '../epg-timeline/epg-archive.util'; +import { + classifyTimelineWhen, + TimelineWhen, +} from '../epg-timeline/epg-timeline.utils'; + +/** + * One vertical list row. View-agnostic classification/gating is precomputed so + * the row component stays dumb and the list re-derives everything off the 30s + * `nowMs` tick. Mirrors the timeline's `TimelineBlock` semantics without the + * ribbon geometry (offset/duration in px). + */ +export interface EpgListRow { + readonly program: EpgProgram; + readonly key: string; + readonly startMs: number; + readonly stopMs: number; + readonly when: TimelineWhen; + /** Live progress 0–100 for the `now` row, else null. */ + readonly progress: number | null; + /** Matches the host's active programme (archive playback highlight). */ + readonly isActive: boolean; + /** Past programme playable from the catch-up archive. */ + readonly canCatchUp: boolean; +} + +export interface BuildEpgListRowsOptions { + readonly archivePlaybackAvailable: boolean; + readonly archiveDays: number; + readonly activeProgram: EpgProgram | null; +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function liveProgress( + when: TimelineWhen, + startMs: number, + stopMs: number, + nowMs: number +): number | null { + if (when !== 'now' || stopMs <= startMs) { + return null; + } + return clampPercent(((nowMs - startMs) / (stopMs - startMs)) * 100); +} + +/** + * Programmes overlapping the given local day, sorted by start and deduplicated. + * Overlap-based (not start-date-only) so a cross-midnight programme airing now + * still shows for *today* — matching `hasProgramsForDateKey`, which the list's + * render-state uses. A start-date filter would drop the on-air programme and + * leave an empty list while the state says `list`. + */ +export function buildEpgListRows( + programs: readonly EpgProgram[], + selectedDateKey: string, + nowMs: number, + options: BuildEpgListRowsOptions +): EpgListRow[] { + const dayStart = parseEpgDateKey(selectedDateKey); + const dayStartMs = dayStart.getTime(); + const dayEndMs = addDays(dayStart, 1).getTime(); // exclusive + + const forDay = programs.filter((program) => { + const startMs = getProgramTimeMs(program.start, program.startTimestamp); + const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + if (!Number.isFinite(startMs) || !Number.isFinite(stopMs)) { + return false; + } + return startMs < dayEndMs && stopMs > dayStartMs; + }); + + const deduped = deduplicateProgramsByTimeSlot( + [...forDay].sort( + (left, right) => + getProgramTimeMs(left.start, left.startTimestamp) - + getProgramTimeMs(right.start, right.startTimestamp) + ) + ); + + const activeProgram = options.activeProgram; + + return deduped.map((program, index) => { + const startMs = getProgramTimeMs(program.start, program.startTimestamp); + const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + const when = classifyTimelineWhen(startMs, stopMs, nowMs); + + return { + program, + key: `${startMs}-${stopMs}-${index}`, + startMs, + stopMs, + when, + progress: liveProgress(when, startMs, stopMs, nowMs), + isActive: activeProgram + ? areProgramsSame(program, activeProgram) + : false, + canCatchUp: canCatchUpProgramme( + when, + startMs, + options.archivePlaybackAvailable, + options.archiveDays, + nowMs + ), + }; + }); +} diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index 7293d319d..456d7a1c4 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -133,13 +133,21 @@ flex: 0 0 var(--epg-inline-height, clamp(180px, 36vh, 264px)); } + // Vertical list view (epgViewMode = 'list'): rows need more height to + // be useful — raise the inline clamp only. Sets just the variable, so + // the later `.epg-collapsed` flex rule still wins when collapsed. + &.epg--list { + --epg-inline-height: clamp(280px, 46vh, 430px); + } + &.epg-collapsed { flex: 0 0 56px; min-height: 56px; } > app-live-epg-panel, - > app-epg-timeline { + > app-epg-timeline, + > app-epg-list-view { flex: 1; min-height: 0; } @@ -154,7 +162,8 @@ > app-epg-view, > app-live-epg-panel, - > app-epg-timeline { + > app-epg-timeline, + > app-epg-list-view { flex: 1; min-height: 0; } From e6fd0d082552bcbc015398aa697681d6a40e6962 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 2 Jul 2026 00:50:41 +0200 Subject: [PATCH 2/3] fix(epg): address list-view review findings from Codex and Greptile - Reset the list view to today when a new channel's programme set arrives while the user is parked on another day (timeline parity): the scroll controller now keys by the full programme-set identity (programsFocusKey) and commits today before focusing, instead of silently stranding the new channel on the stale day. (Codex P2) - Centralise the 'timeline' fallback as a resolvedEpgViewMode computed on SettingsStore; the four live hosts consume the derived signal instead of duplicating the `?? 'timeline'` expression. (Greptile P2) - Extract the component's reactive plumbing into registerEpgListViewEffects(), bringing the component back under the 300-line guideline (290). (Greptile P2) - Controller spec rewritten around programme-set fixtures with new coverage: return-to-today on channel switch, day navigation left alone, no-takeover when today has no data, empty-set no-op. Co-Authored-By: Claude Opus 4.8 --- .../video-player.component.spec.ts | 2 +- .../video-player/video-player.component.ts | 4 +- .../unified-live-tab.component.spec.ts | 2 +- .../unified-live-tab.component.ts | 4 +- ...alker-live-stream-layout.component.spec.ts | 8 +- .../stalker-live-stream-layout.component.ts | 4 +- .../live-stream-layout.component.spec.ts | 8 +- .../live-stream-layout.component.ts | 4 +- .../src/lib/settings-store.service.ts | 14 +- .../epg-list-scroll.controller.spec.ts | 132 +++++++++++------- .../epg-list-scroll.controller.ts | 56 +++++--- .../epg-list-view/epg-list-view.component.ts | 38 ++--- .../epg-list-view/epg-list-view.effects.ts | 52 +++++++ 13 files changed, 206 insertions(+), 122 deletions(-) create mode 100644 libs/ui/epg/src/lib/epg-list-view/epg-list-view.effects.ts diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 0ebd8193d..5ddc02e79 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -389,7 +389,7 @@ describe('VideoPlayerComponent', () => { useValue: { player, showCaptions, - epgViewMode, + resolvedEpgViewMode: epgViewMode, }, }, { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 4e8543f92..e1dbf329b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -276,9 +276,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ); readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 980d763c4..84aa3776b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -189,7 +189,7 @@ describe('UnifiedLiveTabComponent', () => { useValue: { openStreamOnDoubleClick: signal(false), player, - epgViewMode, + resolvedEpgViewMode: epgViewMode, }, }, { provide: PORTAL_PLAYER, useValue: portalPlayer }, diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index a582efa81..7c518374d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -194,9 +194,7 @@ export class UnifiedLiveTabComponent { () => this.liveEpgPanelState() === 'collapsed' ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly liveEpgPanelSummary = computed(() => { this.progressTick(); return this.getLiveEpgPanelSummary(this.activeDetail()); 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 8b92f50d8..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 @@ -235,14 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { }; const settingsStore = { openStreamOnDoubleClick: signal(false), - epgViewMode: signal<'timeline' | 'list'>('timeline'), + 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.epgViewMode.set('timeline'); + settingsStore.resolvedEpgViewMode.set('timeline'); window.electron = { platform: 'darwin', updateRemoteControlStatus: jest.fn(), @@ -397,7 +397,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { }); it('swaps the timeline for the list view when epgViewMode is "list"', () => { - settingsStore.epgViewMode.set('list'); + settingsStore.resolvedEpgViewMode.set('list'); fixture.detectChanges(); @@ -414,7 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { ?.classList.contains('epg--list') ).toBe(true); - settingsStore.epgViewMode.set('timeline'); // restore for sibling tests + settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests }); it('does not request or render EPG in browser/PWA playback', async () => { 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 1dee75e43..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 @@ -194,9 +194,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { () => this.liveEpgPanelState() === 'collapsed' ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + 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/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 3a2919016..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 @@ -200,7 +200,7 @@ describe('LiveStreamLayoutComponent', () => { openStreamOnDoubleClick: signal(false), // Reset in beforeEach: the store is module-scoped, so a test failure // before an in-test restore must not leak 'list' into siblings. - epgViewMode: signal<'timeline' | 'list'>('timeline'), + resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'), }; const originalElectron = window.electron; @@ -208,7 +208,7 @@ describe('LiveStreamLayoutComponent', () => { beforeEach(async () => { jest.useFakeTimers(); jest.setSystemTime(fixedNow); - settingsStore.epgViewMode.set('timeline'); + 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); @@ -368,7 +368,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('swaps the timeline for the list view when epgViewMode is "list"', () => { - settingsStore.epgViewMode.set('list'); + settingsStore.resolvedEpgViewMode.set('list'); component.playLive(sampleChannel); fixture.detectChanges(); @@ -386,7 +386,7 @@ describe('LiveStreamLayoutComponent', () => { ?.classList.contains('epg--list') ).toBe(true); - settingsStore.epgViewMode.set('timeline'); // restore for sibling tests + settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests }); it('hides the EPG panel in browser/PWA playback', () => { 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 cd2cbe75c..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 @@ -211,9 +211,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { () => this.liveEpgPanelState() === 'collapsed' ); /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ - readonly epgViewMode = computed( - () => this.settingsStore.epgViewMode?.() ?? 'timeline' - ); + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary( diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index a0349af47..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, @@ -92,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 { 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 index 056545c61..2e126b3bd 100644 --- 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 @@ -1,24 +1,20 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgListScrollController } from './epg-list-scroll.controller'; -import { EpgListRow } from './epg-list-view.utils'; -function rowAt(when: EpgListRow['when'], key = `${when}-row`): EpgListRow { - const startMs = Date.now(); +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 { - program: { - start: new Date(startMs).toISOString(), - stop: new Date(startMs + 60 * 60_000).toISOString(), - channel: 'ch', - title: 'P', - desc: null, - category: null, - }, - key, - startMs, - stopMs: startMs + 60 * 60_000, - when, - progress: when === 'now' ? 50 : null, - isActive: false, - canCatchUp: false, + start: start.toISOString(), + stop: stop.toISOString(), + channel, + title: 'P', + desc: null, + category: null, }; } @@ -26,6 +22,8 @@ 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 @@ -34,14 +32,19 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => { 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 ); @@ -52,17 +55,25 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => { }); it('scrolls the now row into view instantly on first load', () => { - controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], true, 'ch'); + 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 channel stays loaded (now-tick / re-emit)', () => { + 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 rows = [rowAt('now')]; - controller.maybeAutoScroll(list, rows, true, 'ch'); - controller.maybeAutoScroll(list, rows, true, 'ch'); + 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 @@ -71,54 +82,67 @@ describe('EpgListScrollController (channel-select auto-scroll)', () => { }); it('restores the now row when the same channel list remounts (collapse then expand)', () => { - const rows = [rowAt('now')]; - controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // mount - controller.maybeAutoScroll(undefined, rows, true, 'ch'); // collapsed - controller.maybeAutoScroll({} as HTMLElement, rows, true, 'ch'); // expand + 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('does not re-scroll when the on-air programme rolls over within the same set', () => { - // The 30s tick reclassifies `when` at every programme boundary; the - // programme SET is unchanged, so the viewport must stay put. + it('re-scrolls when the channel changes', () => { const list = {} as HTMLElement; - const a = rowAt('now', 'a'); - const b = { - ...rowAt('future', 'b'), - startMs: a.stopMs, - stopMs: a.stopMs + 3_600_000, - }; - controller.maybeAutoScroll(list, [a, b], true, 'ch'); - - const rolled = [ - { ...a, when: 'past' as const }, - { ...b, when: 'now' as const }, - ]; - controller.maybeAutoScroll(list, rolled, true, 'ch'); + 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('re-scrolls when the channel changes', () => { + 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; - controller.maybeAutoScroll(list, [rowAt('now', 'a')], true, 'alpha'); - controller.maybeAutoScroll(list, [rowAt('now', 'b')], true, 'beta'); + const programs = [programAt(0, 120)]; + controller.maybeAutoScroll(list, programs, true, 'ch'); + controller.maybeAutoScroll(list, programs, false, 'ch'); - expect(scrollSpy).toHaveBeenCalledTimes(2); + expect(commitToday).not.toHaveBeenCalled(); + expect(scrollSpy).toHaveBeenCalledTimes(1); }); - it('leaves a non-today day alone (no snap back to now)', () => { - controller.maybeAutoScroll({} as HTMLElement, [rowAt('now')], false, 'ch'); + 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 without an on-air row or a mounted list', () => { - controller.maybeAutoScroll({} as HTMLElement, [rowAt('past')], true, 'ch'); - controller.maybeAutoScroll(undefined, [rowAt('now')], true, 'ch'); + it('does nothing for an empty programme set', () => { + controller.maybeAutoScroll({} as HTMLElement, [], true, 'ch'); expect(scrollSpy).not.toHaveBeenCalled(); + expect(commitToday).not.toHaveBeenCalled(); }); }); @@ -129,6 +153,8 @@ describe('EpgListScrollController (now-strip visibility)', () => { list: () => list as HTMLElement, isViewToday: () => true, setNowStripVisible: (value) => (visible = value), + hasProgramsToday: () => true, + commitToday: () => undefined, }); controller.updateNowStrip(); return visible; @@ -180,6 +206,8 @@ describe('EpgListScrollController (scroll target maths)', () => { list: () => list, isViewToday: () => true, setNowStripVisible: () => undefined, + hasProgramsToday: () => true, + commitToday: () => undefined, }); controller.scrollNowIntoView(false); 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 index 6583fe783..9b00c4e68 100644 --- 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 @@ -1,4 +1,5 @@ -import { EpgListRow } from './epg-list-view.utils'; +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). */ @@ -7,6 +8,10 @@ export interface EpgListScrollDeps { 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; } /** @@ -22,34 +27,34 @@ export class EpgListScrollController { constructor(private readonly deps: EpgListScrollDeps) {} /** - * Scroll the on-air row into view once per channel/EPG load, today only. - * A *new* list element under an unchanged key means the body was unmounted - * and remounted (the inline panel was collapsed and re-expanded), which - * resets scrollTop to 0 — restore the now-row instead of stranding the user - * at the top of the day. The same element (data re-emit / 30s now-tick) is - * left alone so the viewport is never yanked out from under the user. + * 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, - rows: EpgListRow[], + programs: readonly EpgProgram[], today: boolean, channel: string ): void { - if (!list || !today) { + const setKey = programsFocusKey(programs); + if (!setKey) { return; } - const now = rows.find((row) => row.when === 'now'); - if (!now) { - return; - } - // Programme-SET identity (like the timeline's `programsFocusKey`), NOT - // the on-air row's key: the latter changes at every programme rollover - // (the 30s tick reclassifies `when`), which would re-trigger the scroll - // and yank the viewport away from wherever the user scrolled to. - const first = rows[0]; - const last = rows[rows.length - 1]; - const key = `${channel}|${rows.length}|${first.startMs}|${last.stopMs}`; + const key = `${channel}|${setKey}`; if (key === this.autoScrollKey) { + if (!list) { + return; + } if (list !== this.lastList) { this.lastList = list; this.focusNowAfterRender(); @@ -61,8 +66,17 @@ export class EpgListScrollController { } 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; + this.lastList = list ?? null; + if (!today) { + this.deps.commitToday(); + } this.focusNowAfterRender(); } 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 index e46470475..3bfb41345 100644 --- 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 @@ -3,14 +3,12 @@ import { ChangeDetectionStrategy, Component, computed, - effect, ElementRef, inject, input, linkedSignal, output, signal, - untracked, viewChild, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -47,6 +45,7 @@ import { } 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; @@ -191,33 +190,20 @@ export class EpgListViewComponent { list: () => this.list()?.nativeElement, isViewToday: () => this.isViewToday(), setNowStripVisible: (visible) => this.nowStripVisible.set(visible), + hasProgramsToday: () => + hasProgramsForDateKey(this.programs(), getTodayEpgDateKey()), + commitToday: () => this.commitDay(getTodayEpgDateKey()), }); constructor() { - // 30s tick reclassifies past/now/future and refreshes progress. This is - // a controlled component (activeProgram/isLivePlayback come from the - // host), so the tick never clobbers active archive playback. - effect((onCleanup) => { - const intervalId = window.setInterval( - () => this.nowMs.set(Date.now()), - 30_000 - ); - onCleanup(() => clearInterval(intervalId)); - }); - - // Auto-focus the on-air row when a channel's EPG (re)loads or the list - // (re)mounts (collapse → expand) while viewing today — the vertical - // analogue of the ribbon's auto-focus. Tracks the `list` viewChild so a - // remount re-triggers; deduped inside the controller so 30s ticks and - // data re-emits don't re-jump. - effect(() => { - const list = this.list()?.nativeElement; - const rows = this.rows(); - const today = this.isViewToday(); - const channel = this.channelName(); - untracked(() => - this.scroll.maybeAutoScroll(list, rows, today, channel) - ); + registerEpgListViewEffects({ + nowMs: this.nowMs, + list: () => this.list()?.nativeElement, + rows: () => this.rows(), + programs: () => this.programs(), + isViewToday: () => this.isViewToday(), + channelName: () => this.channelName(), + scroll: this.scroll, }); } 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) + ); + }); +} From c6dbedd035605db726397487288d808555a8cd8d Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 2 Jul 2026 01:12:45 +0200 Subject: [PATCH 3/3] fix(epg): drop malformed programmes from the list-view day filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject programmes whose stop is not after their start in buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad provider data would otherwise render impossible time ranges and could even be offered as catch-up playable. (Codex P2 on e6fd0d08) Co-Authored-By: Claude Opus 4.8 --- .../epg-list-view/epg-list-view.utils.spec.ts | 18 ++++++++++++++++++ .../lib/epg-list-view/epg-list-view.utils.ts | 6 ++++++ 2 files changed, 24 insertions(+) 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 index 50d90dd6a..fdc42bfc0 100644 --- 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 @@ -120,6 +120,24 @@ describe('buildEpgListRows', () => { ); }); + 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( [ 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 index 9c9162a6c..3c5c1a69d 100644 --- 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 @@ -77,6 +77,12 @@ export function buildEpgListRows( 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; });