From 3345478bd66a01e2a7e073a76af0158425ec8b52 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 2 Jul 2026 00:41:19 +0200 Subject: [PATCH 1/2] chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps Removes verified-dead components (0 class/selector references outside their own files): EpgListComponent (+ epg-list-item), EpgViewComponent, LiveEpgPanelComponent, StalkerCollectionChannelsListComponent, NavigationComponent, FilterSortMenuComponent, video-player ToolbarComponent, PortalCollectionShellComponent and LoadingOverlayComponent, together with their barrel exports. Alive code extracted from the deleted trees: - LiveEpgPanelSummary -> libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts - EpgProgramActivationEvent -> libs/ui/epg/src/lib/epg-program-activation-event.ts - epg-list.utils.ts trimmed to the three timeline-used helpers and moved to libs/ui/epg/src/lib/epg-program.utils.ts - epg-item-description/ moved up out of the deleted epg-list/ folder Also removes 18 i18n keys now unused (from all 18 locales), dead CSS selectors targeting the deleted elements, and unused dependencies: lodash (+ @types/lodash), semver, @ngrx/component-store and @videojs/http-streaming (videojs-quality-selector-hls declares no peer dependency on it; video.js 8 bundles VHS). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 - apps/web/src/assets/i18n/ar.json | 22 +- apps/web/src/assets/i18n/ary.json | 22 +- apps/web/src/assets/i18n/by.json | 22 +- apps/web/src/assets/i18n/de.json | 22 +- apps/web/src/assets/i18n/el.json | 22 +- apps/web/src/assets/i18n/en.json | 22 +- apps/web/src/assets/i18n/es.json | 22 +- apps/web/src/assets/i18n/fr.json | 22 +- apps/web/src/assets/i18n/it.json | 22 +- apps/web/src/assets/i18n/ja.json | 22 +- apps/web/src/assets/i18n/ko.json | 22 +- apps/web/src/assets/i18n/nl.json | 22 +- apps/web/src/assets/i18n/pl.json | 22 +- apps/web/src/assets/i18n/pt.json | 22 +- apps/web/src/assets/i18n/ru.json | 22 +- apps/web/src/assets/i18n/tr.json | 22 +- apps/web/src/assets/i18n/zh.json | 22 +- apps/web/src/assets/i18n/zhtw.json | 22 +- apps/web/src/styles.scss | 4 - docs/architecture/iptvnator-ui-guidelines.md | 9 +- docs/architecture/m3u-playlist-module.md | 3 +- libs/playlist/shared/ui/src/index.ts | 1 - .../filter-sort-menu.component.html | 140 ----- .../filter-sort-menu.component.scss | 87 --- .../filter-sort-menu.component.ts | 118 ---- libs/portal/shared/ui/src/index.ts | 2 - .../portal-collection-shell.component.html | 29 - .../portal-collection-shell.component.ts | 61 --- .../unified-live-tab.component.scss | 3 +- .../lib/navigation/navigation.component.html | 78 --- .../lib/navigation/navigation.component.scss | 116 ---- .../navigation/navigation.component.spec.ts | 121 ----- .../lib/navigation/navigation.component.ts | 116 ---- libs/portal/stalker/feature/src/index.ts | 1 - ...er-collection-channels-list.component.html | 44 -- ...er-collection-channels-list.component.scss | 115 ---- ...collection-channels-list.component.spec.ts | 118 ---- ...lker-collection-channels-list.component.ts | 171 ------ libs/portal/xtream/feature/src/index.ts | 1 - .../src/lib/loading-overlay.component.ts | 54 -- libs/ui/epg/src/index.ts | 4 +- .../epg-item-description.component.html | 0 .../epg-item-description.component.scss | 0 .../epg-item-description.component.spec.ts | 0 .../epg-item-description.component.ts | 0 .../epg-list-item.component.html | 33 -- .../epg-list-item.component.scss | 114 ---- .../epg-list-item.component.spec.ts | 109 ---- .../epg-list-item/epg-list-item.component.ts | 41 -- .../src/lib/epg-list/epg-list.component.html | 127 ----- .../src/lib/epg-list/epg-list.component.scss | 287 ---------- .../lib/epg-list/epg-list.component.spec.ts | 508 ------------------ .../src/lib/epg-list/epg-list.component.ts | 383 ------------- .../ui/epg/src/lib/epg-list/epg-list.utils.ts | 125 ----- .../src/lib/epg-program-activation-event.ts | 6 + libs/ui/epg/src/lib/epg-program.utils.ts | 40 ++ .../src/lib/epg-programme-dialog.service.ts | 2 +- .../src/lib/epg-timeline/epg-archive.util.ts | 2 +- .../epg-timeline-track.component.ts | 2 +- .../epg-timeline/epg-timeline.component.ts | 4 +- .../lib/epg-timeline/epg-timeline.utils.ts | 2 +- .../multi-epg-container.component.ts | 2 +- libs/ui/playback/src/index.ts | 1 - .../toolbar/toolbar.component.html | 58 -- .../toolbar/toolbar.component.scss | 26 - .../video-player/toolbar/toolbar.component.ts | 49 -- libs/ui/shared-portals/src/index.ts | 3 +- .../src/lib/epg-view/epg-view.component.html | 52 -- .../src/lib/epg-view/epg-view.component.scss | 188 ------- .../lib/epg-view/epg-view.component.spec.ts | 50 -- .../src/lib/epg-view/epg-view.component.ts | 54 -- .../src/lib/live-epg-panel-summary.ts | 6 + .../live-epg-panel.component.html | 169 ------ .../live-epg-panel.component.scss | 291 ---------- .../live-epg-panel.component.spec.ts | 173 ------ .../live-epg-panel.component.ts | 143 ----- libs/ui/styles/_portal-layout.scss | 3 - package.json | 5 - pnpm-lock.yaml | 71 +-- 80 files changed, 106 insertions(+), 4816 deletions(-) delete mode 100644 libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.html delete mode 100644 libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.scss delete mode 100644 libs/playlist/shared/ui/src/lib/filter-sort-menu/filter-sort-menu.component.ts delete mode 100644 libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.html delete mode 100644 libs/portal/shared/ui/src/lib/components/portal-collection-shell/portal-collection-shell.component.ts delete mode 100644 libs/portal/shared/ui/src/lib/navigation/navigation.component.html delete mode 100644 libs/portal/shared/ui/src/lib/navigation/navigation.component.scss delete mode 100644 libs/portal/shared/ui/src/lib/navigation/navigation.component.spec.ts delete mode 100644 libs/portal/shared/ui/src/lib/navigation/navigation.component.ts delete mode 100644 libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.html delete mode 100644 libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.scss delete mode 100644 libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.spec.ts delete mode 100644 libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts delete mode 100644 libs/portal/xtream/feature/src/lib/loading-overlay.component.ts rename libs/ui/epg/src/lib/{epg-list => }/epg-item-description/epg-item-description.component.html (100%) rename libs/ui/epg/src/lib/{epg-list => }/epg-item-description/epg-item-description.component.scss (100%) rename libs/ui/epg/src/lib/{epg-list => }/epg-item-description/epg-item-description.component.spec.ts (100%) rename libs/ui/epg/src/lib/{epg-list => }/epg-item-description/epg-item-description.component.ts (100%) delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.html delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.scss delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.spec.ts delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list-item/epg-list-item.component.ts delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list.component.html delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list.component.scss delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list.component.ts delete mode 100644 libs/ui/epg/src/lib/epg-list/epg-list.utils.ts create mode 100644 libs/ui/epg/src/lib/epg-program-activation-event.ts create mode 100644 libs/ui/epg/src/lib/epg-program.utils.ts delete mode 100644 libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.html delete mode 100644 libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.scss delete mode 100644 libs/ui/playback/src/lib/video-player/toolbar/toolbar.component.ts delete mode 100644 libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html delete mode 100644 libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss delete mode 100644 libs/ui/shared-portals/src/lib/epg-view/epg-view.component.spec.ts delete mode 100644 libs/ui/shared-portals/src/lib/epg-view/epg-view.component.ts create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts delete mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html delete mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss delete mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts delete mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts diff --git a/CLAUDE.md b/CLAUDE.md index 33ea68e1a..1cbbac8e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -697,7 +697,6 @@ No formal migration system yet. Schema changes are applied via raw SQL in `conne **State Management**: - Use NgRx for global application state (playlists) -- Use component stores (`@ngrx/component-store`) for feature-specific state - Use NgRx signals for reactive data streams diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8a35c14a5..3186464eb 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": "الاسم (ي-أ)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "البحث عن قناة", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "فتح القائمة", "SUPPORT_ARIA": "دعم IPTVnator على GitHub", - "SETTINGS": "الإعدادات", "SETTINGS_ARIA": "فتح الإعدادات", "BUG_REPORT": "ابلاغ عن خلل", "WHAT_IS_NEW": "ما الجديد", @@ -804,10 +789,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..cb5e7d321 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": "الاسم (ي-أ)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "قلب على قناة", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "حل القائمة", "SUPPORT_ARIA": "دعم IPTVnator على GitHub", - "SETTINGS": "الإعدادات", "SETTINGS_ARIA": "حل الإعدادات", "BUG_REPORT": "بلغ على شي مشكل", "WHAT_IS_NEW": "شنو الجديد", @@ -804,10 +789,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..0ce8e448d 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": "Назва (Я-А)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Пошук каналаў", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст", "ALL_CHANNELS": "Усе каналы", "GROUPS": "Групы", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "Адкрыць меню", "SUPPORT_ARIA": "Падтрымаць IPTVnator на GitHub", - "SETTINGS": "Налады", "SETTINGS_ARIA": "Адкрыць налады", "BUG_REPORT": "Паведаміць аб памылцы", "WHAT_IS_NEW": "Што новага?", @@ -804,10 +789,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..caf9b9bb8 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..1eb16f157 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)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Αναζήτηση καναλιού", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής", "ALL_CHANNELS": "Όλα τα κανάλια", "GROUPS": "Ομάδες", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "Άνοιγμα μενού", "SUPPORT_ARIA": "Υποστήριξη του IPTVnator στο GitHub", - "SETTINGS": "Ρυθμίσεις", "SETTINGS_ARIA": "Άνοιγμα ρυθμίσεων", "BUG_REPORT": "Αναφορά σφάλματος", "WHAT_IS_NEW": "Τι νέο υπάρχει", @@ -804,10 +789,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..a8820c9b5 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..05620730c 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..ec727b4d0 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..89bdf6099 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..7d5da5f74 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)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "チャンネルを検索", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択", "ALL_CHANNELS": "すべてのチャンネル", "GROUPS": "グループ", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "メニューを開く", "SUPPORT_ARIA": "GitHubでIPTVnatorをサポート", - "SETTINGS": "設定", "SETTINGS_ARIA": "設定を開く", "BUG_REPORT": "バグを報告", "WHAT_IS_NEW": "新着情報", @@ -804,10 +789,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..6042470f0 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)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "검색 채널", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오", "ALL_CHANNELS": "모든 채널", "GROUPS": "그룹", @@ -557,14 +554,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 +565,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 +602,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 +665,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 +789,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..3245e41a3 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..f81d459ee 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..e5e2b2415 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..865c8ee6c 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": "Имени (Я-А)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "Поиск каналов", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист", "ALL_CHANNELS": "Все каналы", "GROUPS": "Группы", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "Открыть меню", "SUPPORT_ARIA": "Поддержать IPTVnator на GitHub", - "SETTINGS": "Настройки", "SETTINGS_ARIA": "Открыть настройки", "BUG_REPORT": "Сообщить об ошибке", "WHAT_IS_NEW": "Что нового?", @@ -804,10 +789,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..8c7c27e90 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)", @@ -483,7 +481,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 +554,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 +565,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 +602,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 +665,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 +789,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..764fad70e 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)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "搜索频道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表", "ALL_CHANNELS": "所有频道", "GROUPS": "分组", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "打开菜单", "SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator", - "SETTINGS": "设置", "SETTINGS_ARIA": "打开设置", "BUG_REPORT": "问题反馈", "WHAT_IS_NEW": "更新日志", @@ -804,10 +789,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..3d3fe0467 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)", @@ -483,7 +481,6 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)" }, "CHANNELS": { - "SEARCH_CHANNEL": "搜尋頻道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單", "ALL_CHANNELS": "所有頻道", "GROUPS": "群組", @@ -557,14 +554,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 +565,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 +602,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 +665,6 @@ "MENU": { "OPEN": "開啟選單", "SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator", - "SETTINGS": "設定", "SETTINGS_ARIA": "開啟設定", "BUG_REPORT": "回報問題", "WHAT_IS_NEW": "更新內容", @@ -804,10 +789,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..eec478e0d 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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..da6ef65a7 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -564,8 +564,7 @@ class EpgService { | Component | Purpose | | ----------------------------- | ------------------------------------ | -| `EpgListComponent` | Timeline view for single channel | -| `EpgListItemComponent` | Individual program in timeline | +| `EpgTimelineComponent` | Horizontal timeline for one channel | | `EpgItemDescriptionComponent` | Program details dialog | | `MultiEpgContainerComponent` | Grid view of all channels' schedules | 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.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss index 829904eab..dc6bacbf0 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,7 @@ display: flex; flex-direction: column; - > app-epg-view, - > app-epg-list { + > app-epg-timeline { flex: 1; min-height: 0; } 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/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/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/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index 41ef73a48..e335ef443 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -1,5 +1,5 @@ -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-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/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-list/epg-list.utils.ts b/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts deleted file mode 100644 index 463181137..000000000 --- a/libs/ui/epg/src/lib/epg-list/epg-list.utils.ts +++ /dev/null @@ -1,125 +0,0 @@ -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}`; -} - -export function getProgramTimeMs( - isoValue: string, - timestampValue?: number | null -): number { - if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) { - return Number(timestampValue) * 1000; - } - - return Date.parse(isoValue); -} - -export function getProgramDateKey( - isoValue: string, - timestampValue?: number | null -): string { - const programTimeMs = getProgramTimeMs(isoValue, timestampValue); - - if (!Number.isFinite(programTimeMs)) { - return ''; - } - - return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT); -} - -export function areProgramsSame( - left: EpgProgram, - right: EpgProgram -): boolean { - return ( - (left.channel ?? '') === (right.channel ?? '') && - getProgramTimeMs(left.start, left.startTimestamp) === - getProgramTimeMs(right.start, right.startTimestamp) && - getProgramTimeMs(left.stop, left.stopTimestamp) === - getProgramTimeMs(right.stop, right.stopTimestamp) - ); -} - -function buildProgramTimeSlotKey(program: EpgProgram): string { - return [ - getProgramTimeMs(program.start, program.startTimestamp), - getProgramTimeMs(program.stop, program.stopTimestamp), - ].join('|'); -} - -function selectMoreInformativeProgram( - existingProgram: EpgProgram, - candidateProgram: EpgProgram -): EpgProgram { - return getProgramMetadataScore(candidateProgram) > - getProgramMetadataScore(existingProgram) - ? candidateProgram - : existingProgram; -} - -function getProgramMetadataScore(program: EpgProgram): number { - return ( - getTextScore(program.desc) * 8 + - getTextScore(program.category) * 4 + - getTextScore(program.iconUrl) * 2 + - getTextScore(program.rating) * 2 + - getTextScore(program.episodeNum) * 2 + - getTextScore(program.title) - ); -} - -function getTextScore(value: string | null | undefined): number { - return value?.trim() ? 1 : 0; -} 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-program.utils.ts b/libs/ui/epg/src/lib/epg-program.utils.ts new file mode 100644 index 000000000..2a94cf275 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-program.utils.ts @@ -0,0 +1,40 @@ +import { format } from 'date-fns'; +import type { EpgProgram } from '@iptvnator/shared/interfaces'; +import { EPG_DATE_KEY_FORMAT } from './epg-date'; + +export function getProgramTimeMs( + isoValue: string, + timestampValue?: number | null +): number { + if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) { + return Number(timestampValue) * 1000; + } + + return Date.parse(isoValue); +} + +export function getProgramDateKey( + isoValue: string, + timestampValue?: number | null +): string { + const programTimeMs = getProgramTimeMs(isoValue, timestampValue); + + if (!Number.isFinite(programTimeMs)) { + return ''; + } + + return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT); +} + +export function areProgramsSame( + left: EpgProgram, + right: EpgProgram +): boolean { + return ( + (left.channel ?? '') === (right.channel ?? '') && + getProgramTimeMs(left.start, left.startTimestamp) === + getProgramTimeMs(right.start, right.startTimestamp) && + getProgramTimeMs(left.stop, left.stopTimestamp) === + getProgramTimeMs(right.stop, right.stopTimestamp) + ); +} 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/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..a7dd4839c 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -138,7 +138,6 @@ min-height: 56px; } - > app-live-epg-panel, > app-epg-timeline { flex: 1; min-height: 0; @@ -152,8 +151,6 @@ display: flex; flex-direction: column; - > app-epg-view, - > app-live-epg-panel, > app-epg-timeline { flex: 1; min-height: 0; diff --git a/package.json b/package.json index b342bcca3..b9c78205d 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,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", @@ -85,7 +84,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", @@ -100,7 +98,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", @@ -108,7 +105,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", @@ -172,7 +168,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 From ec0f9bc3d012582162fdc81eb4548963f1377036 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 2 Jul 2026 00:59:03 +0200 Subject: [PATCH 2/2] chore(coverage): move shared-portals to Tier C, fix stale doc references The Tier A gate failed in CI because deleting the dead epg-view and live-epg-panel components removed the only specs in libs/ui/shared-portals. The lib now contains a single type-only interface (LiveEpgPanelSummary), so there is no runtime code to unit test; reclassify it to Tier C with a documented reason, matching the gate's own guidance. Also update remaining doc references to the deleted components in docs/architecture/stalker-epg.md, iptvnator-ui-guidelines.md and CLAUDE.md. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- docs/architecture/iptvnator-ui-guidelines.md | 8 ++++---- docs/architecture/stalker-epg.md | 15 +++++++-------- tools/coverage/coverage-policy.json | 12 +++++------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 44b624e17..8395aaa2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,7 +251,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 diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index eec478e0d..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: 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/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." } ] },