diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a73d3f6f..bf4da6b24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,33 @@ on: workflow_dispatch: jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint all projects + run: pnpm nx run-many --target=lint --all --parallel=3 --output-style=static + env: + CI: true + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + unit-and-typecheck: name: Unit Tests and Typechecks runs-on: ubuntu-latest @@ -43,6 +70,12 @@ jobs: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + - name: Run Tier B/C validation commands + run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a + env: + CI: true + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + - name: Upload unit coverage artifact if: always() uses: actions/upload-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 80acd2a41..f728cb4a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,13 +195,21 @@ Before finishing behavior changes or bug fixes, follow `Regression Prevention An ### Linting ```bash -# Lint frontend -nx lint web +# Lint all projects (what CI enforces on every PR) +pnpm run lint -# Lint backend +# Lint a single project +nx lint web nx lint electron-backend ``` +CI runs lint for every project (`.github/workflows/ci.yml`). This enforces the +Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint +rule (hard maximum 400 lines per TypeScript file). Pre-existing oversized files +are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline +with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. +Never add new files to the baseline. + ## Architecture ### Monorepo Structure (Nx Workspace) @@ -238,7 +246,7 @@ This is an Nx monorepo with the following structure: - **ui/playback** - Player UI (video/audio players) - **ui/pipes** - Angular pipes - **ui/remote-control** - Remote-control UI pieces - - **ui/shared-portals** - Portal-related UI components + - **ui/shared-portals** - Shared portal types (`LiveEpgPanelSummary`) - **ui/styles** - Shared styles/theme - **workspace/{shell,dashboard}** - Workspace shell (layout/navigation) and dashboard @@ -736,7 +744,6 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c - Use NgRx for global application state (M3U playlists, `libs/m3u-state`) - Use NgRx Signal Store with `signalStoreFeature()` composition for portal/feature state (XtreamStore, StalkerStore) - Use NgRx signals for reactive data streams -- `@ngrx/component-store` is listed in `package.json` but unused in the codebase — do not introduce new usages 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_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 @@ [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..e07973ada 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -192,10 +192,8 @@ "VALIDATING": "جارٍ التحقق من البوابة..." }, "FILTER_BY_NAME": "التصفية حسب الاسم", - "FILTER_BY_TYPE": "التصفية حسب النوع", "FILTER_AND_SORT": "تصفية وفرز", "SORT_PLAYLISTS": "فرز القوائم", - "SORT_BY": "ترتيب حسب", "SORT_OPTIONS": { "NAME_ASC": "الاسم (أ-ي)", "NAME_DESC": "الاسم (ي-أ)", @@ -292,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": "أول عرض متاح", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "البحث عن قناة", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "فتح قائمة القنوات", - "TOGGLE_FAVORITE_FLAG": "تبديل حالة المفضلة", - "OPEN_EPG_LIST": "فتح قائمة EPG", - "OPEN_MULTI_EPG": "فتح عرض Multi-EPG", - "SHOW_INFO": "عرض معلومات البرنامج (اضغط I)" + "OPEN_MULTI_EPG": "فتح عرض Multi-EPG" }, "EPG": { - "TITLE": "الدليل الإلكتروني للبرامج", "NEXT_DAY": "اليوم التالي", "PREVIOUS_DAY": "اليوم السابق", "ZOOM_IN": "تكبير", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "إغلاق التصفية", "SEARCH_PROGRAMS": "البحث في البرامج", "CLOSE_PROGRAM_SEARCH": "إغلاق بحث البرامج", - "LIVE_NOW": "يُبث الآن", "LIVE_STREAM": "بث مباشر", - "TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف غير متاح", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "يكشف هذا المزود عن سجل الجدول لهذه القناة، لكنه لا يوفر تشغيلًا قابلًا للأرشيف أو Catchup.", - "EPG_NOT_AVAILABLE_DATE": "عذراً، الدليل غير متاح لهذا التاريخ", - "NO_GUIDE_HINT": "جرب تاريخاً آخر أو حدّث قائمة التشغيل.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "عذراً، الدليل غير متاح لهذه القناة", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "يرجى إضافة/تعديل رابط EPG في إعدادات التطبيق.", "FETCH_EPG": "جارٍ جلب بيانات EPG...", "ERROR": "عذراً، فشل تحميل الدليل.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", - "RETURN_TO_LIVE": "العودة إلى البث المباشر", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "فتح القائمة", "SUPPORT_ARIA": "دعم IPTVnator على GitHub", - "SETTINGS": "الإعدادات", "SETTINGS_ARIA": "فتح الإعدادات", "BUG_REPORT": "ابلاغ عن خلل", "WHAT_IS_NEW": "ما الجديد", @@ -804,10 +793,7 @@ "SEARCH": "بحث", "RECENT": "الأخيرة", "RECENTLY_ADDED": "أُضيفت مؤخرًا", - "FAVORITES": "المفضلة", - "HOME": "الرئيسية", - "PLAYLIST_INFO": "معلومات قائمة التشغيل", - "ACCOUNT_INFO": "معلومات الحساب" + "FAVORITES": "المفضلة" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c4a702eac..d8b72b82c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -192,10 +192,8 @@ "VALIDATING": "جاري التحقق من البوابة..." }, "FILTER_BY_NAME": "فلتر بالاسم", - "FILTER_BY_TYPE": "فلتر بالنوع", "FILTER_AND_SORT": "فلتر وترتيب", "SORT_PLAYLISTS": "رتب قوائم التشغيل", - "SORT_BY": "رتب بـ", "SORT_OPTIONS": { "NAME_ASC": "الاسم (أ-ي)", "NAME_DESC": "الاسم (ي-أ)", @@ -292,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": "أول عرض متوفر", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "قلب على قناة", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "حل قائمة القنوات", - "TOGGLE_FAVORITE_FLAG": "بدل علامة المفضلة", - "OPEN_EPG_LIST": "حل قائمة EPG", - "OPEN_MULTI_EPG": "حل عرض EPG المتعدد", - "SHOW_INFO": "وري معلومات البرنامج (ضغط على I)" + "OPEN_MULTI_EPG": "حل عرض EPG المتعدد" }, "EPG": { - "TITLE": "دليل البرامج الإلكتروني", "NEXT_DAY": "اليوم التالي", "PREVIOUS_DAY": "اليوم السابق", "ZOOM_IN": "كبر", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "سد الفلتر", "SEARCH_PROGRAMS": "قلب على البرامج", "CLOSE_PROGRAM_SEARCH": "سد البحث على البرامج", - "LIVE_NOW": "مباشر الآن", "LIVE_STREAM": "بث مباشر", - "TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف ما متوفرش", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "هاد المزود كيوفر تاريخ جدول البرامج للقناة، ولكن ما كيوفرش تشغيل أرشيف/Catchup.", - "EPG_NOT_AVAILABLE_DATE": "أوه، EPG ما متوفرش للتاريخ اللي اختاريتيه", - "NO_GUIDE_HINT": "جرب تاريخ آخر أو حدّث اللائحة.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "أوه، EPG ما متوفرش لهاد القناة.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "عفاك زيد/بدل رابط قائمة EPG في إعدادات التطبيق.", "FETCH_EPG": "جاري جلب بيانات EPG...", "ERROR": "أوه، EPG ما قدرش يتشارجا.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", - "RETURN_TO_LIVE": "رجع للبث المباشر", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "حل القائمة", "SUPPORT_ARIA": "دعم IPTVnator على GitHub", - "SETTINGS": "الإعدادات", "SETTINGS_ARIA": "حل الإعدادات", "BUG_REPORT": "بلغ على شي مشكل", "WHAT_IS_NEW": "شنو الجديد", @@ -804,10 +793,7 @@ "SEARCH": "بحث", "RECENT": "أخير", "RECENTLY_ADDED": "تزادو مؤخراً", - "FAVORITES": "المفضلة", - "HOME": "الرئيسية", - "PLAYLIST_INFO": "معلومات قائمة التشغيل", - "ACCOUNT_INFO": "معلومات الحساب" + "FAVORITES": "المفضلة" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e2142f5e7..b97e73af5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -192,10 +192,8 @@ "VALIDATING": "Праверка партала..." }, "FILTER_BY_NAME": "Фільтраваць па імені", - "FILTER_BY_TYPE": "Фільтраваць па тыпу", "FILTER_AND_SORT": "Фільтр і сартаванне", "SORT_PLAYLISTS": "Сартаваць плэйлісты", - "SORT_BY": "Сартаваць па", "SORT_OPTIONS": { "NAME_ASC": "Назва (А-Я)", "NAME_DESC": "Назва (Я-А)", @@ -292,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": "Першы даступны выгляд", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Пошук каналаў", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст", "ALL_CHANNELS": "Усе каналы", "GROUPS": "Групы", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Адкрыць спіс каналаў", - "TOGGLE_FAVORITE_FLAG": "Выбраны", - "OPEN_EPG_LIST": "Адкрыць праграму (EPG)", - "OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд", - "SHOW_INFO": "Паказаць інфармацыю аб праграме (націсніце I)" + "OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд" }, "EPG": { - "TITLE": "Электронная праграма перадач", "NEXT_DAY": "Наступны дзень", "PREVIOUS_DAY": "Папярэдні дзень", "ZOOM_IN": "Павялічыць маштаб", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Закрыць фільтр", "SEARCH_PROGRAMS": "Пошук перадач", "CLOSE_PROGRAM_SEARCH": "Закрыць пошук перадач", - "LIVE_NOW": "Цяпер", "LIVE_STREAM": "Прамая трансляцыя", - "TIMESHIFT_AVAILABLE": "Даступная функцыя архіва (timeshift). Націсніце на перадачу для прайгравання.", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Прайграванне архіва недаступна", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Гэты правайдэр прадастаўляе гісторыю перадач для канала, але не падтрымлівае прайграванне архіва/catch-up.", - "EPG_NOT_AVAILABLE_DATE": "Ой, праграма на гэты дзень, на жаль, адсутнічае.", - "NO_GUIDE_HINT": "Паспрабуйце іншую дату або абнавіце плэйліст.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Праграма для гэтага канала недаступная.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Паспрабуйце змяніць або абнавіць спасылку на EPG у наладах праграмы.", "FETCH_EPG": "Загружаю праграму ТБ-перадач (EPG)...", "ERROR": "Ой, не ўдалося загрузіць праграму каналаў (EPG)", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Прайграванне архіва", - "RETURN_TO_LIVE": "Вярнуцца да эфіру", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Адкрыць меню", "SUPPORT_ARIA": "Падтрымаць IPTVnator на GitHub", - "SETTINGS": "Налады", "SETTINGS_ARIA": "Адкрыць налады", "BUG_REPORT": "Паведаміць аб памылцы", "WHAT_IS_NEW": "Што новага?", @@ -804,10 +793,7 @@ "SEARCH": "Пошук", "RECENT": "Нядаўнія", "RECENTLY_ADDED": "Нядаўна дададзеныя", - "FAVORITES": "Выбранае", - "HOME": "Галоўная", - "PLAYLIST_INFO": "Інфармацыя аб плэйлісце", - "ACCOUNT_INFO": "Інфармацыя аб уліковым запісе" + "FAVORITES": "Выбранае" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 25438869f..4328ba5fc 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -192,10 +192,8 @@ "VALIDATING": "Portal wird überprüft …" }, "FILTER_BY_NAME": "Nach Namen filtern", - "FILTER_BY_TYPE": "Playlist-Typen", "FILTER_AND_SORT": "Filtern & Sortieren", "SORT_PLAYLISTS": "Playlisten sortieren", - "SORT_BY": "Sortieren nach", "SORT_OPTIONS": { "NAME_ASC": "Name (A-Z)", "NAME_DESC": "Name (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Nach Sender suchen", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen", "ALL_CHANNELS": "Alle Sender", "GROUPS": "Gruppen", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Senderliste öffnen", - "TOGGLE_FAVORITE_FLAG": "Als Favoriten hinzufügen/entfernen", - "OPEN_EPG_LIST": "TV-Guide (EPG) öffnen", - "OPEN_MULTI_EPG": "Open Multi-EPG view", - "SHOW_INFO": "Programminfo anzeigen (Taste I)" + "OPEN_MULTI_EPG": "Open Multi-EPG view" }, "EPG": { - "TITLE": "Elektronischer Programmführer", "NEXT_DAY": "Zum nächsten Tag", "PREVIOUS_DAY": "Zum vorherigen Tag", "ZOOM_IN": "Vergrößern", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Filter schließen", "SEARCH_PROGRAMS": "Sendungen suchen", "CLOSE_PROGRAM_SEARCH": "Sendungssuche schließen", - "LIVE_NOW": "Live jetzt", "LIVE_STREAM": "Live stream", - "TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archivwiedergabe nicht verfügbar", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Dieser Anbieter stellt zwar den Programmverlauf für den Sender bereit, jedoch keine abspielbare Archiv-/Catchup-Wiedergabe.", - "EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar", - "NO_GUIDE_HINT": "Versuchen Sie ein anderes Datum oder aktualisieren Sie die Playlist.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.", "FETCH_EPG": "EPG-Daten werden geladen...", "ERROR": "Ooops, EPG konnte nicht geladen werden", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Archivwiedergabe", - "RETURN_TO_LIVE": "Zurück zum Live-TV", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Menü öffnen", "SUPPORT_ARIA": "IPTVnator auf GitHub unterstützen", - "SETTINGS": "Einstellungen", "SETTINGS_ARIA": "Einstellungen öffnen", "BUG_REPORT": "Fehler melden", "WHAT_IS_NEW": "Was ist neu", @@ -804,10 +793,7 @@ "SEARCH": "Suche", "RECENT": "Zuletzt angesehen", "RECENTLY_ADDED": "Kürzlich hinzugefügt", - "FAVORITES": "Favoriten", - "HOME": "Startseite", - "PLAYLIST_INFO": "Playlist-Info", - "ACCOUNT_INFO": "Account-Info" + "FAVORITES": "Favoriten" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index c552f9991..407309743 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -192,10 +192,8 @@ "VALIDATING": "Επικύρωση πύλης..." }, "FILTER_BY_NAME": "Φιλτράρισμα κατά όνομα", - "FILTER_BY_TYPE": "Τύποι λιστών αναπαραγωγής", "FILTER_AND_SORT": "Φιλτράρισμα & Ταξινόμηση", "SORT_PLAYLISTS": "Ταξινόμηση λιστών αναπαραγωγής", - "SORT_BY": "Ταξινόμηση κατά", "SORT_OPTIONS": { "NAME_ASC": "Όνομα (A-Z)", "NAME_DESC": "Όνομα (Z-A)", @@ -292,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": "Πρώτη διαθέσιμη προβολή", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Αναζήτηση καναλιού", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής", "ALL_CHANNELS": "Όλα τα κανάλια", "GROUPS": "Ομάδες", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Άνοιγμα λίστας καναλιών", - "TOGGLE_FAVORITE_FLAG": "Εναλλαγή σημαίας αγαπημένων", - "OPEN_EPG_LIST": "Άνοιγμα λίστας EPG", - "OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG", - "SHOW_INFO": "Εμφάνιση πληροφοριών προγράμματος (Πατήστε το I)" + "OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG" }, "EPG": { - "TITLE": "Ηλεκτρονικός Οδηγός Προγράμματος", "NEXT_DAY": "Επόμενη μέρα", "PREVIOUS_DAY": "Προηγούμενη ημέρα", "ZOOM_IN": "Μεγέθυνση", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Κλείσιμο φίλτρου", "SEARCH_PROGRAMS": "Αναζήτηση προγραμμάτων", "CLOSE_PROGRAM_SEARCH": "Κλείσιμο αναζήτησης προγραμμάτων", - "LIVE_NOW": "Ζωντανά τώρα", "LIVE_STREAM": "Ζωντανή ροή", - "TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Η αναπαραγωγή αρχείου δεν είναι διαθέσιμη", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Αυτός ο πάροχος εκθέτει ιστορικό προγράμματος για το κανάλι, αλλά δεν παρέχει αναπαραγωγή αρχείου/catch-up.", - "EPG_NOT_AVAILABLE_DATE": "Ωχ, το EPG δεν είναι διαθέσιμο για την επιλεγμένη ημερομηνία", - "NO_GUIDE_HINT": "Δοκιμάστε άλλη ημερομηνία ή ανανεώστε τη λίστα αναπαραγωγής.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ωχ, το EPG δεν είναι διαθέσιμο για αυτό το κανάλι.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Προσθέστε/αλλάξτε τη διεύθυνση URL της λίστας EPG στις ρυθμίσεις της εφαρμογής.", "FETCH_EPG": "Ανάκτηση δεδομένων EPG...", "ERROR": "Ωχ, δεν ήταν δυνατή η φόρτωση του EPG.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου", - "RETURN_TO_LIVE": "Επιστροφή στη ζωντανή ροή", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Άνοιγμα μενού", "SUPPORT_ARIA": "Υποστήριξη του IPTVnator στο GitHub", - "SETTINGS": "Ρυθμίσεις", "SETTINGS_ARIA": "Άνοιγμα ρυθμίσεων", "BUG_REPORT": "Αναφορά σφάλματος", "WHAT_IS_NEW": "Τι νέο υπάρχει", @@ -804,10 +793,7 @@ "SEARCH": "Αναζήτηση", "RECENT": "Πρόσφατα", "RECENTLY_ADDED": "Πρόσφατα προστέθηκε", - "FAVORITES": "Αγαπημένα", - "HOME": "Αρχικό", - "PLAYLIST_INFO": "Πληροφορίες λίστας αναπαραγωγής", - "ACCOUNT_INFO": "Πληροφορίες λογαριασμού" + "FAVORITES": "Αγαπημένα" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index fcf8eedfa..7c7f8b1dd 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -192,10 +192,8 @@ "VALIDATING": "Validating portal..." }, "FILTER_BY_NAME": "Filter by name", - "FILTER_BY_TYPE": "Playlist Types", "FILTER_AND_SORT": "Filter & Sort", "SORT_PLAYLISTS": "Sort playlists", - "SORT_BY": "Sort By", "SORT_OPTIONS": { "NAME_ASC": "Name (A-Z)", "NAME_DESC": "Name (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Search channel", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist", "ALL_CHANNELS": "All channels", "GROUPS": "Groups", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Open channel list", - "TOGGLE_FAVORITE_FLAG": "Toggle favorite flag", - "OPEN_EPG_LIST": "Open EPG list", - "OPEN_MULTI_EPG": "Open Multi-EPG view", - "SHOW_INFO": "Show program info (Press I)" + "OPEN_MULTI_EPG": "Open Multi-EPG view" }, "EPG": { - "TITLE": "Electronic Program Guide", "NEXT_DAY": "Next day", "PREVIOUS_DAY": "Previous day", "ZOOM_IN": "Zoom in", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Close filter", "SEARCH_PROGRAMS": "Search programs", "CLOSE_PROGRAM_SEARCH": "Close program search", - "LIVE_NOW": "Live now", "LIVE_STREAM": "Live stream", - "TIMESHIFT_AVAILABLE": "Timeshift is available, click to play", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archive playback is unavailable", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "This provider exposes schedule history for the channel, but does not expose playable archive/catch-up playback.", - "EPG_NOT_AVAILABLE_DATE": "No program guide", - "NO_GUIDE_HINT": "Try a different date or refresh the playlist.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the URL of the EPG list in the settings of the app.", "FETCH_EPG": "Fetching EPG data...", "ERROR": "Ooops, EPG could not be loaded.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Archive playback", - "RETURN_TO_LIVE": "Return to live", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Open menu", "SUPPORT_ARIA": "Support IPTVnator on GitHub", - "SETTINGS": "Settings", "SETTINGS_ARIA": "Open settings", "BUG_REPORT": "Report a bug", "WHAT_IS_NEW": "What is new", @@ -804,10 +793,7 @@ "SEARCH": "Search", "RECENT": "Recent", "RECENTLY_ADDED": "Recently added", - "FAVORITES": "Favorites", - "HOME": "Home", - "PLAYLIST_INFO": "Playlist info", - "ACCOUNT_INFO": "Account info" + "FAVORITES": "Favorites" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index ef4d64e29..7236e8f70 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -192,10 +192,8 @@ "VALIDATING": "Validando portal…" }, "FILTER_BY_NAME": "Filtrar por nombre", - "FILTER_BY_TYPE": "Filtrar por tipo", "FILTER_AND_SORT": "Filtrar y ordenar", "SORT_PLAYLISTS": "Ordenar listas", - "SORT_BY": "Ordenar por", "SORT_OPTIONS": { "NAME_ASC": "Nombre (A-Z)", "NAME_DESC": "Nombre (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Buscar canal", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción", "ALL_CHANNELS": "Todos los canales", "GROUPS": "Grupos", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Abrir lista de canales", - "TOGGLE_FAVORITE_FLAG": "Alternar bandera favorita", - "OPEN_EPG_LIST": "Abrir lista EPG", - "OPEN_MULTI_EPG": "Abrir vista Multi-EPG", - "SHOW_INFO": "Mostrar información del programa (presiona I)" + "OPEN_MULTI_EPG": "Abrir vista Multi-EPG" }, "EPG": { - "TITLE": "Guía electrónica de programación", "NEXT_DAY": "Día siguiente", "PREVIOUS_DAY": "Día anterior", "ZOOM_IN": "Acercar", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Cerrar filtro", "SEARCH_PROGRAMS": "Buscar programas", "CLOSE_PROGRAM_SEARCH": "Cerrar búsqueda de programas", - "LIVE_NOW": "Ahora en vivo", "LIVE_STREAM": "Transmisión en vivo", - "TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "La reproducción del archivo no está disponible", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este proveedor expone el historial de programación del canal, pero no permite la reproducción del archivo/Catchup.", - "EPG_NOT_AVAILABLE_DATE": "Vaya, no está disponible la EPG de esa fecha", - "NO_GUIDE_HINT": "Prueba otra fecha o actualiza la lista de reproducción.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Vaya, no está disponible la EPG del canal.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Por favor añada/cargue la url de la lista EPG en los ajustes de la app.", "FETCH_EPG": "Obteniendo datos de EPG...", "ERROR": "¡Vaya! Esta EPG no se pudo cargar.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Reproducción de archivo", - "RETURN_TO_LIVE": "Volver al directo", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Abrir menú", "SUPPORT_ARIA": "Apoyar IPTVnator en GitHub", - "SETTINGS": "Ajustes", "SETTINGS_ARIA": "Abrir ajustes", "BUG_REPORT": "Reportar un bug", "WHAT_IS_NEW": "Novedades", @@ -804,10 +793,7 @@ "SEARCH": "Buscar", "RECENT": "Reciente", "RECENTLY_ADDED": "Añadidos recientemente", - "FAVORITES": "Favoritos", - "HOME": "Inicio", - "PLAYLIST_INFO": "Información de la lista", - "ACCOUNT_INFO": "Información de la cuenta" + "FAVORITES": "Favoritos" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 16be1d351..0fa46bb2b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -192,10 +192,8 @@ "VALIDATING": "Validation du portail…" }, "FILTER_BY_NAME": "Filtrer par nom", - "FILTER_BY_TYPE": "Filtrer par type", "FILTER_AND_SORT": "Filtrer et trier", "SORT_PLAYLISTS": "Trier les listes", - "SORT_BY": "Trier par", "SORT_OPTIONS": { "NAME_ASC": "Nom (A-Z)", "NAME_DESC": "Nom (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Rechercher une chaîne", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture", "ALL_CHANNELS": "Toutes les chaînes", "GROUPS": "Groupes", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Ouvrir la liste des chaînes", - "TOGGLE_FAVORITE_FLAG": "Basculer le statut de favoris", - "OPEN_EPG_LIST": "Ouvrir la liste EPG", - "OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG", - "SHOW_INFO": "Afficher les infos du programme (Appuyez sur I)" + "OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG" }, "EPG": { - "TITLE": "Guide électronique des programmes", "NEXT_DAY": "Jour suivant", "PREVIOUS_DAY": "Jour précédent", "ZOOM_IN": "Zoom avant", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Fermer le filtre", "SEARCH_PROGRAMS": "Rechercher des programmes", "CLOSE_PROGRAM_SEARCH": "Fermer la recherche de programmes", - "LIVE_NOW": "En direct", "LIVE_STREAM": "Diffusion en direct", - "TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Lecture des archives indisponible", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ce fournisseur expose l'historique des programmes pour la chaîne, mais n'expose pas la lecture archive/catch-up.", - "EPG_NOT_AVAILABLE_DATE": "L'EPG n'est pas disponible pour la date sélectionnée", - "NO_GUIDE_HINT": "Essayez une autre date ou actualisez la playlist.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "L'EPG n'est pas disponible pour cette chaîne.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Veuillez ajouter/modifier l'URL de la liste EPG dans les réglages de l'application.", "FETCH_EPG": "Récupération des données EPG...", "ERROR": "L'EPG n'a pas pu être chargé.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Lecture archive", - "RETURN_TO_LIVE": "Retour au direct", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Ouvrir le menu", "SUPPORT_ARIA": "Soutenir IPTVnator sur GitHub", - "SETTINGS": "Paramètres", "SETTINGS_ARIA": "Ouvrir les paramètres", "BUG_REPORT": "Signaler un bug", "WHAT_IS_NEW": "Nouveautés", @@ -804,10 +793,7 @@ "SEARCH": "Rechercher", "RECENT": "Récent", "RECENTLY_ADDED": "Récemment ajoutés", - "FAVORITES": "Favoris", - "HOME": "Accueil", - "PLAYLIST_INFO": "Infos de la liste", - "ACCOUNT_INFO": "Infos du compte" + "FAVORITES": "Favoris" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 77ba6bd57..ebfebaeaf 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -192,10 +192,8 @@ "VALIDATING": "Validazione del portale..." }, "FILTER_BY_NAME": "Filtra per nome", - "FILTER_BY_TYPE": "Filtra per tipo", "FILTER_AND_SORT": "Filtra e ordina", "SORT_PLAYLISTS": "Ordina playlist", - "SORT_BY": "Ordina per", "SORT_OPTIONS": { "NAME_ASC": "Nome (A-Z)", "NAME_DESC": "Nome (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Cerca canale", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist", "ALL_CHANNELS": "Tutti i canali", "GROUPS": "Gruppi", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Apri elenco canali", - "TOGGLE_FAVORITE_FLAG": "Attiva/disattiva flag preferiti", - "OPEN_EPG_LIST": "Apri elenco EPG", - "OPEN_MULTI_EPG": "Apri vista Multi-EPG", - "SHOW_INFO": "Mostra info programma (premi I)" + "OPEN_MULTI_EPG": "Apri vista Multi-EPG" }, "EPG": { - "TITLE": "Guida elettronica programmi", "NEXT_DAY": "Giorno successivo", "PREVIOUS_DAY": "Giorno precedente", "ZOOM_IN": "Ingrandisci", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Chiudi filtro", "SEARCH_PROGRAMS": "Cerca programmi", "CLOSE_PROGRAM_SEARCH": "Chiudi ricerca programmi", - "LIVE_NOW": "Live attuale", "LIVE_STREAM": "Stream live", - "TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Riproduzione archivio non disponibile", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Questo fornitore espone la cronologia della programmazione del canale, ma non la riproduzione di archivio/catch-up.", - "EPG_NOT_AVAILABLE_DATE": "Non è disponibile l'EPG per la data selezionata", - "NO_GUIDE_HINT": "Prova una data diversa o aggiorna la playlist.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Non è disponibile l'EPG per questo canale.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Aggiungi/modifica l'URL dell'elenco EPG nelle impostazioni dell'app.", "FETCH_EPG": "Recupero dati EPG...", "ERROR": "Non è stato possibile caricare l'EPG.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Riproduzione archivio", - "RETURN_TO_LIVE": "Torna alla diretta", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Apri menu", "SUPPORT_ARIA": "Supporta IPTVnator su GitHub", - "SETTINGS": "Impostazioni", "SETTINGS_ARIA": "Apri impostazioni", "BUG_REPORT": "Segnala un bug", "WHAT_IS_NEW": "Novità del programma", @@ -804,10 +793,7 @@ "SEARCH": "Cerca", "RECENT": "Recenti", "RECENTLY_ADDED": "Aggiunti di recente", - "FAVORITES": "Preferiti", - "HOME": "Home", - "PLAYLIST_INFO": "Info playlist", - "ACCOUNT_INFO": "Info account" + "FAVORITES": "Preferiti" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 05abf58c3..a10acc880 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -192,10 +192,8 @@ "VALIDATING": "ポータルを検証中..." }, "FILTER_BY_NAME": "名前でフィルター", - "FILTER_BY_TYPE": "種類でフィルター", "FILTER_AND_SORT": "フィルターと並び替え", "SORT_PLAYLISTS": "プレイリストを並び替え", - "SORT_BY": "並び替え", "SORT_OPTIONS": { "NAME_ASC": "名前(A-Z)", "NAME_DESC": "名前(Z-A)", @@ -292,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": "最初に利用可能なビュー", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "チャンネルを検索", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択", "ALL_CHANNELS": "すべてのチャンネル", "GROUPS": "グループ", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "チャンネルリストを開く", - "TOGGLE_FAVORITE_FLAG": "お気に入りフラグを切り替え", - "OPEN_EPG_LIST": "EPGリストを開く", - "OPEN_MULTI_EPG": "マルチEPGビューを開く", - "SHOW_INFO": "番組情報を表示(Iキー)" + "OPEN_MULTI_EPG": "マルチEPGビューを開く" }, "EPG": { - "TITLE": "電子番組表", "NEXT_DAY": "翌日", "PREVIOUS_DAY": "前日", "ZOOM_IN": "拡大", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "フィルターを閉じる", "SEARCH_PROGRAMS": "番組を検索", "CLOSE_PROGRAM_SEARCH": "番組検索を閉じる", - "LIVE_NOW": "現在放送中", "LIVE_STREAM": "ライブストリーム", - "TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "アーカイブ再生は利用できません", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "このプロバイダーはチャンネルの番組履歴を提供していますが、アーカイブ/Catchup再生には対応していません。", - "EPG_NOT_AVAILABLE_DATE": "申し訳ありません。選択した日付のEPGは利用できません", - "NO_GUIDE_HINT": "別の日付を試すか、プレイリストを更新してください。", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "申し訳ありません。このチャンネルのEPGは利用できません。", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "アプリの設定でEPGリストのURLを追加/変更してください。", "FETCH_EPG": "EPGデータを取得中...", "ERROR": "申し訳ありません。EPGを読み込めませんでした。", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "アーカイブ再生", - "RETURN_TO_LIVE": "ライブに戻る", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "メニューを開く", "SUPPORT_ARIA": "GitHubでIPTVnatorをサポート", - "SETTINGS": "設定", "SETTINGS_ARIA": "設定を開く", "BUG_REPORT": "バグを報告", "WHAT_IS_NEW": "新着情報", @@ -804,10 +793,7 @@ "SEARCH": "検索", "RECENT": "最近", "RECENTLY_ADDED": "最近追加した項目", - "FAVORITES": "お気に入り", - "HOME": "ホーム", - "PLAYLIST_INFO": "プレイリスト情報", - "ACCOUNT_INFO": "アカウント情報" + "FAVORITES": "お気に入り" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d8f4ddab9..ddb5efb64 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -192,10 +192,8 @@ "VALIDATING": "포털을 검증하는 중..." }, "FILTER_BY_NAME": "이름으로 필터", - "FILTER_BY_TYPE": "재생목록 유형", "FILTER_AND_SORT": "필터 및 정렬", "SORT_PLAYLISTS": "재생목록 정렬", - "SORT_BY": "정렬 기준", "SORT_OPTIONS": { "NAME_ASC": "이름 (A-Z)", "NAME_DESC": "이름 (Z-A)", @@ -292,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": "첫 번째로 사용 가능한 화면", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "검색 채널", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오", "ALL_CHANNELS": "모든 채널", "GROUPS": "그룹", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "채널 열기 목록", - "TOGGLE_FAVORITE_FLAG": "즐겨찾기 보이기/숨기기", - "OPEN_EPG_LIST": "EPG 목록 열기", - "OPEN_MULTI_EPG": "Open Multi-EPG view", - "SHOW_INFO": "프로그램 정보 표시 (I 키)" + "OPEN_MULTI_EPG": "Open Multi-EPG view" }, "EPG": { - "TITLE": "전자 프로그램 가이드", "NEXT_DAY": "다음날", "PREVIOUS_DAY": "전날", "ZOOM_IN": "확대", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "필터 닫기", "SEARCH_PROGRAMS": "프로그램 검색", "CLOSE_PROGRAM_SEARCH": "프로그램 검색 닫기", - "LIVE_NOW": "Live now", "LIVE_STREAM": "Live stream", - "TIMESHIFT_AVAILABLE": "Timeshift를 사용할 수 있습니다, 클릭하여 재생합니다", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "아카이브 재생을 사용할 수 없습니다", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "이 공급자는 채널의 편성 이력을 제공하지만 재생 가능한 아카이브/Catchup 재생은 제공하지 않습니다.", - "EPG_NOT_AVAILABLE_DATE": "선택한 날짜에는 EPG를 사용할 수 없습니다.", - "NO_GUIDE_HINT": "다른 날짜를 시도하거나 재생 목록을 새로 고치세요.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "이 채널에는 EPG를 사용할 수 없습니다.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "설정에서 EPG의 URL을 추가/변경하십시오.", "FETCH_EPG": "EPG 데이터 가져 오기...", "ERROR": "Ooops, EPG could not be loaded.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "아카이브 재생", - "RETURN_TO_LIVE": "라이브로 돌아가기", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Open menu", "SUPPORT_ARIA": "Support IPTVnator on GitHub", - "SETTINGS": "Settings", "SETTINGS_ARIA": "Open settings", "BUG_REPORT": "Report a bug", "WHAT_IS_NEW": "What is new", @@ -804,10 +793,7 @@ "SEARCH": "검색", "RECENT": "최근", "RECENTLY_ADDED": "최근 추가됨", - "FAVORITES": "즐겨찾기", - "HOME": "홈", - "PLAYLIST_INFO": "재생목록 정보", - "ACCOUNT_INFO": "계정 정보" + "FAVORITES": "즐겨찾기" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 820747178..fd5b80b9e 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -192,10 +192,8 @@ "VALIDATING": "Portaal valideren..." }, "FILTER_BY_NAME": "Filter op naam", - "FILTER_BY_TYPE": "Filter op type", "FILTER_AND_SORT": "Filteren & sorteren", "SORT_PLAYLISTS": "Sorteer afspeellijsten", - "SORT_BY": "Sorteer op", "SORT_OPTIONS": { "NAME_ASC": "Naam (A-Z)", "NAME_DESC": "Naam (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Zoek een kanaal", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst", "ALL_CHANNELS": "Alle kanalen", "GROUPS": "Groepen", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Open kanalenlijst", - "TOGGLE_FAVORITE_FLAG": "Wissel favorietenstatus", - "OPEN_EPG_LIST": "Open TV-gids lijst", - "OPEN_MULTI_EPG": "Open Multi TV-Gids weergave", - "SHOW_INFO": "Programma-info tonen (druk op I)" + "OPEN_MULTI_EPG": "Open Multi TV-Gids weergave" }, "EPG": { - "TITLE": "TV-Gids", "NEXT_DAY": "Volgende dag", "PREVIOUS_DAY": "Vorige dag", "ZOOM_IN": "Inzoomen", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Filter sluiten", "SEARCH_PROGRAMS": "Programma's zoeken", "CLOSE_PROGRAM_SEARCH": "Programmazoekopdracht sluiten", - "LIVE_NOW": "Nu live", "LIVE_STREAM": "Live stream", - "TIMESHIFT_AVAILABLE": "Doorspoelen is beschikbaar, klik om af te spelen", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archiefweergave is niet beschikbaar", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Deze provider toont de programmageschiedenis voor het kanaal, maar biedt geen afspeelbare archief-/catch-up-weergave.", - "EPG_NOT_AVAILABLE_DATE": "De TV-Gids is niet beschikbaar voor de geselecteerde datum", - "NO_GUIDE_HINT": "Probeer een andere datum of vernieuw de afspeellijst.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "De TV-Gids is niet beschikbaar voor dit kanaal.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Voeg een TV-Gids URL toe of wijzig deze in de instellingen van de app.", "FETCH_EPG": "Ophalen TV-Gids...", "ERROR": "TV-Gids kan niet geladen worden.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Archief afspelen", - "RETURN_TO_LIVE": "Terug naar live", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Open menu", "SUPPORT_ARIA": "Ondersteun IPTVnator op GitHub", - "SETTINGS": "Instellingen", "SETTINGS_ARIA": "Open instellingen", "BUG_REPORT": "Meld een bug", "WHAT_IS_NEW": "Wat is er nieuw", @@ -804,10 +793,7 @@ "SEARCH": "Zoeken", "RECENT": "Recent", "RECENTLY_ADDED": "Recent toegevoegd", - "FAVORITES": "Favorieten", - "HOME": "Home", - "PLAYLIST_INFO": "Afspeellijstinfo", - "ACCOUNT_INFO": "Accountinfo" + "FAVORITES": "Favorieten" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 536538da8..7e6e8e2a4 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -192,10 +192,8 @@ "VALIDATING": "Walidacja portalu..." }, "FILTER_BY_NAME": "Filtruj według nazwy", - "FILTER_BY_TYPE": "Filtruj według typu", "FILTER_AND_SORT": "Filtruj i sortuj", "SORT_PLAYLISTS": "Sortuj listy odtwarzania", - "SORT_BY": "Sortuj według", "SORT_OPTIONS": { "NAME_ASC": "Nazwa (A-Z)", "NAME_DESC": "Nazwa (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Szukaj kanału", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania", "ALL_CHANNELS": "Wszystkie kanały", "GROUPS": "Grupy", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Otwórz listę kanałów", - "TOGGLE_FAVORITE_FLAG": "Przełącz flagę ulubionych", - "OPEN_EPG_LIST": "Otwórz listę EPG", - "OPEN_MULTI_EPG": "Otwórz widok Multi-EPG", - "SHOW_INFO": "Pokaż informacje o programie (naciśnij I)" + "OPEN_MULTI_EPG": "Otwórz widok Multi-EPG" }, "EPG": { - "TITLE": "Elektroniczny przewodnik po programach", "NEXT_DAY": "Następny dzień", "PREVIOUS_DAY": "Poprzedni dzień", "ZOOM_IN": "Powiększ", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Zamknij filtr", "SEARCH_PROGRAMS": "Szukaj programów", "CLOSE_PROGRAM_SEARCH": "Zamknij wyszukiwanie programów", - "LIVE_NOW": "Na żywo teraz", "LIVE_STREAM": "Transmisja na żywo", - "TIMESHIFT_AVAILABLE": "Dostępne jest przesunięcie czasowe, kliknij, aby odtworzyć", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Odtwarzanie archiwum niedostępne", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ten dostawca udostępnia historię programu dla kanału, ale nie udostępnia odtwarzalnego archiwum/catch-up.", - "EPG_NOT_AVAILABLE_DATE": "Ups, EPG nie jest dostępne dla wybranej daty", - "NO_GUIDE_HINT": "Spróbuj innej daty lub odśwież playlistę.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, EPG nie jest dostępne dla tego kanału.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Dodaj/zmień URL listy EPG w ustawieniach aplikacji.", "FETCH_EPG": "Pobieranie danych EPG...", "ERROR": "Ups, nie można załadować EPG.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Odtwarzanie archiwum", - "RETURN_TO_LIVE": "Wróć do transmisji na żywo", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Otwórz menu", "SUPPORT_ARIA": "Wsparcie IPTVnator na GitHubie", - "SETTINGS": "Ustawienia", "SETTINGS_ARIA": "Otwórz ustawienia", "BUG_REPORT": "Zgłoś błąd", "WHAT_IS_NEW": "Co nowego", @@ -804,10 +793,7 @@ "SEARCH": "Szukaj", "RECENT": "Ostatnie", "RECENTLY_ADDED": "Ostatnio dodane", - "FAVORITES": "Ulubione", - "HOME": "Strona główna", - "PLAYLIST_INFO": "Informacje o liście odtwarzania", - "ACCOUNT_INFO": "Informacje o koncie" + "FAVORITES": "Ulubione" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 6a17c720b..6d11cc496 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -192,10 +192,8 @@ "VALIDATING": "Validando portal..." }, "FILTER_BY_NAME": "Filtrar por nome", - "FILTER_BY_TYPE": "Filtrar por tipo", "FILTER_AND_SORT": "Filtrar e ordenar", "SORT_PLAYLISTS": "Ordenar playlists", - "SORT_BY": "Ordenar por", "SORT_OPTIONS": { "NAME_ASC": "Nome (A-Z)", "NAME_DESC": "Nome (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Pesquisar canal", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist", "ALL_CHANNELS": "Todos os canais", "GROUPS": "Grupos", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Abrir lista de canais", - "TOGGLE_FAVORITE_FLAG": "Alternar sinalizador de favorito", - "OPEN_EPG_LIST": "Abrir lista de EPG", - "OPEN_MULTI_EPG": "Abrir visualização Multi-EPG", - "SHOW_INFO": "Mostrar informações do programa (pressione I)" + "OPEN_MULTI_EPG": "Abrir visualização Multi-EPG" }, "EPG": { - "TITLE": "Guia Eletrônico de Programação", "NEXT_DAY": "Próximo dia", "PREVIOUS_DAY": "Dia anterior", "ZOOM_IN": "Mais zoom", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Fechar filtro", "SEARCH_PROGRAMS": "Pesquisar programas", "CLOSE_PROGRAM_SEARCH": "Fechar pesquisa de programas", - "LIVE_NOW": "Ao vivo agora", "LIVE_STREAM": "Transmissão ao vivo", - "TIMESHIFT_AVAILABLE": "Timeshift está disponível, clique para reproduzir", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Reprodução de arquivo indisponível", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este provedor expõe o histórico de programação do canal, mas não disponibiliza reprodução de arquivo/Catchup.", - "EPG_NOT_AVAILABLE_DATE": "Ooops, EPG não está disponível para a data selecionada", - "NO_GUIDE_HINT": "Tente uma data diferente ou atualize a lista de reprodução.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG não está disponível para este canal.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Adicione/altere a URL da lista de EPG nas configurações do aplicativo.", "FETCH_EPG": "Buscando dados do EPG...", "ERROR": "Ooops, EPG não pôde ser carregado.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Reprodução do arquivo", - "RETURN_TO_LIVE": "Voltar ao vivo", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Abrir menu", "SUPPORT_ARIA": "Apoie o IPTVnator no GitHub", - "SETTINGS": "Configurações", "SETTINGS_ARIA": "Abrir configurações", "BUG_REPORT": "Reportar um bug", "WHAT_IS_NEW": "O que há de novo", @@ -804,10 +793,7 @@ "SEARCH": "Pesquisar", "RECENT": "Recente", "RECENTLY_ADDED": "Adicionados recentemente", - "FAVORITES": "Favoritos", - "HOME": "Início", - "PLAYLIST_INFO": "Informações da playlist", - "ACCOUNT_INFO": "Informações da conta" + "FAVORITES": "Favoritos" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8e33f5544..bea6d789a 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -192,10 +192,8 @@ "VALIDATING": "Проверка портала…" }, "FILTER_BY_NAME": "Фильтровать по имени", - "FILTER_BY_TYPE": "Типы плейлистов", "FILTER_AND_SORT": "Фильтр и сортировка", "SORT_PLAYLISTS": "Сортировать плейлисты", - "SORT_BY": "Сортировать по", "SORT_OPTIONS": { "NAME_ASC": "Имени (А-Я)", "NAME_DESC": "Имени (Я-А)", @@ -292,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": "Первый доступный экран", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Поиск каналов", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист", "ALL_CHANNELS": "Все каналы", "GROUPS": "Группы", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Открыть список каналов", - "TOGGLE_FAVORITE_FLAG": "Избранный", - "OPEN_EPG_LIST": "Открыть программу (EPG)", - "OPEN_MULTI_EPG": "Открыть Multi-EPG вид", - "SHOW_INFO": "Показать информацию о программе (нажмите I)" + "OPEN_MULTI_EPG": "Открыть Multi-EPG вид" }, "EPG": { - "TITLE": "Электронный программный гид", "NEXT_DAY": "Следующий день", "PREVIOUS_DAY": "Предыдущий день", "ZOOM_IN": "Увеличить", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Закрыть фильтр", "SEARCH_PROGRAMS": "Поиск программ", "CLOSE_PROGRAM_SEARCH": "Закрыть поиск программ", - "LIVE_NOW": "Сейчас", "LIVE_STREAM": "Прямая трансляция", - "TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Воспроизведение архива недоступно", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Этот провайдер предоставляет историю программы для канала, но не предоставляет воспроизводимый архив/catch-up.", - "EPG_NOT_AVAILABLE_DATE": "Ой, программа на этот день, к сожалению, отсутствует.", - "NO_GUIDE_HINT": "Попробуйте другую дату или обновите плейлист.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Программа для этого канала недоступна.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.", "FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...", "ERROR": "Ой, не удалось загрузить программу каналов (EPG)", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Архивная запись", - "RETURN_TO_LIVE": "Вернуться к эфиру", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Открыть меню", "SUPPORT_ARIA": "Поддержать IPTVnator на GitHub", - "SETTINGS": "Настройки", "SETTINGS_ARIA": "Открыть настройки", "BUG_REPORT": "Сообщить об ошибке", "WHAT_IS_NEW": "Что нового?", @@ -804,10 +793,7 @@ "SEARCH": "Поиск", "RECENT": "Недавние", "RECENTLY_ADDED": "Недавно добавленные", - "FAVORITES": "Избранное", - "HOME": "Домой", - "PLAYLIST_INFO": "Информация о плейлисте", - "ACCOUNT_INFO": "Информация об аккаунте" + "FAVORITES": "Избранное" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 983481f44..c6098f6ca 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -192,10 +192,8 @@ "VALIDATING": "Portal doğrulanıyor..." }, "FILTER_BY_NAME": "İsime göre filtrele", - "FILTER_BY_TYPE": "Türe göre filtrele", "FILTER_AND_SORT": "Filtrele ve Sırala", "SORT_PLAYLISTS": "Oynatma listelerini sırala", - "SORT_BY": "Sıralama ölçütü", "SORT_OPTIONS": { "NAME_ASC": "İsim (A-Z)", "NAME_DESC": "İsim (Z-A)", @@ -292,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", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Kanal ara", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin", "ALL_CHANNELS": "Tüm kanallar", "GROUPS": "Gruplar", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "Kanal listesini aç", - "TOGGLE_FAVORITE_FLAG": "Favori işaretini değiştir", - "OPEN_EPG_LIST": "EPG listesini aç", - "OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç", - "SHOW_INFO": "Program bilgisini göster (I tuşuna basın)" + "OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç" }, "EPG": { - "TITLE": "Elektronik Program Rehberi", "NEXT_DAY": "Sonraki gün", "PREVIOUS_DAY": "Önceki gün", "ZOOM_IN": "Yakınlaştır", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "Filtreyi kapat", "SEARCH_PROGRAMS": "Programları ara", "CLOSE_PROGRAM_SEARCH": "Program aramayı kapat", - "LIVE_NOW": "Şu an canlı", "LIVE_STREAM": "Canlı yayın", - "TIMESHIFT_AVAILABLE": "Zaman kaydırma mevcut, tıklayarak oynat", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Arşiv oynatma kullanılamıyor", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Bu sağlayıcı kanal için yayın akışı geçmişini sunar, ancak oynatılabilir arşiv/Catchup oynatma sunmaz.", - "EPG_NOT_AVAILABLE_DATE": "Üzgünüz, seçilen tarih için EPG mevcut değil", - "NO_GUIDE_HINT": "Başka bir tarih deneyin veya oynatma listesini yenileyin.", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Üzgünüz, bu kanal için EPG mevcut değil.", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Lütfen uygulamanın ayarlarında EPG listesi URL'sini ekleyin veya değiştirin.", "FETCH_EPG": "EPG verileri alınıyor...", "ERROR": "Üzgünüz, EPG yüklenemedi.", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "Arşiv oynatma", - "RETURN_TO_LIVE": "Canlı yayına dön", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "Menüyü aç", "SUPPORT_ARIA": "IPTVnator'u GitHub'da destekleyin", - "SETTINGS": "Ayarlar", "SETTINGS_ARIA": "Ayarları aç", "BUG_REPORT": "Hata bildir", "WHAT_IS_NEW": "Yeni ne var?", @@ -804,10 +793,7 @@ "SEARCH": "Ara", "RECENT": "Son İzlenenler", "RECENTLY_ADDED": "Yeni Eklenenler", - "FAVORITES": "Favoriler", - "HOME": "Ana Sayfa", - "PLAYLIST_INFO": "Oynatma Listesi Bilgisi", - "ACCOUNT_INFO": "Hesap Bilgisi" + "FAVORITES": "Favoriler" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index c867c4c64..a2f7ea162 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -192,10 +192,8 @@ "VALIDATING": "正在验证门户…" }, "FILTER_BY_NAME": "按名称筛选", - "FILTER_BY_TYPE": "播放列表类型", "FILTER_AND_SORT": "筛选与排序", "SORT_PLAYLISTS": "排序播放列表", - "SORT_BY": "排序方式", "SORT_OPTIONS": { "NAME_ASC": "名称(A-Z)", "NAME_DESC": "名称(Z-A)", @@ -292,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": "首个可用视图", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "搜索频道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表", "ALL_CHANNELS": "所有频道", "GROUPS": "分组", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "打开频道列表", - "TOGGLE_FAVORITE_FLAG": "切换收藏夹标志", - "OPEN_EPG_LIST": "打开电子节目单EPG列表", - "OPEN_MULTI_EPG": "打开多EPG显示", - "SHOW_INFO": "显示节目信息(按 I 键)" + "OPEN_MULTI_EPG": "打开多EPG显示" }, "EPG": { - "TITLE": "电子节目指南", "NEXT_DAY": "明天", "PREVIOUS_DAY": "昨天", "ZOOM_IN": "放大", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "关闭筛选", "SEARCH_PROGRAMS": "搜索节目", "CLOSE_PROGRAM_SEARCH": "关闭节目搜索", - "LIVE_NOW": "正在播放", "LIVE_STREAM": "正在直播", - "TIMESHIFT_AVAILABLE": "时移功能可用,点击播放", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "归档播放不可用", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此提供商提供了该频道的节目历史,但不提供可播放的归档/Catchup 回看。", - "EPG_NOT_AVAILABLE_DATE": "错误:电子节目单EPG在所选日期不可用", - "NO_GUIDE_HINT": "尝试其他日期或刷新播放列表。", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "错误:电子节目单EPG不适用于此频道", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "请添加/更改设置项中电子节目单的URL", "FETCH_EPG": "获取 EPG 数据...", "ERROR": "错误: 获取 EPG 数据失败", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "回看播放", - "RETURN_TO_LIVE": "返回直播", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "打开菜单", "SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator", - "SETTINGS": "设置", "SETTINGS_ARIA": "打开设置", "BUG_REPORT": "问题反馈", "WHAT_IS_NEW": "更新日志", @@ -804,10 +793,7 @@ "SEARCH": "搜索", "RECENT": "最近", "RECENTLY_ADDED": "最近添加", - "FAVORITES": "收藏", - "HOME": "首页", - "PLAYLIST_INFO": "播放列表信息", - "ACCOUNT_INFO": "账户信息" + "FAVORITES": "收藏" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index a88dcda85..98cc1773d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -192,10 +192,8 @@ "VALIDATING": "正在驗證入口網站..." }, "FILTER_BY_NAME": "依名稱篩選", - "FILTER_BY_TYPE": "播放清單類型", "FILTER_AND_SORT": "篩選與排序", "SORT_PLAYLISTS": "排序播放清單", - "SORT_BY": "排序依據", "SORT_OPTIONS": { "NAME_ASC": "名稱(A-Z)", "NAME_DESC": "名稱(Z-A)", @@ -292,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": "第一個可用畫面", @@ -483,7 +485,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "搜尋頻道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單", "ALL_CHANNELS": "所有頻道", "GROUPS": "群組", @@ -557,14 +558,9 @@ } }, "TOP_MENU": { - "OPEN_CHANNELS_LIST": "打開頻道列表", - "TOGGLE_FAVORITE_FLAG": "切換我的最愛標記", - "OPEN_EPG_LIST": "開啟 EPG 列表", - "OPEN_MULTI_EPG": "開啟多 EPG 視圖", - "SHOW_INFO": "顯示節目資訊(按 I 鍵)" + "OPEN_MULTI_EPG": "開啟多 EPG 視圖" }, "EPG": { - "TITLE": "電子節目指南", "NEXT_DAY": "下一天", "PREVIOUS_DAY": "上一天", "ZOOM_IN": "放大", @@ -573,14 +569,9 @@ "CLOSE_FILTER": "關閉篩選", "SEARCH_PROGRAMS": "搜尋節目", "CLOSE_PROGRAM_SEARCH": "關閉節目搜尋", - "LIVE_NOW": "現在播放", "LIVE_STREAM": "現場直播", - "TIMESHIFT_AVAILABLE": "時間平移功能可用,點選以播放", "ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "無法播放存檔內容", "ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此供應商有提供本頻道的歷史節目表,但未提供可播放的存檔/Catchup 播放功能。", - "EPG_NOT_AVAILABLE_DATE": "抱歉,選擇的日期上 EPG 無法使用", - "NO_GUIDE_HINT": "嘗試其他日期或重新整理播放清單。", - "EPG_NOT_AVAILABLE_CHANNEL_TITLE": "抱歉,此頻道上的 EPG 無法使用。", "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "請在應用程式設定中增加/更改 EPG 列表的URL。", "FETCH_EPG": "取得 EPG 資料...", "ERROR": "抱歉,無法載入 EPG。", @@ -615,7 +606,6 @@ "TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.", "CURRENT_PROGRAM": "Current program", "ARCHIVE_PLAYBACK": "回看播放", - "RETURN_TO_LIVE": "返回直播", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", "TIMELINE": { @@ -679,7 +669,6 @@ "MENU": { "OPEN": "開啟選單", "SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator", - "SETTINGS": "設定", "SETTINGS_ARIA": "開啟設定", "BUG_REPORT": "回報問題", "WHAT_IS_NEW": "更新內容", @@ -804,10 +793,7 @@ "SEARCH": "搜尋", "RECENT": "最近觀看", "RECENTLY_ADDED": "最近新增", - "FAVORITES": "我的最愛", - "HOME": "首頁", - "PLAYLIST_INFO": "播放清單資訊", - "ACCOUNT_INFO": "帳戶資訊" + "FAVORITES": "我的最愛" }, "ERROR_VIEW": { "IMPORT_CANCELLED": { diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index c4d00706e..60e038639 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -96,10 +96,6 @@ app-root.macos-platform .current-playlist { padding-top: 30px; } -app-root.macos-platform app-navigation .portal-status-container { - padding-top: 30px; -} - // Windows/Linux frameless title bar: reserve room in top-aligned drag // regions for the renderer-drawn window controls (3 × 46px buttons). // Body-level class so it also reaches cdk-overlay content (multi-EPG). diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 810785905..d0d3f9f29 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -28,10 +28,10 @@ Use it when changing existing views or introducing new list-based UI in the work `libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html` - Channel row styles: `libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss` -- Shared EPG pane: - `libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html` -- Shared EPG pane styles: - `libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss` +- Shared EPG timeline: + `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html` +- Shared EPG timeline styles: + `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss` - Shared list selection style: `apps/web/src/nav-list.scss` - Theme tokens: @@ -141,12 +141,11 @@ The shared row should be reused instead of rebuilding channel markup per view. ### Collapsible Live EPG -- Live TV layouts with an internal player use `app-live-epg-panel` around the +- Live TV layouts with an internal player render `app-epg-timeline` as the EPG content, including playlist-specific live pages and the global favorites/recent live tabs. -- The live panel toolbar owns the current-program summary and live date - navigation together, so `app-epg-list` hides its internal date navigator when - projected inside that panel. +- The timeline's own panel bar owns the current-program summary and live date + navigation together; there is no separate wrapper component around it. - Collapsed state is shared across M3U, Xtream, and Stalker with `live-epg-panel-state`; missing or invalid values restore to expanded. - The collapsed panel is a slim current-program strip with a trailing progress @@ -289,7 +288,7 @@ Settings use the same system but are flatter than content-heavy views. Before creating new markup or CSS: 1. Check whether `app-channel-list-item` can be reused. -2. Check whether `app-epg-view` already provides the correct structure. +2. Check whether `app-epg-timeline` already provides the correct structure. 3. Check whether `nav-list.scss` already solves the list-selection problem. 4. Extend tokens first, duplicate styles last. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index be56e4055..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 @@ -564,8 +598,8 @@ class EpgService { | Component | Purpose | | ----------------------------- | ------------------------------------ | -| `EpgListComponent` | Timeline view for single channel | -| `EpgListItemComponent` | Individual program in timeline | +| `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/stalker-epg.md b/docs/architecture/stalker-epg.md index a1b7e8914..80bf997fe 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -14,7 +14,7 @@ Stalker now uses two EPG paths with different purposes: - The active channel EPG panel uses `get_epg_info` as a bulk endpoint, fetches a 7-day window once per playlist session, caches programs by channel id, and - renders the selected channel through the shared `app-epg-list` component. + renders the selected channel through the shared `app-epg-timeline` component. - Channel rows no longer send preview EPG requests during initial category load. They stay empty until bulk EPG has been fetched once, then derive their current program and progress bar from the cached bulk map. @@ -39,7 +39,7 @@ date-navigator UI used in the M3U/Xtream flows. │ │ ensureBulkItvEpg(168) │ │ │ selectedItvEpgPrograms() │ │ ▼ │ │ -│ current program preview ├── bulk hit → app-epg-list │ +│ current program preview ├── bulk hit → app-epg-timeline │ │ after first bulk load └── empty/unsupported → short fallback │ └────────────────────────────────────────────────────────────────────────────┘ │ │ @@ -166,7 +166,7 @@ Key mapped fields: ### Active panel data (`get_epg_info` / fallback) → `EpgProgram` -The active panel uses controlled `EpgProgram[]` because `app-epg-list` filters +The active panel uses controlled `EpgProgram[]` because `app-epg-timeline` filters and groups by day. Normalization rules: @@ -183,10 +183,9 @@ Normalization rules: | File | Purpose | | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling | -| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring | +| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-timeline` wiring | | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template | -| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading | -| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator | +| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts` | shared controlled EPG timeline with date navigator | ### Store API @@ -210,7 +209,7 @@ playlists. 1. User activates a live channel 2. The component ensures playback link resolution as before 3. The component calls `ensureBulkItvEpg(168)` on first use for the playlist -4. `selectedItvEpgPrograms()` feeds `app-epg-list` +4. `selectedItvEpgPrograms()` feeds `app-epg-timeline` 5. If the selected channel has no bulk programs, the component falls back to `get_short_epg` @@ -267,5 +266,5 @@ is available. ## Future Enhancements - add cache refresh / invalidation for long-running live sessions -- add Stalker catch-up support to `app-epg-list` once the playback flow exists +- add Stalker catch-up support to `app-epg-timeline` once the playback flow exists - optionally add category-level prefetch timing metrics for bulk EPG 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 @@
- + @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..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 @@ -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, + resolvedEpgViewMode: 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..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 @@ -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,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { restoreLiveEpgPanelState() ); readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts index 0ec759655..112ec10dc 100644 --- a/libs/playlist/shared/ui/src/index.ts +++ b/libs/playlist/shared/ui/src/index.ts @@ -1,5 +1,4 @@ export * from './lib/add-playlist-menu/playlist-type'; -export * from './lib/filter-sort-menu/filter-sort-menu.component'; export * from './lib/playlist-switcher/playlist-switcher.component'; export * from './lib/recent-playlists/playlist-info/playlist-info.component'; export * from './lib/recent-playlists/recent-playlists.component'; diff --git a/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.html b/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.html deleted file mode 100644 index fb7056325..000000000 --- a/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.html +++ /dev/null @@ -1,140 +0,0 @@ - -
- - @for (type of playlistTypes; track type.id) { - - } -
- -
- - - - - - -
- - -
diff --git a/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.scss b/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.scss deleted file mode 100644 index 3bbf88383..000000000 --- a/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.scss +++ /dev/null @@ -1,87 +0,0 @@ -.filter-section, -.sort-section { - padding: 4px 0; -} - -.menu-section-header { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px 4px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - pointer-events: none; - - .section-icon { - font-size: 16px; - width: 16px; - height: 16px; - color: rgba(139, 92, 246, 0.6); - } -} - -::ng-deep .filter-sort-menu { - .mat-mdc-menu-item { - display: flex !important; - align-items: center !important; - gap: 12px !important; - min-height: 40px !important; - padding: 0 16px !important; - - .mat-icon:first-child { - margin-right: 0 !important; - flex-shrink: 0; - } - - .menu-item-text { - flex: 1; - text-align: left; - } - - .check-icon { - margin-left: auto !important; - margin-right: 0 !important; - flex-shrink: 0; - opacity: 0; - transform: scale(0.8); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &.check-visible { - opacity: 1; - transform: scale(1); - } - } - } -} - -.active-option { - background-color: rgba(139, 92, 246, 0.12) !important; - position: relative; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 60%; - background: rgba(139, 92, 246, 0.8); - border-radius: 0 2px 2px 0; - } -} - -.check-icon { - font-size: 18px !important; - width: 18px !important; - height: 18px !important; - color: rgba(139, 92, 246, 1) !important; -} - -.menu-footer { - padding: 4px 8px 4px; - display: flex; - justify-content: center; -} diff --git a/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.ts b/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.ts deleted file mode 100644 index 91c76e1da..000000000 --- a/libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - Component, - effect, - inject, - input, - output, - viewChild, -} from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; -import { Store } from '@ngrx/store'; -import { TranslateModule } from '@ngx-translate/core'; -import { FilterActions, selectActiveTypeFilters } from '@iptvnator/m3u-state'; -import { SortBy, SortOrder, SortService } from '@iptvnator/services'; - -@Component({ - selector: 'app-filter-sort-menu', - templateUrl: './filter-sort-menu.component.html', - styleUrls: ['./filter-sort-menu.component.scss'], - imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule], -}) -export class FilterSortMenuComponent { - private readonly store = inject(Store); - private readonly sortService = inject(SortService); - - readonly menu = viewChild.required('filterSortMenu'); - readonly menuTrigger = input(); - readonly filterChanged = output(); - - playlistTypes = [ - { - id: 'm3u', - icon: 'playlist_play', - translationKey: 'HOME.PLAYLIST_TYPES.M3U', - checked: true, - }, - { - id: 'xtream', - icon: 'cloud', - translationKey: 'HOME.PLAYLIST_TYPES.XTREAM', - checked: true, - }, - { - id: 'stalker', - icon: 'router', - translationKey: 'HOME.PLAYLIST_TYPES.STALKER', - checked: true, - }, - ]; - - private readonly selectedTypeFilters = this.store.selectSignal( - selectActiveTypeFilters - ); - - readonly SortBy = SortBy; - readonly SortOrder = SortOrder; - private readonly currentSortOptions = toSignal( - this.sortService.getSortOptions(), - { - requireSync: true, - } - ); - - constructor() { - effect(() => { - if (this.selectedTypeFilters) { - this.playlistTypes = this.playlistTypes.map((type) => { - type.checked = this.selectedTypeFilters().includes(type.id); - return type; - }); - } - }); - } - - togglePlaylistType(type: { - id: string; - icon: string; - translationKey: string; - checked: boolean; - }) { - const currentlySelectedCount = this.playlistTypes.filter( - (item) => item.checked - ).length; - - if (type.checked && currentlySelectedCount === 1) { - return; - } - - type.checked = !type.checked; - this.store.dispatch( - FilterActions.setSelectedFilters({ - selectedFilters: this.playlistTypes - .filter((item) => item.checked) - .map((item) => item.id), - }) - ); - this.filterChanged.emit(); - } - - setSortOptions(by: SortBy, order: SortOrder): void { - this.sortService.setSortOptions({ by, order }); - } - - isSortActive(by: SortBy, order: SortOrder): boolean { - const options = this.currentSortOptions(); - return options?.by === by && options?.order === order; - } - - get activeFiltersCount(): number { - return this.playlistTypes.filter((type) => !type.checked).length; - } - - closeMenu(): void { - this.menuTrigger()?.closeMenu(); - } -} diff --git a/libs/portal/shared/ui/src/index.ts b/libs/portal/shared/ui/src/index.ts index a95b5fa20..4b6df6e72 100644 --- a/libs/portal/shared/ui/src/index.ts +++ b/libs/portal/shared/ui/src/index.ts @@ -6,7 +6,6 @@ export * from './lib/components/global-favorites-list/global-favorites-list.comp export * from './lib/components/grid-list/grid-list.component'; export * from './lib/components/playlist-error-view/playlist-error-view.component'; export { ProgressCapsuleComponent } from '@iptvnator/ui/components'; -export * from './lib/components/portal-collection-shell/portal-collection-shell.component'; export * from './lib/components/portal-empty-state/portal-empty-state.component'; export * from './lib/components/search-form/search-form.component'; export * from './lib/components/search-layout/search-layout.component'; @@ -14,5 +13,4 @@ export { WatchedBadgeComponent } from '@iptvnator/ui/components'; export * from './lib/components/unified-collection/unified-collection-page.component'; export * from './lib/components/unified-collection/unified-collection-detail.directive'; export * from './lib/components/unified-collection/unified-live-tab.component'; -export * from './lib/navigation/navigation.component'; export * from './lib/navigation/portal-rail-links.component'; diff --git a/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.html b/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.html deleted file mode 100644 index 81f2d5a7d..000000000 --- a/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.html +++ /dev/null @@ -1,29 +0,0 @@ -@switch (mode()) { - @case ('detail') { - - } - @case ('live') { - - } - @default { - - } -} diff --git a/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts b/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts deleted file mode 100644 index d468194f5..000000000 --- a/libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { XtreamCategory } from '@iptvnator/shared/interfaces'; -import { - FavoriteLayoutItem, - FavoritesLayoutComponent, -} from '../favorites-layout/favorites-layout.component'; - -interface PortalCategorySelection { - readonly category_id?: string | number; - readonly id?: string | number; -} - -export interface PortalCollectionShellLayout { - titleTranslationKey?: string; - removeTooltip?: string; - emptyIcon?: string; - showHeaderAction?: boolean; - headerActionIcon?: string; - headerActionTooltip?: string; -} - -export type PortalCollectionMode = 'grid' | 'detail' | 'live'; - -@Component({ - selector: 'app-portal-collection-shell', - imports: [FavoritesLayoutComponent], - templateUrl: './portal-collection-shell.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - ` - :host { - display: block; - height: 100%; - width: 100%; - } - `, - ], -}) -export class PortalCollectionShellComponent { - readonly categories = input([]); - readonly items = input([]); - readonly playlistSubtitle = input(''); - readonly playlistTitle = input('Playlist'); - readonly selectedCategoryId = input('all'); - readonly layout = input({}); - readonly mode = input('grid'); - - readonly categoryClicked = output(); - readonly removeItem = output(); - readonly openItem = output(); - readonly headerActionClicked = output(); - - onCategoryClicked(event: PortalCategorySelection): void { - const categoryId = event.category_id ?? event.id; - if (categoryId == null) { - return; - } - - this.categoryClicked.emit(String(categoryId)); - } -} 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.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss index 829904eab..8f442d3b0 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss @@ -67,8 +67,8 @@ display: flex; flex-direction: column; - > app-epg-view, - > app-epg-list { + > app-epg-timeline, + > app-epg-list-view { flex: 1; min-height: 0; } 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..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 @@ -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, + resolvedEpgViewMode: 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..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 @@ -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,8 @@ export class UnifiedLiveTabComponent { readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly liveEpgPanelSummary = computed(() => { this.progressTick(); return this.getLiveEpgPanelSummary(this.activeDetail()); diff --git a/libs/portal/shared/ui/src/lib/navigation/navigation.component.html b/libs/portal/shared/ui/src/lib/navigation/navigation.component.html deleted file mode 100644 index 76dcf67fc..000000000 --- a/libs/portal/shared/ui/src/lib/navigation/navigation.component.html +++ /dev/null @@ -1,78 +0,0 @@ -
- {{ getStatusIcon() }} -
- - - - - - - - diff --git a/libs/portal/shared/ui/src/lib/navigation/navigation.component.scss b/libs/portal/shared/ui/src/lib/navigation/navigation.component.scss deleted file mode 100644 index d12c17c3e..000000000 --- a/libs/portal/shared/ui/src/lib/navigation/navigation.component.scss +++ /dev/null @@ -1,116 +0,0 @@ -@use '../../../../../../../apps/web/src/nav-list.scss'; - -:host { - padding: 4px; - display: flex; - flex-direction: column; - height: 100%; -} - -.mdc-list { - padding: 2px 0; -} - -.mat-nav-list { - padding: 0; - margin: 0; - - .portal-nav-link.mdc-list-item { - min-height: 28px; - height: 28px; - padding: 0 !important; - border-radius: 10px; - margin: 2px 0; - display: flex; - justify-content: center; - color: var(--mat-sys-on-surface-variant); - - .mdc-list-item__start { - margin-inline-end: 0 !important; - margin-inline-start: 0 !important; - } - - .mdc-list-item__content { - display: none; - } - } - - mat-icon.mat-mdc-list-item-icon, - mat-icon { - font-size: 18px !important; - width: 18px !important; - height: 18px !important; - } -} - -.navigation-bottom { - margin-top: auto; - padding-bottom: 2px; -} - -.active { - color: var(--app-selection-color); - - mat-icon { - color: var(--app-selection-color); - } -} - -mat-divider { - margin: 1px 0; -} - -mat-icon { - margin-right: 0; - font-size: 18px !important; - width: 18px !important; - height: 18px !important; -} - -a.mat-mdc-list-item.mdc-list-item { - padding-right: 0; -} - -.portal-status-container { - app-region: drag; - margin: -4px -4px 0; - height: 42px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - background: var(--mat-sys-surface-container); - color: var(--mat-sys-on-surface-variant); - border: 1px solid var(--mat-sys-outline-variant); -} - -.portal-status-container.status-active { - background: var(--mat-sys-primary-container); - color: var(--mat-sys-on-primary-container); -} - -.portal-status-container.status-inactive, -.portal-status-container.status-unavailable { - background: var(--mat-sys-error-container); - color: var(--mat-sys-on-error-container); -} - -.portal-status-container.status-expired { - background: var(--mat-sys-tertiary-container); - color: var(--mat-sys-on-tertiary-container); -} - -.portal-status-container .status-watermark { - font-size: 24px !important; - width: 24px !important; - height: 24px !important; - margin: 0; -} - -.nav-section { - app-portal-rail-links { - display: block; - padding: 0; - margin: 0; - } -} diff --git a/libs/portal/shared/ui/src/lib/navigation/navigation.component.spec.ts b/libs/portal/shared/ui/src/lib/navigation/navigation.component.spec.ts deleted file mode 100644 index 320625478..000000000 --- a/libs/portal/shared/ui/src/lib/navigation/navigation.component.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { TranslateModule } from '@ngx-translate/core'; -import { PORTAL_NAVIGATION_ACTIONS } from '@iptvnator/portal/shared/util'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { Playlist } from '@iptvnator/shared/interfaces'; -import { NavigationComponent } from './navigation.component'; - -describe('NavigationComponent', () => { - let playlist: ReturnType | undefined>>; - let runtime: { - supportsDownloads: boolean; - }; - - beforeEach(async () => { - playlist = signal | undefined>({ - _id: 'xtream-1', - }); - runtime = { - supportsDownloads: true, - }; - - await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), NavigationComponent], - providers: [ - { - provide: ActivatedRoute, - useValue: { - snapshot: { - params: { - id: 'xtream-1', - }, - }, - }, - }, - { - provide: Store, - useValue: { - selectSignal: jest.fn(() => playlist), - }, - }, - { - provide: PORTAL_NAVIGATION_ACTIONS, - useValue: { - openAccountInfo: jest.fn(), - openPlaylistInfo: jest.fn(), - openSettings: jest.fn(), - }, - }, - { - provide: RuntimeCapabilitiesService, - useValue: runtime, - }, - ], - }).compileComponents(); - }); - - function createComponent(): NavigationComponent { - return TestBed.createComponent(NavigationComponent).componentInstance; - } - - it('includes downloads in portal rail links when downloads are supported', () => { - runtime.supportsDownloads = true; - - const component = createComponent(); - - expect(component.secondaryLinks().map((link) => link.section)).toEqual([ - 'recently-added', - 'search', - 'downloads', - ]); - }); - - it('hides downloads in portal rail links when downloads are unsupported', () => { - runtime.supportsDownloads = false; - - const component = createComponent(); - - expect(component.secondaryLinks().map((link) => link.section)).toEqual([ - 'recently-added', - 'search', - ]); - }); - - it('uses the Stalker rail shape for Stalker playlists', () => { - runtime.supportsDownloads = true; - playlist.set({ - _id: 'stalker-1', - macAddress: '00:1A:79:00:00:01', - }); - - const component = createComponent(); - - expect(component.primaryLinks().map((link) => link.section)).toEqual([ - 'vod', - 'itv', - 'radio', - 'series', - ]); - expect(component.secondaryLinks().map((link) => link.section)).toEqual([ - 'search', - 'downloads', - ]); - }); - - it('hides Stalker downloads when downloads are unsupported', () => { - runtime.supportsDownloads = false; - playlist.set({ - _id: 'stalker-1', - macAddress: '00:1A:79:00:00:01', - }); - - const component = createComponent(); - - expect(component.secondaryLinks().map((link) => link.section)).toEqual([ - 'search', - ]); - }); -}); diff --git a/libs/portal/shared/ui/src/lib/navigation/navigation.component.ts b/libs/portal/shared/ui/src/lib/navigation/navigation.component.ts deleted file mode 100644 index fae267ff1..000000000 --- a/libs/portal/shared/ui/src/lib/navigation/navigation.component.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Component, computed, inject, input } from '@angular/core'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatIcon } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { MatTooltip } from '@angular/material/tooltip'; -import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { TranslatePipe } from '@ngx-translate/core'; -import { - buildPortalRailLinks, - PORTAL_NAVIGATION_ACTIONS, - PortalRailSection, -} from '@iptvnator/portal/shared/util'; -import { selectPlaylistById } from '@iptvnator/m3u-state'; -import { RuntimeCapabilitiesService } from '@iptvnator/services'; -import { Playlist } from '@iptvnator/shared/interfaces'; -import { PortalRailLinksComponent } from './portal-rail-links.component'; - -@Component({ - selector: 'app-navigation', - imports: [ - MatDividerModule, - MatIcon, - MatListModule, - MatTooltip, - RouterLink, - RouterLinkActive, - TranslatePipe, - PortalRailLinksComponent, - ], - templateUrl: './navigation.component.html', - styleUrl: './navigation.component.scss', -}) -export class NavigationComponent { - private readonly activatedRoute = inject(ActivatedRoute); - private readonly navigationActions = inject(PORTAL_NAVIGATION_ACTIONS); - private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly store = inject(Store); - - readonly portalStatus = input< - 'active' | 'inactive' | 'expired' | 'unavailable' - >(); - readonly selectedContentType = input(); - - readonly currentPlaylist = this.store.selectSignal( - selectPlaylistById(this.activatedRoute.snapshot.params.id) - ); - - readonly isStalkerPlaylist = computed( - () => !!(this.currentPlaylist() as Playlist | undefined)?.macAddress - ); - - get supportsDownloads(): boolean { - return this.runtime.supportsDownloads; - } - - readonly railLinks = computed(() => { - const playlistId = - (this.currentPlaylist() as Playlist | undefined)?._id ?? - this.activatedRoute.snapshot.params['id']; - if (!playlistId) { - return { primary: [], secondary: [] }; - } - - return buildPortalRailLinks({ - provider: this.isStalkerPlaylist() ? 'stalker' : 'xtreams', - playlistId, - supportsDownloads: this.supportsDownloads, - workspace: false, - }); - }); - readonly primaryLinks = computed(() => this.railLinks().primary); - readonly secondaryLinks = computed(() => this.railLinks().secondary); - - getStatusColor(): string { - if (this.isStalkerPlaylist()) return 'status-active'; - - switch (this.portalStatus()) { - case 'active': - return 'status-active'; - case 'inactive': - return 'status-inactive'; - case 'expired': - return 'status-expired'; - default: - return 'status-unavailable'; - } - } - - getStatusIcon(): string { - if (this.isStalkerPlaylist()) return 'play_circle'; - - switch (this.portalStatus()) { - case 'active': - return 'check_circle'; - case 'inactive': - return 'cancel'; - case 'expired': - return 'warning'; - default: - return 'error'; - } - } - - openAccountInfo(): void { - this.navigationActions.openAccountInfo(); - } - - openSettings(): void { - this.navigationActions.openSettings(); - } - - openPlaylistInfo(): void { - this.navigationActions.openPlaylistInfo(this.currentPlaylist()); - } -} diff --git a/libs/portal/stalker/feature/src/index.ts b/libs/portal/stalker/feature/src/index.ts index 5aae4db16..d5600ef48 100644 --- a/libs/portal/stalker/feature/src/index.ts +++ b/libs/portal/stalker/feature/src/index.ts @@ -1,6 +1,5 @@ export * from './lib/stalker-inline-detail/stalker-inline-detail.component'; export * from './lib/stalker-collection-detail.component'; -export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component'; export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component'; export * from './lib/stalker-favorites-button/stalker-favorites-button.component'; export * from './lib/stalker-catalog-facade.service'; diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.html b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.html deleted file mode 100644 index 4fd89ca7d..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.html +++ /dev/null @@ -1,44 +0,0 @@ -
- -
- -
- @if (filteredItems().length > 0) { - @for (item of filteredItems(); track item.id) { - - } - } @else { -
- search_off -

- {{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }} -

-

- {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }} -

