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 @@
[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 3186464eb..e07973ada 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -290,6 +290,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 cb5e7d321..d8b72b82c 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -290,6 +290,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 0ce8e448d..b97e73af5 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -290,6 +290,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 caf9b9bb8..4328ba5fc 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -290,6 +290,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 1eb16f157..407309743 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -290,6 +290,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 a8820c9b5..7c7f8b1dd 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -290,6 +290,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 05620730c..7236e8f70 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -290,6 +290,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 ec727b4d0..0fa46bb2b 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -290,6 +290,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 89bdf6099..ebfebaeaf 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -290,6 +290,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 7d5da5f74..a10acc880 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -290,6 +290,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 6042470f0..ddb5efb64 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -290,6 +290,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 3245e41a3..fd5b80b9e 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -290,6 +290,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 f81d459ee..7e6e8e2a4 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -290,6 +290,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 e5e2b2415..6d11cc496 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -290,6 +290,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 865c8ee6c..bea6d789a 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -290,6 +290,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 8c7c27e90..c6098f6ca 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -290,6 +290,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 764fad70e..a2f7ea162 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -290,6 +290,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 3d3fe0467..98cc1773d 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -290,6 +290,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 da6ef65a7..2685d563c 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
+### EPG Panel (Timeline & List views)
-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 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*):
-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
+- **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
@@ -565,6 +599,7 @@ class EpgService {
| Component | Purpose |
| ----------------------------- | ------------------------------------ |
| `EpgTimelineComponent` | Horizontal timeline for one channel |
+| `EpgListViewComponent` | Vertical single-day list alternative |
| `EpgItemDescriptionComponent` | Program details dialog |
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md
index 4207660c8..44a43772c 100644
--- a/docs/architecture/nx-workspace-boundaries.md
+++ b/docs/architecture/nx-workspace-boundaries.md
@@ -82,3 +82,16 @@ playback, or EPG data belongs in `libs/portal/shared/data-access`, not
`libs/portal/shared/util`. That keeps pure collection helpers importable by
Xtream/Stalker data-access libraries while allowing shared UI to use
provider-specific collection services without creating cycles.
+
+Note: `workspace-shell-util` (`libs/workspace/shell/util`) is tagged
+`type:data-access` despite its path. It exports injectable services such as
+`WorkspaceStartupPreferencesService` that depend on `@iptvnator/services`, and
+it must stay eagerly importable from `apps/web/src/app/app.routes.ts` without
+pulling the lazy-loaded workspace shell feature bundle into the initial chunk.
+
+## CI Enforcement
+
+The `Lint` job in `.github/workflows/ci.yml` runs
+`pnpm nx run-many --target=lint --all` on every PR, so
+`@nx/enforce-module-boundaries` violations, legacy bare-alias imports, and
+`max-lines` violations fail CI. Run `pnpm run lint` locally before pushing.
diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md
index 7df36a6de..387bfcea7 100644
--- a/docs/architecture/validation-map.md
+++ b/docs/architecture/validation-map.md
@@ -17,16 +17,38 @@ pnpm nx show projects --withTarget e2e
| ---------------------------------- | ----------------------------------- |
| Angular renderer entry points | `pnpm run typecheck:web` |
| Electron main process entry points | `pnpm run typecheck:backend` |
-| Curated CI unit suite | `pnpm run test:unit:ci` |
+| Full unit suite (all projects) | `pnpm run test:unit:ci` |
| EPG data access | `pnpm nx test epg-data-access` |
| Workspace shell utilities | `pnpm nx test workspace-shell-util` |
| Shared SQLite schema/connection | `pnpm nx test database` |
| Packaging metadata | `pnpm nx test packaging` |
+## Lint
+
+```bash
+pnpm run lint # nx run-many --target=lint --all
+pnpm nx lint # single project
+```
+
+The CI workflow (`.github/workflows/ci.yml`) runs lint for every project on
+each PR. This enforces `@nx/enforce-module-boundaries` (scope/domain/type tag
+constraints), the legacy bare-alias ban, and the `max-lines` file-size rule
+(hard maximum 400 lines per TypeScript file). Files that predate the
+`max-lines` rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after
+splitting a baselined file below the limit, regenerate the list with
+`node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to
+the baseline.
+
## Coverage Tiers
Use `tools/coverage/coverage-policy.json` as the source of truth for coverage
-ownership.
+ownership. Every project with a `test` target must be classified in a tier;
+`pnpm run coverage:policy:check` (part of `coverage:ci`) fails CI when a new
+project is missing from the policy, a listed project no longer exists, or a
+Tier A entry has no test target. CI runs Tier A with coverage (uploaded to
+Codecov) and each Tier B/C project's `validationCommand` (falling back to
+`nx test`) without coverage; projects with an `e2e` target are skipped there
+because the E2E workflow already runs them on every PR.
| Tier | Rule | Validation |
| --- | --- | --- |
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 41093fbd1..57bef608a 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,4 +1,5 @@
import nx from '@nx/eslint-plugin';
+import { maxLinesBaseline } from './tools/eslint/max-lines-baseline.mjs';
const legacyBareAliases = [
'components',
@@ -246,6 +247,23 @@ export default [
'@angular-eslint/template/interactive-supports-focus': 'off',
},
},
+ {
+ // CLAUDE.md file-size rule: keep TypeScript files under 300 lines,
+ // hard maximum 400. Files that predate the rule are baselined below.
+ files: ['**/*.ts', '**/*.tsx'],
+ rules: {
+ 'max-lines': [
+ 'error',
+ { max: 400, skipBlankLines: false, skipComments: false },
+ ],
+ },
+ },
+ {
+ files: maxLinesBaseline,
+ rules: {
+ 'max-lines': 'off',
+ },
+ },
{
files: [
'apps/web/src/app/app.component.ts',
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 @@