-
- } -
diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.scss b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.scss deleted file mode 100644 index 150929661..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.scss +++ /dev/null @@ -1,115 +0,0 @@ -:host { - display: block; - height: 100%; - width: 100%; - overflow: hidden; - container-type: inline-size; -} - -.channels-list { - display: flex; - flex-direction: column; - height: calc(100% - 56px); - overflow-y: auto; - overflow-x: hidden; - width: 100%; - padding: 4px 8px 12px; - box-sizing: border-box; - gap: 2px; -} - -.search-panel { - padding: 10px 12px; - flex-shrink: 0; - height: 56px; - box-sizing: border-box; - display: flex; - align-items: center; -} - -.search-field { - flex: 1; - width: 100%; - height: 36px; - border-radius: 10px; - border: 1px solid rgba(127, 127, 127, 0.22); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), - rgba(127, 127, 127, 0.08); - display: flex; - align-items: center; - gap: 8px; - padding: 0 12px; - box-sizing: border-box; - cursor: text; - transition: - border-color 0.15s ease, - background-color 0.15s ease, - box-shadow 0.15s ease; - - &:focus-within { - border-color: var(--app-selection-border); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent); - } - - mat-icon { - width: 18px; - height: 18px; - font-size: 18px; - opacity: 0.68; - flex-shrink: 0; - } - - input { - flex: 1; - border: none; - outline: none; - background: transparent; - color: inherit; - font: inherit; - font-size: 0.875rem; - } -} - -.empty-search-state { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px 24px; - text-align: center; - color: var(--app-muted-color); - - .empty-state-icon { - width: 48px; - height: 48px; - font-size: 48px; - opacity: 0.5; - margin-bottom: 16px; - } - - .empty-state-title { - font-size: 1.05rem; - font-weight: 520; - letter-spacing: -0.01em; - margin: 0 0 8px; - text-wrap: balance; - } - - .empty-state-hint { - font-size: 0.85rem; - margin: 0; - opacity: 0.65; - line-height: 1.4; - max-width: 240px; - text-wrap: pretty; - } -} - -@container (max-width: 350px) { - .channels-list { - padding-inline: 6px; - } -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts deleted file mode 100644 index 625cac098..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Component, input, output, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MockPipe } from 'ng-mocks'; -import { TranslatePipe } from '@ngx-translate/core'; -import { ChannelListItemComponent } from '@iptvnator/ui/components'; -import { EpgProgram } from '@iptvnator/shared/interfaces'; -import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; -import { StalkerCollectionChannelsListComponent } from './stalker-collection-channels-list.component'; - -@Component({ - selector: 'app-channel-list-item', - standalone: true, - template: '', -}) -class StubChannelListItemComponent { - readonly name = input(''); - readonly logo = input(null); - readonly selected = input(false); - readonly epgProgram = input(null); - readonly progressPercentage = input(0); - readonly showFavoriteButton = input(false); - readonly showProgramInfoButton = input(false); - readonly isFavorite = input(false); - readonly clicked = output(); - readonly favoriteToggled = output(); -} - -describe('StalkerCollectionChannelsListComponent', () => { - let fixture: ComponentFixture; - let component: StalkerCollectionChannelsListComponent; - - const bulkItvEpgByChannel = signal>({}); - const stalkerStore = { - bulkItvEpgByChannel, - }; - - beforeEach(async () => { - bulkItvEpgByChannel.set({}); - - await TestBed.configureTestingModule({ - imports: [StalkerCollectionChannelsListComponent], - providers: [ - { provide: StalkerStore, useValue: stalkerStore }, - ], - }) - .overrideComponent(StalkerCollectionChannelsListComponent, { - remove: { - imports: [ChannelListItemComponent, TranslatePipe], - }, - add: { - imports: [ - StubChannelListItemComponent, - MockPipe( - TranslatePipe, - (value: string | null | undefined) => value ?? '' - ), - ], - }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(StalkerCollectionChannelsListComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('items', [ - { - id: '10001', - name: 'Alpha TV', - o_name: 'Alpha TV', - logo: 'alpha.png', - }, - { - id: '10002', - name: 'Beta TV', - o_name: 'Beta TV', - logo: 'beta.png', - }, - ]); - }); - - afterEach(() => { - fixture?.destroy(); - }); - - it('keeps row previews empty until bulk epg has been loaded', () => { - fixture.detectChanges(); - - expect(component.epgPrograms.size).toBe(0); - expect(component.currentProgramsProgress.size).toBe(0); - }); - - it('derives row previews from cached bulk epg', () => { - bulkItvEpgByChannel.set({ - '10001': [buildProgram('10001', 'Current Show')], - '10002': [buildProgram('10002', 'Other Show')], - }); - - fixture.detectChanges(); - - expect(component.epgPrograms.get('10001')?.title).toBe('Current Show'); - expect(component.epgPrograms.get('10002')?.title).toBe('Other Show'); - }); -}); - -function buildProgram(channelId: string, title: string): EpgProgram { - const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000); - const stopTimestamp = startTimestamp + 30 * 60; - - return { - start: new Date(startTimestamp * 1000).toISOString(), - stop: new Date(stopTimestamp * 1000).toISOString(), - channel: channelId, - title, - desc: `${title} description`, - category: null, - startTimestamp, - stopTimestamp, - }; -} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts deleted file mode 100644 index 58b52492e..000000000 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - ChangeDetectorRef, - ChangeDetectionStrategy, - Component, - computed, - effect, - inject, - input, - output, - signal, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatIconModule } from '@angular/material/icon'; -import { TranslatePipe } from '@ngx-translate/core'; -import { ChannelListItemComponent } from '@iptvnator/ui/components'; -import { EpgProgram } from '@iptvnator/shared/interfaces'; -import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access'; -import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access'; -import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; - -@Component({ - selector: 'app-stalker-collection-channels-list', - imports: [ChannelListItemComponent, FormsModule, MatIconModule, TranslatePipe], - templateUrl: './stalker-collection-channels-list.component.html', - styleUrl: './stalker-collection-channels-list.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class StalkerCollectionChannelsListComponent { - readonly items = input([]); - readonly selectedItemId = input(null); - readonly favoriteIds = input>(new Map()); - - readonly playClicked = output(); - readonly favoriteToggled = output(); - - private readonly stalkerStore = inject(StalkerStore); - private readonly cdr = inject(ChangeDetectorRef); - protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; - readonly searchString = signal(''); - readonly filteredItems = computed(() => { - const search = this.searchString().trim().toLowerCase(); - if (!search) { - return this.items(); - } - - return this.items().filter((item) => - `${item.o_name ?? ''} ${item.name ?? ''}` - .toLowerCase() - .includes(search) - ); - }); - - readonly epgPrograms = new Map(); - readonly currentProgramsProgress = new Map(); - - constructor() { - effect(() => { - const items = this.items(); - const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel(); - this.syncBulkEpgPreviews(items, bulkProgramsByChannel); - }); - } - - onPlay(item: StalkerVodSource): void { - this.playClicked.emit(item); - } - - onFavoriteToggle(item: StalkerVodSource): void { - this.favoriteToggled.emit(item); - } - - isSelected(item: StalkerVodSource): boolean { - return String(this.selectedItemId() ?? '') === normalizeStalkerEntityId(item.id); - } - - isFavorite(item: StalkerVodSource): boolean { - return this.favoriteIds().get(normalizeStalkerEntityId(item.id)) ?? false; - } - - private syncBulkEpgPreviews( - items: StalkerVodSource[], - bulkProgramsByChannel: Record - ): void { - this.epgPrograms.clear(); - this.currentProgramsProgress.clear(); - - if (items.length === 0 || Object.keys(bulkProgramsByChannel).length === 0) { - this.cdr.markForCheck(); - return; - } - - for (const item of items) { - const channelId = normalizeStalkerEntityId(item.id); - const currentProgram = this.findCurrentProgram( - bulkProgramsByChannel[channelId] ?? [] - ); - - if (!currentProgram) { - continue; - } - - this.epgPrograms.set(channelId, currentProgram); - this.updateProgramProgress(channelId, currentProgram); - } - - this.cdr.markForCheck(); - } - - private updateProgramProgress( - channelId: string | number, - program: EpgProgram - ): void { - const startMs = this.getProgramTimestampMs( - program.start, - program.startTimestamp - ); - const stopMs = this.getProgramTimestampMs( - program.stop, - program.stopTimestamp - ); - const nowMs = Date.now(); - - if ( - Number.isFinite(startMs) && - Number.isFinite(stopMs) && - nowMs >= startMs && - nowMs <= stopMs && - stopMs > startMs - ) { - this.currentProgramsProgress.set( - channelId, - ((nowMs - startMs) / (stopMs - startMs)) * 100 - ); - return; - } - - this.currentProgramsProgress.delete(channelId); - } - - private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null { - const nowMs = Date.now(); - - return ( - programs.find((program) => { - const startMs = this.getProgramTimestampMs( - program.start, - program.startTimestamp - ); - const stopMs = this.getProgramTimestampMs( - program.stop, - program.stopTimestamp - ); - - return nowMs >= startMs && nowMs <= stopMs; - }) ?? null - ); - } - - private getProgramTimestampMs( - rawDate: string, - timestamp?: number | null - ): number { - const parsedTimestamp = Number(timestamp); - if (Number.isFinite(parsedTimestamp) && parsedTimestamp > 0) { - return parsedTimestamp * 1000; - } - - const parsedDate = Date.parse(rawDate); - return Number.isFinite(parsedDate) ? parsedDate : Number.POSITIVE_INFINITY; - } -} 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..e32ac6a29 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -10,7 +10,10 @@ import { ResizableDirective, } from '@iptvnator/portal/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; -import { EpgTimelineComponent } from '@iptvnator/ui/epg'; +import { + EpgListViewComponent, + EpgTimelineComponent, +} from '@iptvnator/ui/epg'; import { AudioPlayerComponent } from '@iptvnator/ui/playback'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ChannelListItemComponent } from '@iptvnator/ui/components'; @@ -69,8 +72,10 @@ class StubAudioPlayerComponent { readonly channelSwitchRequested = output<'next' | 'previous'>(); } +// Matches both live-panel selectors so the host's timeline ↔ list swap can be +// asserted by tag name; both branches share the identical contract. @Component({ - selector: 'app-epg-timeline', + selector: 'app-epg-timeline, app-epg-list-view', standalone: true, template: `
{{ summary()?.title }}
@@ -230,10 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { }; const settingsStore = { openStreamOnDoubleClick: signal(false), + resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'), }; const originalElectron = window.electron; beforeEach(async () => { + // The store mock is module-scoped: reset so a failed test can't leak + // 'list' into siblings. + settingsStore.resolvedEpgViewMode.set('timeline'); window.electron = { platform: 'darwin', updateRemoteControlStatus: jest.fn(), @@ -341,6 +350,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ ChannelListItemComponent, AudioPlayerComponent, + EpgListViewComponent, EpgTimelineComponent, PortalEmptyStateComponent, ResizableDirective, @@ -386,6 +396,27 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBeNull(); }); + it('swaps the timeline for the list view when epgViewMode is "list"', () => { + settingsStore.resolvedEpgViewMode.set('list'); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-epg-list-view') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); + // Taller inline panel for the list view (see _portal-layout.scss). + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg--list') + ).toBe(true); + + settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests + }); + it('does not request or render EPG in browser/PWA playback', async () => { fixture.destroy(); window.electron = undefined as unknown as typeof window.electron; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 4eba2e00e..e50766d38 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -39,6 +39,7 @@ import { } from '@iptvnator/shared/interfaces'; import { EpgDateNavigationDirection, + EpgListViewComponent, EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, @@ -81,6 +82,7 @@ type StalkerPlayableChannel = StalkerPortalItem & { AudioPlayerComponent, ChannelListItemComponent, ChannelListSkeletonComponent, + EpgListViewComponent, EpgTimelineComponent, MatButtonModule, MatIconModule, @@ -191,6 +193,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary(this.currentProgram()) diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index 7de311c3d..c0dc55e9e 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -2,7 +2,6 @@ export * from './lib/account-info/account-info.component'; export * from './lib/category-management-dialog/category-management-dialog.component'; export * from './lib/global-search-results/global-search-results.component'; export * from './lib/live-stream-layout/live-stream-layout.component'; -export * from './lib/loading-overlay.component'; export * from './lib/portal-channels-list/portal-channels-list.component'; export * from './lib/recently-added/recently-added.component'; export * from './lib/search-results/search-results.component'; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 16b343dc8..48c74e9c4 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -109,6 +109,7 @@
- + @if (epgViewMode() === 'list') { + + } @else { + + }
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 4abc1b0be..6a591be03 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -24,6 +24,7 @@ import { XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; import { + EpgListViewComponent, EpgProgramActivationEvent, EpgTimelineComponent, EpgTimelineSummary, @@ -83,8 +84,10 @@ class StubWebPlayerViewComponent { readonly externalFallbackRequested = output(); } +// Matches both live-panel selectors so the host's timeline ↔ list swap can be +// asserted by tag name; both branches share the identical contract. @Component({ - selector: 'app-epg-timeline', + selector: 'app-epg-timeline, app-epg-list-view', standalone: true, template: `
{{ summaryLabelKey() }}
@@ -195,6 +198,9 @@ describe('LiveStreamLayoutComponent', () => { }; const settingsStore = { openStreamOnDoubleClick: signal(false), + // Reset in beforeEach: the store is module-scoped, so a test failure + // before an in-test restore must not leak 'list' into siblings. + resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'), }; const originalElectron = window.electron; @@ -202,6 +208,7 @@ describe('LiveStreamLayoutComponent', () => { beforeEach(async () => { jest.useFakeTimers(); jest.setSystemTime(fixedNow); + settingsStore.resolvedEpgViewMode.set('timeline'); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY); @@ -301,6 +308,7 @@ describe('LiveStreamLayoutComponent', () => { .overrideComponent(LiveStreamLayoutComponent, { remove: { imports: [ + EpgListViewComponent, EpgTimelineComponent, GridListComponent, PortalChannelsListComponent, @@ -359,6 +367,28 @@ describe('LiveStreamLayoutComponent', () => { ).not.toBeNull(); }); + it('swaps the timeline for the list view when epgViewMode is "list"', () => { + settingsStore.resolvedEpgViewMode.set('list'); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-epg-list-view') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); + // Taller inline panel for the list view (see _portal-layout.scss). + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg--list') + ).toBe(true); + + settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests + }); + it('hides the EPG panel in browser/PWA playback', () => { fixture.destroy(); window.electron = undefined as unknown as typeof window.electron; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 22ba83a40..51986f145 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -49,6 +49,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { EpgDateNavigationDirection, + EpgListViewComponent, EpgProgramActivationEvent, EpgTimelineComponent, getTodayEpgDateKey, @@ -91,6 +92,7 @@ interface XtreamLiveChannelItem { styleUrls: ['./live-stream-layout.component.scss'], providers: [LiveStreamAutoOpenStateService], imports: [ + EpgListViewComponent, EpgTimelineComponent, MatButtonModule, MatIcon, @@ -208,6 +210,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); + /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ + readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed; readonly liveEpgPanelSummary = computed(() => this.toLiveEpgPanelSummary( diff --git a/libs/portal/xtream/feature/src/lib/loading-overlay.component.ts b/libs/portal/xtream/feature/src/lib/loading-overlay.component.ts deleted file mode 100644 index 9f5192bb3..000000000 --- a/libs/portal/xtream/feature/src/lib/loading-overlay.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Component, input } from '@angular/core'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; - -@Component({ - selector: 'app-loading-overlay', - imports: [MatProgressBarModule], - template: ` -
-
-

Loading playlist...

- @if (current() !== 0 && total() !== 0) { - -

{{ current() }} / {{ total() }}

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