chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps (#1116)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-04 11:09:50 +02:00
1 parent e14b8ae8d9
commit 1c710d69ef
84 files changed
+110 -4770

No files matched your search

+1 -2
View File
@@ -259,7 +259,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
@@ -757,7 +757,6 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c
- Use NgRx for global application state (M3U playlists, `libs/m3u-state`)
- Use NgRx Signal Store with `signalStoreFeature()` composition for portal/feature state (XtreamStore, StalkerStore)
- Use NgRx signals for reactive data streams
- `@ngrx/component-store` is listed in `package.json` but unused in the codebase — do not introduce new usages
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
+2 -20
View File
@@ -192,10 +192,8 @@
"VALIDATING": "جارٍ التحقق من البوابة..."
},
"FILTER_BY_NAME": "التصفية حسب الاسم",
"FILTER_BY_TYPE": "التصفية حسب النوع",
"FILTER_AND_SORT": "تصفية وفرز",
"SORT_PLAYLISTS": "فرز القوائم",
"SORT_BY": "ترتيب حسب",
"SORT_OPTIONS": {
"NAME_ASC": "الاسم (أ-ي)",
"NAME_DESC": "الاسم (ي-أ)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "البحث عن قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "فتح قائمة القنوات",
"TOGGLE_FAVORITE_FLAG": "تبديل حالة المفضلة",
"OPEN_EPG_LIST": "فتح قائمة EPG",
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG",
"SHOW_INFO": "عرض معلومات البرنامج (اضغط I)"
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG"
},
"EPG": {
"TITLE": "الدليل الإلكتروني للبرامج",
"NEXT_DAY": "اليوم التالي",
"PREVIOUS_DAY": "اليوم السابق",
"ZOOM_IN": "تكبير",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "إغلاق التصفية",
"SEARCH_PROGRAMS": "البحث في البرامج",
"CLOSE_PROGRAM_SEARCH": "إغلاق بحث البرامج",
"LIVE_NOW": "يُبث الآن",
"LIVE_STREAM": "بث مباشر",
"TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف غير متاح",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "يكشف هذا المزود عن سجل الجدول لهذه القناة، لكنه لا يوفر تشغيلًا قابلًا للأرشيف أو Catchup.",
"EPG_NOT_AVAILABLE_DATE": "عذراً، الدليل غير متاح لهذا التاريخ",
"NO_GUIDE_HINT": "جرب تاريخاً آخر أو حدّث قائمة التشغيل.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "عذراً، الدليل غير متاح لهذه القناة",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "يرجى إضافة/تعديل رابط EPG في إعدادات التطبيق.",
"FETCH_EPG": "جارٍ جلب بيانات EPG...",
"ERROR": "عذراً، فشل تحميل الدليل.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
"RETURN_TO_LIVE": "العودة إلى البث المباشر",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "فتح القائمة",
"SUPPORT_ARIA": "دعم IPTVnator على GitHub",
"SETTINGS": "الإعدادات",
"SETTINGS_ARIA": "فتح الإعدادات",
"BUG_REPORT": "ابلاغ عن خلل",
"WHAT_IS_NEW": "ما الجديد",
@@ -808,10 +793,7 @@
"SEARCH": "بحث",
"RECENT": "الأخيرة",
"RECENTLY_ADDED": "أُضيفت مؤخرًا",
"FAVORITES": "المفضلة",
"HOME": "الرئيسية",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب"
"FAVORITES": "المفضلة"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -192,10 +192,8 @@
"VALIDATING": "جاري التحقق من البوابة..."
},
"FILTER_BY_NAME": "فلتر بالاسم",
"FILTER_BY_TYPE": "فلتر بالنوع",
"FILTER_AND_SORT": "فلتر وترتيب",
"SORT_PLAYLISTS": "رتب قوائم التشغيل",
"SORT_BY": "رتب بـ",
"SORT_OPTIONS": {
"NAME_ASC": "الاسم (أ-ي)",
"NAME_DESC": "الاسم (ي-أ)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "قلب على قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "حل قائمة القنوات",
"TOGGLE_FAVORITE_FLAG": "بدل علامة المفضلة",
"OPEN_EPG_LIST": "حل قائمة EPG",
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد",
"SHOW_INFO": "وري معلومات البرنامج (ضغط على I)"
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد"
},
"EPG": {
"TITLE": "دليل البرامج الإلكتروني",
"NEXT_DAY": "اليوم التالي",
"PREVIOUS_DAY": "اليوم السابق",
"ZOOM_IN": "كبر",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "سد الفلتر",
"SEARCH_PROGRAMS": "قلب على البرامج",
"CLOSE_PROGRAM_SEARCH": "سد البحث على البرامج",
"LIVE_NOW": "مباشر الآن",
"LIVE_STREAM": "بث مباشر",
"TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف ما متوفرش",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "هاد المزود كيوفر تاريخ جدول البرامج للقناة، ولكن ما كيوفرش تشغيل أرشيف/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "أوه، EPG ما متوفرش للتاريخ اللي اختاريتيه",
"NO_GUIDE_HINT": "جرب تاريخ آخر أو حدّث اللائحة.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "أوه، EPG ما متوفرش لهاد القناة.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "عفاك زيد/بدل رابط قائمة EPG في إعدادات التطبيق.",
"FETCH_EPG": "جاري جلب بيانات EPG...",
"ERROR": "أوه، EPG ما قدرش يتشارجا.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
"RETURN_TO_LIVE": "رجع للبث المباشر",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "حل القائمة",
"SUPPORT_ARIA": "دعم IPTVnator على GitHub",
"SETTINGS": "الإعدادات",
"SETTINGS_ARIA": "حل الإعدادات",
"BUG_REPORT": "بلغ على شي مشكل",
"WHAT_IS_NEW": "شنو الجديد",
@@ -808,10 +793,7 @@
"SEARCH": "بحث",
"RECENT": "أخير",
"RECENTLY_ADDED": "تزادو مؤخراً",
"FAVORITES": "المفضلة",
"HOME": "الرئيسية",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب"
"FAVORITES": "المفضلة"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -192,10 +192,8 @@
"VALIDATING": "Праверка партала..."
},
"FILTER_BY_NAME": "Фільтраваць па імені",
"FILTER_BY_TYPE": "Фільтраваць па тыпу",
"FILTER_AND_SORT": "Фільтр і сартаванне",
"SORT_PLAYLISTS": "Сартаваць плэйлісты",
"SORT_BY": "Сартаваць па",
"SORT_OPTIONS": {
"NAME_ASC": "Назва (А-Я)",
"NAME_DESC": "Назва (Я-А)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Пошук каналаў",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст",
"ALL_CHANNELS": "Усе каналы",
"GROUPS": "Групы",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Адкрыць спіс каналаў",
"TOGGLE_FAVORITE_FLAG": "Выбраны",
"OPEN_EPG_LIST": "Адкрыць праграму (EPG)",
"OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд",
"SHOW_INFO": "Паказаць інфармацыю аб праграме (націсніце I)"
"OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд"
},
"EPG": {
"TITLE": "Электронная праграма перадач",
"NEXT_DAY": "Наступны дзень",
"PREVIOUS_DAY": "Папярэдні дзень",
"ZOOM_IN": "Павялічыць маштаб",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Закрыць фільтр",
"SEARCH_PROGRAMS": "Пошук перадач",
"CLOSE_PROGRAM_SEARCH": "Закрыць пошук перадач",
"LIVE_NOW": "Цяпер",
"LIVE_STREAM": "Прамая трансляцыя",
"TIMESHIFT_AVAILABLE": "Даступная функцыя архіва (timeshift). Націсніце на перадачу для прайгравання.",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Прайграванне архіва недаступна",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Гэты правайдэр прадастаўляе гісторыю перадач для канала, але не падтрымлівае прайграванне архіва/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ой, праграма на гэты дзень, на жаль, адсутнічае.",
"NO_GUIDE_HINT": "Паспрабуйце іншую дату або абнавіце плэйліст.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Праграма для гэтага канала недаступная.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Паспрабуйце змяніць або абнавіць спасылку на EPG у наладах праграмы.",
"FETCH_EPG": "Загружаю праграму ТБ-перадач (EPG)...",
"ERROR": "Ой, не ўдалося загрузіць праграму каналаў (EPG)",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Прайграванне архіва",
"RETURN_TO_LIVE": "Вярнуцца да эфіру",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Адкрыць меню",
"SUPPORT_ARIA": "Падтрымаць IPTVnator на GitHub",
"SETTINGS": "Налады",
"SETTINGS_ARIA": "Адкрыць налады",
"BUG_REPORT": "Паведаміць аб памылцы",
"WHAT_IS_NEW": "Што новага?",
@@ -808,10 +793,7 @@
"SEARCH": "Пошук",
"RECENT": "Нядаўнія",
"RECENTLY_ADDED": "Нядаўна дададзеныя",
"FAVORITES": "Выбранае",
"HOME": "Галоўная",
"PLAYLIST_INFO": "Інфармацыя аб плэйлісце",
"ACCOUNT_INFO": "Інфармацыя аб уліковым запісе"
"FAVORITES": "Выбранае"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Nach Sender suchen",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
"ALL_CHANNELS": "Alle Sender",
"GROUPS": "Gruppen",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Senderliste öffnen",
"TOGGLE_FAVORITE_FLAG": "Als Favoriten hinzufügen/entfernen",
"OPEN_EPG_LIST": "TV-Guide (EPG) öffnen",
"OPEN_MULTI_EPG": "Open Multi-EPG view",
"SHOW_INFO": "Programminfo anzeigen (Taste I)"
"OPEN_MULTI_EPG": "Open Multi-EPG view"
},
"EPG": {
"TITLE": "Elektronischer Programmführer",
"NEXT_DAY": "Zum nächsten Tag",
"PREVIOUS_DAY": "Zum vorherigen Tag",
"ZOOM_IN": "Vergrößern",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Filter schließen",
"SEARCH_PROGRAMS": "Sendungen suchen",
"CLOSE_PROGRAM_SEARCH": "Sendungssuche schließen",
"LIVE_NOW": "Live jetzt",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archivwiedergabe nicht verfügbar",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Dieser Anbieter stellt zwar den Programmverlauf für den Sender bereit, jedoch keine abspielbare Archiv-/Catchup-Wiedergabe.",
"EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar",
"NO_GUIDE_HINT": "Versuchen Sie ein anderes Datum oder aktualisieren Sie die Playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.",
"FETCH_EPG": "EPG-Daten werden geladen...",
"ERROR": "Ooops, EPG konnte nicht geladen werden",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Archivwiedergabe",
"RETURN_TO_LIVE": "Zurück zum Live-TV",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Menü öffnen",
"SUPPORT_ARIA": "IPTVnator auf GitHub unterstützen",
"SETTINGS": "Einstellungen",
"SETTINGS_ARIA": "Einstellungen öffnen",
"BUG_REPORT": "Fehler melden",
"WHAT_IS_NEW": "Was ist neu",
@@ -808,10 +793,7 @@
"SEARCH": "Suche",
"RECENT": "Zuletzt angesehen",
"RECENTLY_ADDED": "Kürzlich hinzugefügt",
"FAVORITES": "Favoriten",
"HOME": "Startseite",
"PLAYLIST_INFO": "Playlist-Info",
"ACCOUNT_INFO": "Account-Info"
"FAVORITES": "Favoriten"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Αναζήτηση καναλιού",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
"ALL_CHANNELS": "Όλα τα κανάλια",
"GROUPS": "Ομάδες",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Άνοιγμα λίστας καναλιών",
"TOGGLE_FAVORITE_FLAG": "Εναλλαγή σημαίας αγαπημένων",
"OPEN_EPG_LIST": "Άνοιγμα λίστας EPG",
"OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG",
"SHOW_INFO": "Εμφάνιση πληροφοριών προγράμματος (Πατήστε το I)"
"OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG"
},
"EPG": {
"TITLE": "Ηλεκτρονικός Οδηγός Προγράμματος",
"NEXT_DAY": "Επόμενη μέρα",
"PREVIOUS_DAY": "Προηγούμενη ημέρα",
"ZOOM_IN": "Μεγέθυνση",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Κλείσιμο φίλτρου",
"SEARCH_PROGRAMS": "Αναζήτηση προγραμμάτων",
"CLOSE_PROGRAM_SEARCH": "Κλείσιμο αναζήτησης προγραμμάτων",
"LIVE_NOW": "Ζωντανά τώρα",
"LIVE_STREAM": "Ζωντανή ροή",
"TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Η αναπαραγωγή αρχείου δεν είναι διαθέσιμη",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Αυτός ο πάροχος εκθέτει ιστορικό προγράμματος για το κανάλι, αλλά δεν παρέχει αναπαραγωγή αρχείου/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ωχ, το EPG δεν είναι διαθέσιμο για την επιλεγμένη ημερομηνία",
"NO_GUIDE_HINT": "Δοκιμάστε άλλη ημερομηνία ή ανανεώστε τη λίστα αναπαραγωγής.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ωχ, το EPG δεν είναι διαθέσιμο για αυτό το κανάλι.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Προσθέστε/αλλάξτε τη διεύθυνση URL της λίστας EPG στις ρυθμίσεις της εφαρμογής.",
"FETCH_EPG": "Ανάκτηση δεδομένων EPG...",
"ERROR": "Ωχ, δεν ήταν δυνατή η φόρτωση του EPG.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου",
"RETURN_TO_LIVE": "Επιστροφή στη ζωντανή ροή",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Άνοιγμα μενού",
"SUPPORT_ARIA": "Υποστήριξη του IPTVnator στο GitHub",
"SETTINGS": "Ρυθμίσεις",
"SETTINGS_ARIA": "Άνοιγμα ρυθμίσεων",
"BUG_REPORT": "Αναφορά σφάλματος",
"WHAT_IS_NEW": "Τι νέο υπάρχει",
@@ -808,10 +793,7 @@
"SEARCH": "Αναζήτηση",
"RECENT": "Πρόσφατα",
"RECENTLY_ADDED": "Πρόσφατα προστέθηκε",
"FAVORITES": "Αγαπημένα",
"HOME": "Αρχικό",
"PLAYLIST_INFO": "Πληροφορίες λίστας αναπαραγωγής",
"ACCOUNT_INFO": "Πληροφορίες λογαριασμού"
"FAVORITES": "Αγαπημένα"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Search channel",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist",
"ALL_CHANNELS": "All channels",
"GROUPS": "Groups",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Open channel list",
"TOGGLE_FAVORITE_FLAG": "Toggle favorite flag",
"OPEN_EPG_LIST": "Open EPG list",
"OPEN_MULTI_EPG": "Open Multi-EPG view",
"SHOW_INFO": "Show program info (Press I)"
"OPEN_MULTI_EPG": "Open Multi-EPG view"
},
"EPG": {
"TITLE": "Electronic Program Guide",
"NEXT_DAY": "Next day",
"PREVIOUS_DAY": "Previous day",
"ZOOM_IN": "Zoom in",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Close filter",
"SEARCH_PROGRAMS": "Search programs",
"CLOSE_PROGRAM_SEARCH": "Close program search",
"LIVE_NOW": "Live now",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Timeshift is available, click to play",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archive playback is unavailable",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "This provider exposes schedule history for the channel, but does not expose playable archive/catch-up playback.",
"EPG_NOT_AVAILABLE_DATE": "No program guide",
"NO_GUIDE_HINT": "Try a different date or refresh the playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the URL of the EPG list in the settings of the app.",
"FETCH_EPG": "Fetching EPG data...",
"ERROR": "Ooops, EPG could not be loaded.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Archive playback",
"RETURN_TO_LIVE": "Return to live",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Open menu",
"SUPPORT_ARIA": "Support IPTVnator on GitHub",
"SETTINGS": "Settings",
"SETTINGS_ARIA": "Open settings",
"BUG_REPORT": "Report a bug",
"WHAT_IS_NEW": "What is new",
@@ -808,10 +793,7 @@
"SEARCH": "Search",
"RECENT": "Recent",
"RECENTLY_ADDED": "Recently added",
"FAVORITES": "Favorites",
"HOME": "Home",
"PLAYLIST_INFO": "Playlist info",
"ACCOUNT_INFO": "Account info"
"FAVORITES": "Favorites"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Buscar canal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción",
"ALL_CHANNELS": "Todos los canales",
"GROUPS": "Grupos",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Abrir lista de canales",
"TOGGLE_FAVORITE_FLAG": "Alternar bandera favorita",
"OPEN_EPG_LIST": "Abrir lista EPG",
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG",
"SHOW_INFO": "Mostrar información del programa (presiona I)"
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG"
},
"EPG": {
"TITLE": "Guía electrónica de programación",
"NEXT_DAY": "Día siguiente",
"PREVIOUS_DAY": "Día anterior",
"ZOOM_IN": "Acercar",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Cerrar filtro",
"SEARCH_PROGRAMS": "Buscar programas",
"CLOSE_PROGRAM_SEARCH": "Cerrar búsqueda de programas",
"LIVE_NOW": "Ahora en vivo",
"LIVE_STREAM": "Transmisión en vivo",
"TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "La reproducción del archivo no está disponible",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este proveedor expone el historial de programación del canal, pero no permite la reproducción del archivo/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "Vaya, no está disponible la EPG de esa fecha",
"NO_GUIDE_HINT": "Prueba otra fecha o actualiza la lista de reproducción.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Vaya, no está disponible la EPG del canal.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Por favor añada/cargue la url de la lista EPG en los ajustes de la app.",
"FETCH_EPG": "Obteniendo datos de EPG...",
"ERROR": "¡Vaya! Esta EPG no se pudo cargar.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Reproducción de archivo",
"RETURN_TO_LIVE": "Volver al directo",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Abrir menú",
"SUPPORT_ARIA": "Apoyar IPTVnator en GitHub",
"SETTINGS": "Ajustes",
"SETTINGS_ARIA": "Abrir ajustes",
"BUG_REPORT": "Reportar un bug",
"WHAT_IS_NEW": "Novedades",
@@ -808,10 +793,7 @@
"SEARCH": "Buscar",
"RECENT": "Reciente",
"RECENTLY_ADDED": "Añadidos recientemente",
"FAVORITES": "Favoritos",
"HOME": "Inicio",
"PLAYLIST_INFO": "Información de la lista",
"ACCOUNT_INFO": "Información de la cuenta"
"FAVORITES": "Favoritos"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Rechercher une chaîne",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture",
"ALL_CHANNELS": "Toutes les chaînes",
"GROUPS": "Groupes",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Ouvrir la liste des chaînes",
"TOGGLE_FAVORITE_FLAG": "Basculer le statut de favoris",
"OPEN_EPG_LIST": "Ouvrir la liste EPG",
"OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG",
"SHOW_INFO": "Afficher les infos du programme (Appuyez sur I)"
"OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG"
},
"EPG": {
"TITLE": "Guide électronique des programmes",
"NEXT_DAY": "Jour suivant",
"PREVIOUS_DAY": "Jour précédent",
"ZOOM_IN": "Zoom avant",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Fermer le filtre",
"SEARCH_PROGRAMS": "Rechercher des programmes",
"CLOSE_PROGRAM_SEARCH": "Fermer la recherche de programmes",
"LIVE_NOW": "En direct",
"LIVE_STREAM": "Diffusion en direct",
"TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Lecture des archives indisponible",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ce fournisseur expose l'historique des programmes pour la chaîne, mais n'expose pas la lecture archive/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "L'EPG n'est pas disponible pour la date sélectionnée",
"NO_GUIDE_HINT": "Essayez une autre date ou actualisez la playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "L'EPG n'est pas disponible pour cette chaîne.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Veuillez ajouter/modifier l'URL de la liste EPG dans les réglages de l'application.",
"FETCH_EPG": "Récupération des données EPG...",
"ERROR": "L'EPG n'a pas pu être chargé.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Lecture archive",
"RETURN_TO_LIVE": "Retour au direct",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Ouvrir le menu",
"SUPPORT_ARIA": "Soutenir IPTVnator sur GitHub",
"SETTINGS": "Paramètres",
"SETTINGS_ARIA": "Ouvrir les paramètres",
"BUG_REPORT": "Signaler un bug",
"WHAT_IS_NEW": "Nouveautés",
@@ -808,10 +793,7 @@
"SEARCH": "Rechercher",
"RECENT": "Récent",
"RECENTLY_ADDED": "Récemment ajoutés",
"FAVORITES": "Favoris",
"HOME": "Accueil",
"PLAYLIST_INFO": "Infos de la liste",
"ACCOUNT_INFO": "Infos du compte"
"FAVORITES": "Favoris"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Cerca canale",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist",
"ALL_CHANNELS": "Tutti i canali",
"GROUPS": "Gruppi",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Apri elenco canali",
"TOGGLE_FAVORITE_FLAG": "Attiva/disattiva flag preferiti",
"OPEN_EPG_LIST": "Apri elenco EPG",
"OPEN_MULTI_EPG": "Apri vista Multi-EPG",
"SHOW_INFO": "Mostra info programma (premi I)"
"OPEN_MULTI_EPG": "Apri vista Multi-EPG"
},
"EPG": {
"TITLE": "Guida elettronica programmi",
"NEXT_DAY": "Giorno successivo",
"PREVIOUS_DAY": "Giorno precedente",
"ZOOM_IN": "Ingrandisci",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Chiudi filtro",
"SEARCH_PROGRAMS": "Cerca programmi",
"CLOSE_PROGRAM_SEARCH": "Chiudi ricerca programmi",
"LIVE_NOW": "Live attuale",
"LIVE_STREAM": "Stream live",
"TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Riproduzione archivio non disponibile",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Questo fornitore espone la cronologia della programmazione del canale, ma non la riproduzione di archivio/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Non è disponibile l'EPG per la data selezionata",
"NO_GUIDE_HINT": "Prova una data diversa o aggiorna la playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Non è disponibile l'EPG per questo canale.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Aggiungi/modifica l'URL dell'elenco EPG nelle impostazioni dell'app.",
"FETCH_EPG": "Recupero dati EPG...",
"ERROR": "Non è stato possibile caricare l'EPG.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Riproduzione archivio",
"RETURN_TO_LIVE": "Torna alla diretta",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Apri menu",
"SUPPORT_ARIA": "Supporta IPTVnator su GitHub",
"SETTINGS": "Impostazioni",
"SETTINGS_ARIA": "Apri impostazioni",
"BUG_REPORT": "Segnala un bug",
"WHAT_IS_NEW": "Novità del programma",
@@ -808,10 +793,7 @@
"SEARCH": "Cerca",
"RECENT": "Recenti",
"RECENTLY_ADDED": "Aggiunti di recente",
"FAVORITES": "Preferiti",
"HOME": "Home",
"PLAYLIST_INFO": "Info playlist",
"ACCOUNT_INFO": "Info account"
"FAVORITES": "Preferiti"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "チャンネルを検索",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
"ALL_CHANNELS": "すべてのチャンネル",
"GROUPS": "グループ",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "チャンネルリストを開く",
"TOGGLE_FAVORITE_FLAG": "お気に入りフラグを切り替え",
"OPEN_EPG_LIST": "EPGリストを開く",
"OPEN_MULTI_EPG": "マルチEPGビューを開く",
"SHOW_INFO": "番組情報を表示(Iキー)"
"OPEN_MULTI_EPG": "マルチEPGビューを開く"
},
"EPG": {
"TITLE": "電子番組表",
"NEXT_DAY": "翌日",
"PREVIOUS_DAY": "前日",
"ZOOM_IN": "拡大",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "フィルターを閉じる",
"SEARCH_PROGRAMS": "番組を検索",
"CLOSE_PROGRAM_SEARCH": "番組検索を閉じる",
"LIVE_NOW": "現在放送中",
"LIVE_STREAM": "ライブストリーム",
"TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "アーカイブ再生は利用できません",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "このプロバイダーはチャンネルの番組履歴を提供していますが、アーカイブ/Catchup再生には対応していません。",
"EPG_NOT_AVAILABLE_DATE": "申し訳ありません。選択した日付のEPGは利用できません",
"NO_GUIDE_HINT": "別の日付を試すか、プレイリストを更新してください。",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "申し訳ありません。このチャンネルのEPGは利用できません。",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "アプリの設定でEPGリストのURLを追加/変更してください。",
"FETCH_EPG": "EPGデータを取得中...",
"ERROR": "申し訳ありません。EPGを読み込めませんでした。",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "アーカイブ再生",
"RETURN_TO_LIVE": "ライブに戻る",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "メニューを開く",
"SUPPORT_ARIA": "GitHubでIPTVnatorをサポート",
"SETTINGS": "設定",
"SETTINGS_ARIA": "設定を開く",
"BUG_REPORT": "バグを報告",
"WHAT_IS_NEW": "新着情報",
@@ -808,10 +793,7 @@
"SEARCH": "検索",
"RECENT": "最近",
"RECENTLY_ADDED": "最近追加した項目",
"FAVORITES": "お気に入り",
"HOME": "ホーム",
"PLAYLIST_INFO": "プレイリスト情報",
"ACCOUNT_INFO": "アカウント情報"
"FAVORITES": "お気に入り"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "검색 채널",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오",
"ALL_CHANNELS": "모든 채널",
"GROUPS": "그룹",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "채널 열기 목록",
"TOGGLE_FAVORITE_FLAG": "즐겨찾기 보이기/숨기기",
"OPEN_EPG_LIST": "EPG 목록 열기",
"OPEN_MULTI_EPG": "Open Multi-EPG view",
"SHOW_INFO": "프로그램 정보 표시 (I 키)"
"OPEN_MULTI_EPG": "Open Multi-EPG view"
},
"EPG": {
"TITLE": "전자 프로그램 가이드",
"NEXT_DAY": "다음날",
"PREVIOUS_DAY": "전날",
"ZOOM_IN": "확대",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "필터 닫기",
"SEARCH_PROGRAMS": "프로그램 검색",
"CLOSE_PROGRAM_SEARCH": "프로그램 검색 닫기",
"LIVE_NOW": "Live now",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Timeshift를 사용할 수 있습니다, 클릭하여 재생합니다",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "아카이브 재생을 사용할 수 없습니다",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "이 공급자는 채널의 편성 이력을 제공하지만 재생 가능한 아카이브/Catchup 재생은 제공하지 않습니다.",
"EPG_NOT_AVAILABLE_DATE": "선택한 날짜에는 EPG를 사용할 수 없습니다.",
"NO_GUIDE_HINT": "다른 날짜를 시도하거나 재생 목록을 새로 고치세요.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "이 채널에는 EPG를 사용할 수 없습니다.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "설정에서 EPG의 URL을 추가/변경하십시오.",
"FETCH_EPG": "EPG 데이터 가져 오기...",
"ERROR": "Ooops, EPG could not be loaded.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "아카이브 재생",
"RETURN_TO_LIVE": "라이브로 돌아가기",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Open menu",
"SUPPORT_ARIA": "Support IPTVnator on GitHub",
"SETTINGS": "Settings",
"SETTINGS_ARIA": "Open settings",
"BUG_REPORT": "Report a bug",
"WHAT_IS_NEW": "What is new",
@@ -808,10 +793,7 @@
"SEARCH": "검색",
"RECENT": "최근",
"RECENTLY_ADDED": "최근 추가됨",
"FAVORITES": "즐겨찾기",
"HOME": "홈",
"PLAYLIST_INFO": "재생목록 정보",
"ACCOUNT_INFO": "계정 정보"
"FAVORITES": "즐겨찾기"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Zoek een kanaal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst",
"ALL_CHANNELS": "Alle kanalen",
"GROUPS": "Groepen",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Open kanalenlijst",
"TOGGLE_FAVORITE_FLAG": "Wissel favorietenstatus",
"OPEN_EPG_LIST": "Open TV-gids lijst",
"OPEN_MULTI_EPG": "Open Multi TV-Gids weergave",
"SHOW_INFO": "Programma-info tonen (druk op I)"
"OPEN_MULTI_EPG": "Open Multi TV-Gids weergave"
},
"EPG": {
"TITLE": "TV-Gids",
"NEXT_DAY": "Volgende dag",
"PREVIOUS_DAY": "Vorige dag",
"ZOOM_IN": "Inzoomen",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Filter sluiten",
"SEARCH_PROGRAMS": "Programma's zoeken",
"CLOSE_PROGRAM_SEARCH": "Programmazoekopdracht sluiten",
"LIVE_NOW": "Nu live",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Doorspoelen is beschikbaar, klik om af te spelen",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archiefweergave is niet beschikbaar",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Deze provider toont de programmageschiedenis voor het kanaal, maar biedt geen afspeelbare archief-/catch-up-weergave.",
"EPG_NOT_AVAILABLE_DATE": "De TV-Gids is niet beschikbaar voor de geselecteerde datum",
"NO_GUIDE_HINT": "Probeer een andere datum of vernieuw de afspeellijst.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "De TV-Gids is niet beschikbaar voor dit kanaal.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Voeg een TV-Gids URL toe of wijzig deze in de instellingen van de app.",
"FETCH_EPG": "Ophalen TV-Gids...",
"ERROR": "TV-Gids kan niet geladen worden.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Archief afspelen",
"RETURN_TO_LIVE": "Terug naar live",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Open menu",
"SUPPORT_ARIA": "Ondersteun IPTVnator op GitHub",
"SETTINGS": "Instellingen",
"SETTINGS_ARIA": "Open instellingen",
"BUG_REPORT": "Meld een bug",
"WHAT_IS_NEW": "Wat is er nieuw",
@@ -808,10 +793,7 @@
"SEARCH": "Zoeken",
"RECENT": "Recent",
"RECENTLY_ADDED": "Recent toegevoegd",
"FAVORITES": "Favorieten",
"HOME": "Home",
"PLAYLIST_INFO": "Afspeellijstinfo",
"ACCOUNT_INFO": "Accountinfo"
"FAVORITES": "Favorieten"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Szukaj kanału",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania",
"ALL_CHANNELS": "Wszystkie kanały",
"GROUPS": "Grupy",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Otwórz listę kanałów",
"TOGGLE_FAVORITE_FLAG": "Przełącz flagę ulubionych",
"OPEN_EPG_LIST": "Otwórz listę EPG",
"OPEN_MULTI_EPG": "Otwórz widok Multi-EPG",
"SHOW_INFO": "Pokaż informacje o programie (naciśnij I)"
"OPEN_MULTI_EPG": "Otwórz widok Multi-EPG"
},
"EPG": {
"TITLE": "Elektroniczny przewodnik po programach",
"NEXT_DAY": "Następny dzień",
"PREVIOUS_DAY": "Poprzedni dzień",
"ZOOM_IN": "Powiększ",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Zamknij filtr",
"SEARCH_PROGRAMS": "Szukaj programów",
"CLOSE_PROGRAM_SEARCH": "Zamknij wyszukiwanie programów",
"LIVE_NOW": "Na żywo teraz",
"LIVE_STREAM": "Transmisja na żywo",
"TIMESHIFT_AVAILABLE": "Dostępne jest przesunięcie czasowe, kliknij, aby odtworzyć",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Odtwarzanie archiwum niedostępne",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ten dostawca udostępnia historię programu dla kanału, ale nie udostępnia odtwarzalnego archiwum/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ups, EPG nie jest dostępne dla wybranej daty",
"NO_GUIDE_HINT": "Spróbuj innej daty lub odśwież playlistę.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, EPG nie jest dostępne dla tego kanału.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Dodaj/zmień URL listy EPG w ustawieniach aplikacji.",
"FETCH_EPG": "Pobieranie danych EPG...",
"ERROR": "Ups, nie można załadować EPG.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Odtwarzanie archiwum",
"RETURN_TO_LIVE": "Wróć do transmisji na żywo",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Otwórz menu",
"SUPPORT_ARIA": "Wsparcie IPTVnator na GitHubie",
"SETTINGS": "Ustawienia",
"SETTINGS_ARIA": "Otwórz ustawienia",
"BUG_REPORT": "Zgłoś błąd",
"WHAT_IS_NEW": "Co nowego",
@@ -808,10 +793,7 @@
"SEARCH": "Szukaj",
"RECENT": "Ostatnie",
"RECENTLY_ADDED": "Ostatnio dodane",
"FAVORITES": "Ulubione",
"HOME": "Strona główna",
"PLAYLIST_INFO": "Informacje o liście odtwarzania",
"ACCOUNT_INFO": "Informacje o koncie"
"FAVORITES": "Ulubione"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Pesquisar canal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist",
"ALL_CHANNELS": "Todos os canais",
"GROUPS": "Grupos",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Abrir lista de canais",
"TOGGLE_FAVORITE_FLAG": "Alternar sinalizador de favorito",
"OPEN_EPG_LIST": "Abrir lista de EPG",
"OPEN_MULTI_EPG": "Abrir visualização Multi-EPG",
"SHOW_INFO": "Mostrar informações do programa (pressione I)"
"OPEN_MULTI_EPG": "Abrir visualização Multi-EPG"
},
"EPG": {
"TITLE": "Guia Eletrônico de Programação",
"NEXT_DAY": "Próximo dia",
"PREVIOUS_DAY": "Dia anterior",
"ZOOM_IN": "Mais zoom",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Fechar filtro",
"SEARCH_PROGRAMS": "Pesquisar programas",
"CLOSE_PROGRAM_SEARCH": "Fechar pesquisa de programas",
"LIVE_NOW": "Ao vivo agora",
"LIVE_STREAM": "Transmissão ao vivo",
"TIMESHIFT_AVAILABLE": "Timeshift está disponível, clique para reproduzir",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Reprodução de arquivo indisponível",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este provedor expõe o histórico de programação do canal, mas não disponibiliza reprodução de arquivo/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "Ooops, EPG não está disponível para a data selecionada",
"NO_GUIDE_HINT": "Tente uma data diferente ou atualize a lista de reprodução.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG não está disponível para este canal.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Adicione/altere a URL da lista de EPG nas configurações do aplicativo.",
"FETCH_EPG": "Buscando dados do EPG...",
"ERROR": "Ooops, EPG não pôde ser carregado.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Reprodução do arquivo",
"RETURN_TO_LIVE": "Voltar ao vivo",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Abrir menu",
"SUPPORT_ARIA": "Apoie o IPTVnator no GitHub",
"SETTINGS": "Configurações",
"SETTINGS_ARIA": "Abrir configurações",
"BUG_REPORT": "Reportar um bug",
"WHAT_IS_NEW": "O que há de novo",
@@ -808,10 +793,7 @@
"SEARCH": "Pesquisar",
"RECENT": "Recente",
"RECENTLY_ADDED": "Adicionados recentemente",
"FAVORITES": "Favoritos",
"HOME": "Início",
"PLAYLIST_INFO": "Informações da playlist",
"ACCOUNT_INFO": "Informações da conta"
"FAVORITES": "Favoritos"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -192,10 +192,8 @@
"VALIDATING": "Проверка портала…"
},
"FILTER_BY_NAME": "Фильтровать по имени",
"FILTER_BY_TYPE": "Типы плейлистов",
"FILTER_AND_SORT": "Фильтр и сортировка",
"SORT_PLAYLISTS": "Сортировать плейлисты",
"SORT_BY": "Сортировать по",
"SORT_OPTIONS": {
"NAME_ASC": "Имени (А-Я)",
"NAME_DESC": "Имени (Я-А)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Поиск каналов",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
"ALL_CHANNELS": "Все каналы",
"GROUPS": "Группы",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Открыть список каналов",
"TOGGLE_FAVORITE_FLAG": "Избранный",
"OPEN_EPG_LIST": "Открыть программу (EPG)",
"OPEN_MULTI_EPG": "Открыть Multi-EPG вид",
"SHOW_INFO": "Показать информацию о программе (нажмите I)"
"OPEN_MULTI_EPG": "Открыть Multi-EPG вид"
},
"EPG": {
"TITLE": "Электронный программный гид",
"NEXT_DAY": "Следующий день",
"PREVIOUS_DAY": "Предыдущий день",
"ZOOM_IN": "Увеличить",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Закрыть фильтр",
"SEARCH_PROGRAMS": "Поиск программ",
"CLOSE_PROGRAM_SEARCH": "Закрыть поиск программ",
"LIVE_NOW": "Сейчас",
"LIVE_STREAM": "Прямая трансляция",
"TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Воспроизведение архива недоступно",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Этот провайдер предоставляет историю программы для канала, но не предоставляет воспроизводимый архив/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ой, программа на этот день, к сожалению, отсутствует.",
"NO_GUIDE_HINT": "Попробуйте другую дату или обновите плейлист.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Программа для этого канала недоступна.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.",
"FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...",
"ERROR": "Ой, не удалось загрузить программу каналов (EPG)",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Архивная запись",
"RETURN_TO_LIVE": "Вернуться к эфиру",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Открыть меню",
"SUPPORT_ARIA": "Поддержать IPTVnator на GitHub",
"SETTINGS": "Настройки",
"SETTINGS_ARIA": "Открыть настройки",
"BUG_REPORT": "Сообщить об ошибке",
"WHAT_IS_NEW": "Что нового?",
@@ -808,10 +793,7 @@
"SEARCH": "Поиск",
"RECENT": "Недавние",
"RECENTLY_ADDED": "Недавно добавленные",
"FAVORITES": "Избранное",
"HOME": "Домой",
"PLAYLIST_INFO": "Информация о плейлисте",
"ACCOUNT_INFO": "Информация об аккаунте"
"FAVORITES": "Избранное"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Kanal ara",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin",
"ALL_CHANNELS": "Tüm kanallar",
"GROUPS": "Gruplar",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Kanal listesini aç",
"TOGGLE_FAVORITE_FLAG": "Favori işaretini değiştir",
"OPEN_EPG_LIST": "EPG listesini aç",
"OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç",
"SHOW_INFO": "Program bilgisini göster (I tuşuna basın)"
"OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç"
},
"EPG": {
"TITLE": "Elektronik Program Rehberi",
"NEXT_DAY": "Sonraki gün",
"PREVIOUS_DAY": "Önceki gün",
"ZOOM_IN": "Yakınlaştır",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "Filtreyi kapat",
"SEARCH_PROGRAMS": "Programları ara",
"CLOSE_PROGRAM_SEARCH": "Program aramayı kapat",
"LIVE_NOW": "Şu an canlı",
"LIVE_STREAM": "Canlı yayın",
"TIMESHIFT_AVAILABLE": "Zaman kaydırma mevcut, tıklayarak oynat",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Arşiv oynatma kullanılamıyor",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Bu sağlayıcı kanal için yayın akışı geçmişini sunar, ancak oynatılabilir arşiv/Catchup oynatma sunmaz.",
"EPG_NOT_AVAILABLE_DATE": "Üzgünüz, seçilen tarih için EPG mevcut değil",
"NO_GUIDE_HINT": "Başka bir tarih deneyin veya oynatma listesini yenileyin.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Üzgünüz, bu kanal için EPG mevcut değil.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Lütfen uygulamanın ayarlarında EPG listesi URL'sini ekleyin veya değiştirin.",
"FETCH_EPG": "EPG verileri alınıyor...",
"ERROR": "Üzgünüz, EPG yüklenemedi.",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Arşiv oynatma",
"RETURN_TO_LIVE": "Canlı yayına dön",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "Menüyü aç",
"SUPPORT_ARIA": "IPTVnator'u GitHub'da destekleyin",
"SETTINGS": "Ayarlar",
"SETTINGS_ARIA": "Ayarları aç",
"BUG_REPORT": "Hata bildir",
"WHAT_IS_NEW": "Yeni ne var?",
@@ -808,10 +793,7 @@
"SEARCH": "Ara",
"RECENT": "Son İzlenenler",
"RECENTLY_ADDED": "Yeni Eklenenler",
"FAVORITES": "Favoriler",
"HOME": "Ana Sayfa",
"PLAYLIST_INFO": "Oynatma Listesi Bilgisi",
"ACCOUNT_INFO": "Hesap Bilgisi"
"FAVORITES": "Favoriler"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "搜索频道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表",
"ALL_CHANNELS": "所有频道",
"GROUPS": "分组",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "打开频道列表",
"TOGGLE_FAVORITE_FLAG": "切换收藏夹标志",
"OPEN_EPG_LIST": "打开电子节目单EPG列表",
"OPEN_MULTI_EPG": "打开多EPG显示",
"SHOW_INFO": "显示节目信息(按 I 键)"
"OPEN_MULTI_EPG": "打开多EPG显示"
},
"EPG": {
"TITLE": "电子节目指南",
"NEXT_DAY": "明天",
"PREVIOUS_DAY": "昨天",
"ZOOM_IN": "放大",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "关闭筛选",
"SEARCH_PROGRAMS": "搜索节目",
"CLOSE_PROGRAM_SEARCH": "关闭节目搜索",
"LIVE_NOW": "正在播放",
"LIVE_STREAM": "正在直播",
"TIMESHIFT_AVAILABLE": "时移功能可用,点击播放",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "归档播放不可用",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此提供商提供了该频道的节目历史,但不提供可播放的归档/Catchup 回看。",
"EPG_NOT_AVAILABLE_DATE": "错误:电子节目单EPG在所选日期不可用",
"NO_GUIDE_HINT": "尝试其他日期或刷新播放列表。",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "错误:电子节目单EPG不适用于此频道",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "请添加/更改设置项中电子节目单的URL",
"FETCH_EPG": "获取 EPG 数据...",
"ERROR": "错误: 获取 EPG 数据失败",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "回看播放",
"RETURN_TO_LIVE": "返回直播",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "打开菜单",
"SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator",
"SETTINGS": "设置",
"SETTINGS_ARIA": "打开设置",
"BUG_REPORT": "问题反馈",
"WHAT_IS_NEW": "更新日志",
@@ -808,10 +793,7 @@
"SEARCH": "搜索",
"RECENT": "最近",
"RECENTLY_ADDED": "最近添加",
"FAVORITES": "收藏",
"HOME": "首页",
"PLAYLIST_INFO": "播放列表信息",
"ACCOUNT_INFO": "账户信息"
"FAVORITES": "收藏"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+2 -20
View File
@@ -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)",
@@ -487,7 +485,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "搜尋頻道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單",
"ALL_CHANNELS": "所有頻道",
"GROUPS": "群組",
@@ -561,14 +558,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "打開頻道列表",
"TOGGLE_FAVORITE_FLAG": "切換我的最愛標記",
"OPEN_EPG_LIST": "開啟 EPG 列表",
"OPEN_MULTI_EPG": "開啟多 EPG 視圖",
"SHOW_INFO": "顯示節目資訊(按 I 鍵)"
"OPEN_MULTI_EPG": "開啟多 EPG 視圖"
},
"EPG": {
"TITLE": "電子節目指南",
"NEXT_DAY": "下一天",
"PREVIOUS_DAY": "上一天",
"ZOOM_IN": "放大",
@@ -577,14 +569,9 @@
"CLOSE_FILTER": "關閉篩選",
"SEARCH_PROGRAMS": "搜尋節目",
"CLOSE_PROGRAM_SEARCH": "關閉節目搜尋",
"LIVE_NOW": "現在播放",
"LIVE_STREAM": "現場直播",
"TIMESHIFT_AVAILABLE": "時間平移功能可用,點選以播放",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "無法播放存檔內容",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此供應商有提供本頻道的歷史節目表,但未提供可播放的存檔/Catchup 播放功能。",
"EPG_NOT_AVAILABLE_DATE": "抱歉,選擇的日期上 EPG 無法使用",
"NO_GUIDE_HINT": "嘗試其他日期或重新整理播放清單。",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "抱歉,此頻道上的 EPG 無法使用。",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "請在應用程式設定中增加/更改 EPG 列表的URL。",
"FETCH_EPG": "取得 EPG 資料...",
"ERROR": "抱歉,無法載入 EPG。",
@@ -619,7 +606,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "回看播放",
"RETURN_TO_LIVE": "返回直播",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -683,7 +669,6 @@
"MENU": {
"OPEN": "開啟選單",
"SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator",
"SETTINGS": "設定",
"SETTINGS_ARIA": "開啟設定",
"BUG_REPORT": "回報問題",
"WHAT_IS_NEW": "更新內容",
@@ -808,10 +793,7 @@
"SEARCH": "搜尋",
"RECENT": "最近觀看",
"RECENTLY_ADDED": "最近新增",
"FAVORITES": "我的最愛",
"HOME": "首頁",
"PLAYLIST_INFO": "播放清單資訊",
"ACCOUNT_INFO": "帳戶資訊"
"FAVORITES": "我的最愛"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
-4
View File
@@ -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).
+8 -9
View File
@@ -28,10 +28,10 @@ Use it when changing existing views or introducing new list-based UI in the work
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html`
- Channel row styles:
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss`
- Shared EPG pane:
`libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html`
- Shared EPG pane styles:
`libs/ui/shared-portals/src/lib/epg-view/epg-view.component.scss`
- Shared EPG timeline:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html`
- Shared EPG timeline styles:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss`
- Shared list selection style:
`apps/web/src/nav-list.scss`
- Theme tokens:
@@ -141,12 +141,11 @@ The shared row should be reused instead of rebuilding channel markup per view.
### Collapsible Live EPG
- Live TV layouts with an internal player use `app-live-epg-panel` around the
- Live TV layouts with an internal player render `app-epg-timeline` as the
EPG content, including playlist-specific live pages and the global
favorites/recent live tabs.
- The live panel toolbar owns the current-program summary and live date
navigation together, so `app-epg-list` hides its internal date navigator when
projected inside that panel.
- The timeline's own panel bar owns the current-program summary and live date
navigation together; there is no separate wrapper component around it.
- Collapsed state is shared across M3U, Xtream, and Stalker with
`live-epg-panel-state`; missing or invalid values restore to expanded.
- The collapsed panel is a slim current-program strip with a trailing progress
@@ -289,7 +288,7 @@ Settings use the same system but are flatter than content-heavy views.
Before creating new markup or CSS:
1. Check whether `app-channel-list-item` can be reused.
2. Check whether `app-epg-view` already provides the correct structure.
2. Check whether `app-epg-timeline` already provides the correct structure.
3. Check whether `nav-list.scss` already solves the list-selection problem.
4. Extend tokens first, duplicate styles last.
+2 -2
View File
@@ -598,8 +598,8 @@ class EpgService {
| Component | Purpose |
| ----------------------------- | ------------------------------------ |
| `EpgListComponent` | Timeline view for single channel |
| `EpgListItemComponent` | Individual program in timeline |
| `EpgTimelineComponent` | Horizontal timeline for one channel |
| `EpgListViewComponent` | Vertical single-day list alternative |
| `EpgItemDescriptionComponent` | Program details dialog |
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
+7 -8
View File
@@ -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
-1
View File
@@ -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';
@@ -1,140 +0,0 @@
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
<section class="filter-section">
<div class="menu-section-header">
<mat-icon class="section-icon">filter_list</mat-icon>
<span>{{ 'HOME.FILTER_BY_TYPE' | translate }}</span>
</div>
@for (type of playlistTypes; track type.id) {
<button
mat-menu-item
(click)="togglePlaylistType(type); $event.stopPropagation()"
[class.active-option]="type.checked"
>
<mat-icon>{{ type.icon }}</mat-icon>
<span class="menu-item-text">{{
type.translationKey | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="type.checked"
>check</mat-icon
>
</button>
}
</section>
<section class="sort-section">
<div class="menu-section-header">
<mat-icon class="section-icon">sort</mat-icon>
<span>{{ 'HOME.SORT_BY' | translate }}</span>
</div>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_ASC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.DESC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_DESC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NEWEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.OLDEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.CUSTOM, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.CUSTOM, SortOrder.ASC)"
>
<mat-icon>drag_indicator</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.CUSTOM_ORDER' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.CUSTOM, SortOrder.ASC)
"
>check</mat-icon
>
</button>
</section>
<div class="menu-footer">
<button mat-button (click)="closeMenu()">
{{ 'CLOSE' | translate }}
</button>
</div>
</mat-menu>
@@ -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;
}
@@ -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<MatMenu>('filterSortMenu');
readonly menuTrigger = input<MatMenuTrigger>();
readonly filterChanged = output<void>();
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();
}
}
-2
View File
@@ -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';
@@ -1,29 +0,0 @@
@switch (mode()) {
@case ('detail') {
<ng-content select="[details]" />
}
@case ('live') {
<ng-content select="[live]" />
}
@default {
<app-favorites-layout
[categories]="categories()"
[favorites]="items()"
[playlistSubtitle]="playlistSubtitle()"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="
layout().titleTranslationKey || 'CHANNELS.FAVORITES'
"
[removeTooltip]="layout().removeTooltip || ''"
[emptyIcon]="layout().emptyIcon || 'favorite_border'"
[showHeaderAction]="layout().showHeaderAction || false"
[headerActionIcon]="layout().headerActionIcon || 'delete_sweep'"
[headerActionTooltip]="layout().headerActionTooltip || ''"
(categoryClicked)="onCategoryClicked($event)"
(removeFavorite)="removeItem.emit($event)"
(headerActionClicked)="headerActionClicked.emit()"
(openItem)="openItem.emit($event)"
/>
}
}
@@ -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<XtreamCategory[]>([]);
readonly items = input<FavoriteLayoutItem[]>([]);
readonly playlistSubtitle = input<string>('');
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('all');
readonly layout = input<PortalCollectionShellLayout>({});
readonly mode = input<PortalCollectionMode>('grid');
readonly categoryClicked = output<string>();
readonly removeItem = output<FavoriteLayoutItem>();
readonly openItem = output<FavoriteLayoutItem>();
readonly headerActionClicked = output<void>();
onCategoryClicked(event: PortalCategorySelection): void {
const categoryId = event.category_id ?? event.id;
if (categoryId == null) {
return;
}
this.categoryClicked.emit(String(categoryId));
}
}
@@ -67,8 +67,8 @@
display: flex;
flex-direction: column;
> app-epg-view,
> app-epg-list {
> app-epg-timeline,
> app-epg-list-view {
flex: 1;
min-height: 0;
}
@@ -1,78 +0,0 @@
<div
class="portal-status-container"
[class]="getStatusColor()"
[matTooltip]="portalStatus() ?? ''"
matTooltipPosition="right"
>
<mat-icon class="status-watermark">{{ getStatusIcon() }}</mat-icon>
</div>
<div class="nav-section">
<mat-nav-list>
<a
class="portal-nav-link nav-item"
mat-list-item
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
[matTooltip]="'PORTALS.SIDEBAR.HOME' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>home</mat-icon>
</a>
</mat-nav-list>
</div>
<mat-divider />
<div class="nav-section">
<app-portal-rail-links
variant="list"
activeClass="active"
[selectedSection]="selectedContentType()"
[links]="primaryLinks()"
/>
</div>
<mat-divider />
<div class="nav-section">
<app-portal-rail-links
variant="list"
activeClass="active"
[selectedSection]="selectedContentType()"
[links]="secondaryLinks()"
/>
</div>
<div class="navigation-bottom">
<mat-divider />
<mat-nav-list>
@if (!isStalkerPlaylist()) {
<a
mat-list-item
class="portal-nav-link nav-item action-button"
(click)="openAccountInfo()"
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
matTooltipPosition="right"
>
<mat-icon>person</mat-icon>
</a>
}
<a
class="portal-nav-link nav-item"
mat-list-item
(click)="openPlaylistInfo()"
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>playlist_play</mat-icon>
</a>
<mat-divider />
<a
class="portal-nav-link nav-item"
mat-list-item
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>settings</mat-icon>
</a>
</mat-nav-list>
</div>
@@ -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;
}
}
@@ -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<typeof signal<Partial<Playlist> | undefined>>;
let runtime: {
supportsDownloads: boolean;
};
beforeEach(async () => {
playlist = signal<Partial<Playlist> | 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',
]);
});
});
@@ -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<PortalRailSection | undefined>();
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());
}
}
-1
View File
@@ -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';
@@ -1,44 +0,0 @@
<div class="search-panel">
<label class="search-field">
<mat-icon>search</mat-icon>
<input
type="search"
spellcheck="false"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</label>
</div>
<div class="channels-list">
@if (filteredItems().length > 0) {
@for (item of filteredItems(); track item.id) {
<app-channel-list-item
[name]="item.o_name || item.name || ''"
[logo]="item.logo"
[selected]="isSelected(item)"
[epgProgram]="
epgPrograms.get(normalizeStalkerEntityId(item.id)) ?? null
"
[progressPercentage]="
currentProgramsProgress.get(normalizeStalkerEntityId(item.id)) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="isFavorite(item)"
(clicked)="onPlay(item)"
(favoriteToggled)="onFavoriteToggle(item)"
/>
}
} @else {
<div class="empty-search-state">
<mat-icon class="empty-state-icon">search_off</mat-icon>
<h3 class="empty-state-title">
{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}
</h3>
<p class="empty-state-hint">
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
</p>
</div>
}
</div>
@@ -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;
}
}
@@ -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<string | null | undefined>(null);
readonly selected = input(false);
readonly epgProgram = input<unknown>(null);
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly favoriteToggled = output<void>();
}
describe('StalkerCollectionChannelsListComponent', () => {
let fixture: ComponentFixture<StalkerCollectionChannelsListComponent>;
let component: StalkerCollectionChannelsListComponent;
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
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,
};
}
@@ -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<StalkerVodSource[]>([]);
readonly selectedItemId = input<string | number | null>(null);
readonly favoriteIds = input<Map<string | number, boolean>>(new Map());
readonly playClicked = output<StalkerVodSource>();
readonly favoriteToggled = output<StalkerVodSource>();
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<string | number, EpgProgram>();
readonly currentProgramsProgress = new Map<string | number, number>();
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<string, EpgProgram[]>
): 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;
}
}
-1
View File
@@ -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';
@@ -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: `
<div class="overlay">
<div class="progress-container">
<h3>Loading playlist...</h3>
@if (current() !== 0 && total() !== 0) {
<mat-progress-bar
mode="determinate"
[value]="(current() / total()) * 100"
/>
<p>{{ current() }} / {{ total() }}</p>
} @else {
<mat-progress-bar mode="indeterminate" />
}
</div>
</div>
`,
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);
}
+2 -2
View File
@@ -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-list-view/epg-list-view.component';
export * from './lib/epg-list-view/epg-list-view-row/epg-list-view-row.component';
export * from './lib/epg-list-view/epg-list-view.utils';
@@ -24,8 +24,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 { epgDialogActionFor } from '../epg-timeline/epg-archive.util';
import {
@@ -5,7 +5,7 @@ import {
areProgramsSame,
deduplicateProgramsByTimeSlot,
getProgramTimeMs,
} from '../epg-list/epg-list.utils';
} from '../epg-program.utils';
import { canCatchUpProgramme } from '../epg-timeline/epg-archive.util';
import {
classifyTimelineWhen,
@@ -1,33 +0,0 @@
<span class="time"
>{{ (item.startTimestamp ? item.startTimestamp * 1000 : item.start) | date: 'HH:mm' }}–{{ (item.stopTimestamp ? item.stopTimestamp * 1000 : item.stop) | date: 'HH:mm' }}</span
>
@if (isLive) {
<span class="live badge" [matTooltip]="'EPG.LIVE_NOW' | translate"
>live</span
>
}
@if (showArchiveBadge) {
<span
class="archive-playback badge"
[class.archive-playback--active]="isActive"
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>
<mat-icon class="archive-playback__icon" aria-hidden="true"
>replay</mat-icon
>
<span class="archive-playback__label"
>{{ 'XTREAM.PLAY' | translate }}</span
>
</span>
}
@if (item.desc) {
<span
(click)="$event.stopPropagation(); showDescription(item)"
matListItemMeta
class="description-icon"
[matTooltip]="'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate"
><mat-icon>info</mat-icon></span
>
}
@@ -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);
}
}
@@ -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<EpgListItemComponent>;
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');
});
});
@@ -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,
});
}
}
@@ -1,127 +0,0 @@
@let items = filteredItems();
@let timeshiftLimit = timeshiftUntil();
@if (showDateNavigator()) {
<!-- channel info panel -->
<div id="channel-header" color="primary">
<div id="date-navigator">
<button
class="previous-day"
mat-icon-button
(click)="changeDate('prev')"
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="selected-date">
{{
selectedDateKey()
| date: 'MMMM d, EEEE' : undefined : currentLocale()
}}</span
>
<button
class="next-day"
mat-icon-button
(click)="changeDate('next')"
[matTooltip]="'EPG.NEXT_DAY' | translate"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</div>
}
@if (timeshiftLimit) {
<!-- program list -->
<div id="program-list" class="app-scrollbar" #programList>
@if (items.length > 0) {
@for (program of items; track trackProgram($index, program)) {
@if (!canActivateProgram(program)) {
<div
class="program-item"
[class.current-program]="isProgramPlaying(program)"
>
<div class="program-meta">
<app-epg-list-item
[item]="program"
[isLive]="isProgramPlaying(program)"
[isActive]="isProgramActive(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
/>
</div>
<div class="program-title">
{{ program?.title }}
</div>
<div class="epg-item__description">
{{ program?.desc }}
</div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="
calculateProgress(program)
"
></div>
</div>
</div>
}
</div>
} @else {
<div
class="program-item clickable"
[class.current-program]="isProgramPlaying(program)"
[class.archive-program]="
canPlayArchivedProgram(program)
"
[class.active]="isProgramActive(program)"
role="button"
tabindex="0"
(click)="activateProgram(program)"
(keydown.enter)="activateProgram(program)"
(keydown.space)="
activateProgramFromKeyboard($event, program)
"
>
<div class="program-meta">
<app-epg-list-item
[item]="program"
[isLive]="isProgramPlaying(program)"
[isActive]="isProgramActive(program)"
[showArchiveBadge]="
canPlayArchivedProgram(program)
"
/>
</div>
<div class="program-title">{{ program.title }}</div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="
calculateProgress(program)
"
></div>
</div>
</div>
}
</div>
}
}
} @else {
<div class="epg-empty-state">
<mat-icon class="empty-state-icon">event_busy</mat-icon>
<div class="empty-state-title">
{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }}
</div>
<div class="empty-state-hint">
{{ 'EPG.NO_GUIDE_HINT' | translate }}
</div>
</div>
}
</div>
}
@@ -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;
}
}
@@ -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<EpgProgram>();
readonly isLive = input(false);
readonly isActive = input(false);
readonly showArchiveBadge = input(false);
}
describe('EpgListComponent', () => {
let fixture: ComponentFixture<EpgListComponent>;
let component: EpgListComponent;
let store: { select: jest.Mock; dispatch: jest.Mock };
let epgService: { currentEpgPrograms$: BehaviorSubject<EpgProgram[]> };
let translateService: {
currentLang: string;
defaultLang: string;
onLangChange: BehaviorSubject<unknown>;
};
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<EpgProgram[]>([]),
};
translateService = {
currentLang: 'en',
defaultLang: 'en',
onLangChange: new BehaviorSubject<unknown>(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<HTMLElement>(
'.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<HTMLElement>(
'.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> = {}
): 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<EpgListComponent>
): 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);
}
}
@@ -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<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDateInput = input<string | null>(null, {
alias: 'selectedDate',
});
readonly showDateNavigator = input(true);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly selectedDateChange = output<string>();
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<ElementRef<HTMLElement>>('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<Channel | null>(
() => this.controlledChannel() ?? this.activeChannel() ?? null
);
readonly channel = computed<EpgChannel | null>(() => {
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<HTMLElement>(
'.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,
});
}
}
}
@@ -0,0 +1,6 @@
import type { EpgProgram } from '@iptvnator/shared/interfaces';
export interface EpgProgramActivationEvent {
program: EpgProgram;
type: 'live' | 'timeshift';
}
@@ -1,59 +1,6 @@
import { format } from 'date-fns';
import type { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { EPG_DATE_KEY_FORMAT } from '../epg-date';
export function trackProgram(index: number, program: EpgProgram): string {
const start = getProgramTimeMs(program.start, program.startTimestamp);
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
return [
program.channel ?? '',
Number.isFinite(start) ? start : program.start,
Number.isFinite(stop) ? stop : program.stop,
program.title ?? '',
index,
].join('|');
}
export function deduplicateProgramsByTimeSlot(
programs: EpgProgram[]
): EpgProgram[] {
const programsByTimeSlot = new Map<string, EpgProgram>();
for (const program of programs) {
const timeSlotKey = buildProgramTimeSlotKey(program);
const existingProgram = programsByTimeSlot.get(timeSlotKey);
programsByTimeSlot.set(
timeSlotKey,
existingProgram
? selectMoreInformativeProgram(existingProgram, program)
: program
);
}
return Array.from(programsByTimeSlot.values());
}
export function buildScrollContextKey(
channel: Channel | null,
programs: EpgProgram[]
): string | null {
if (!channel && programs.length === 0) {
return null;
}
const channelKey =
channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel';
const programKey = programs
.map(
(program) =>
`${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}`
)
.join('|');
return `${channelKey}:${programKey}`;
}
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import { EPG_DATE_KEY_FORMAT } from './epg-date';
export function getProgramTimeMs(
isoValue: string,
@@ -79,6 +26,26 @@ export function getProgramDateKey(
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
}
export function deduplicateProgramsByTimeSlot(
programs: EpgProgram[]
): EpgProgram[] {
const programsByTimeSlot = new Map<string, EpgProgram>();
for (const program of programs) {
const timeSlotKey = buildProgramTimeSlotKey(program);
const existingProgram = programsByTimeSlot.get(timeSlotKey);
programsByTimeSlot.set(
timeSlotKey,
existingProgram
? selectMoreInformativeProgram(existingProgram, program)
: program
);
}
return Array.from(programsByTimeSlot.values());
}
export function areProgramsSame(
left: EpgProgram,
right: EpgProgram
@@ -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.
@@ -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;
@@ -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,
@@ -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,
@@ -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). */
@@ -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;
-1
View File
@@ -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';
@@ -1,58 +0,0 @@
<mat-toolbar color="primary">
<button
mat-icon-button
(click)="toggleLeftDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
class="sidenav-toggle-button"
>
<mat-icon>{{ isLeftDrawerOpened ? 'menu' : 'menu_open' }}</mat-icon>
</button>
<!-- @if (playlistId$ | async) {
<button
mat-icon-button
(click)="updateFavoriteStatus(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{
(favorites$ | async).includes(activeChannel?.url)
? 'star'
: 'star_outline'
}}</mat-icon
>
</button>
} -->
<button
mat-icon-button
(click)="infoOverlayClicked.emit()"
[matTooltip]="'TOP_MENU.SHOW_INFO' | translate"
>
<mat-icon>info</mat-icon>
</button>
{{ activeChannel.name }}
<div class="spacer"></div>
<!-- <button
mat-icon-button
(click)="settingsClicked.emit()"
[matTooltip]="'MENU.SETTINGS' | translate"
>
<mat-icon>settings</mat-icon>
</button> -->
<button
mat-icon-button
(click)="multiEpgClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
>
<mat-icon>view_list</mat-icon>
</button>
@let isEpgAvailable = isEpgAvailable$ | async;
@if (isEpgAvailable) {
<button
mat-button
(click)="toggleRightDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
>
EPG
</button>
}
</mat-toolbar>
@@ -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;
}
}
@@ -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<void>();
readonly multiEpgClicked = output<void>();
readonly settingsClicked = output<void>();
readonly toggleLeftDrawerClicked = output<void>();
readonly toggleRightDrawerClicked = output<void>();
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 }));
}
}
+1 -2
View File
@@ -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';
@@ -1,52 +0,0 @@
<div class="epg-title">{{ 'EPG.TITLE' | translate }}</div>
<div class="epg-list">
@for (item of epgItems; track $index) {
<article
class="epg-item"
[class.current-program]="isCurrentProgram(item)"
>
<div class="epg-item__meta">
<div class="epg-item__time">
{{ item.start | date: 'HH:mm' }} -
{{ getProgramEnd(item) | date: 'HH:mm' }}
</div>
@if (isCurrentProgram(item)) {
<span class="epg-item__badge">Live</span>
}
<button
mat-icon-button
class="epg-item__info"
(click)="showDetails(item)"
>
<mat-icon>info</mat-icon>
</button>
</div>
<div class="epg-item__title">{{ item.title }}</div>
@if (item.description) {
<div class="epg-item__description">
{{ item.description }}
</div>
}
@if (isCurrentProgram(item)) {
<div class="epg-item__progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="getProgress(item)"
></div>
</div>
</div>
}
</article>
}
@if (epgItems.length === 0) {
<div class="epg-empty">
{{ 'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate }}
</div>
}
</div>
@@ -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;
}
@@ -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<EpgViewComponent>;
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');
});
});
@@ -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',
},
});
}
}
@@ -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;
}
@@ -1,169 +0,0 @@
<section class="live-epg-panel" [class.live-epg-panel--collapsed]="collapsed()">
<div class="live-epg-panel__summary-bar">
<button
mat-icon-button
class="live-epg-panel__toggle"
type="button"
(click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
[matTooltip]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<mat-icon>{{
collapsed() ? 'keyboard_arrow_up' : 'keyboard_arrow_down'
}}</mat-icon>
</button>
<div class="live-epg-panel__summary" role="status">
<span class="live-epg-panel__label">
{{ summaryLabelKey() | translate }}
</span>
<div class="live-epg-panel__program-line">
@if (loading()) {
<span class="live-epg-panel__title">
{{ 'EPG.LOADING' | translate }}
</span>
} @else if (hasSummary()) {
<span class="live-epg-panel__title">
{{ summary()?.title }}
</span>
@if (hasTimeRange()) {
<span class="live-epg-panel__time">
@if (summary()?.start) {
{{ summary()?.start | date: 'HH:mm' }}
}
@if (summary()?.start && summary()?.stop) {
-
}
@if (summary()?.stop) {
{{ summary()?.stop | date: 'HH:mm' }}
}
</span>
}
} @else {
<span
class="live-epg-panel__title live-epg-panel__title--muted"
>
{{ 'EPG.NO_PROGRAM_INFO' | translate }}
</span>
}
</div>
</div>
@if (showReturnToLive()) {
<button
mat-button
class="live-epg-panel__return-live"
type="button"
(click)="returnToLive.emit()"
[attr.aria-label]="'EPG.RETURN_TO_LIVE' | translate"
[matTooltip]="'EPG.RETURN_TO_LIVE' | translate"
>
<mat-icon>live_tv</mat-icon>
<span class="live-epg-panel__return-live-text">
{{ 'EPG.RETURN_TO_LIVE' | translate }}
</span>
</button>
}
@if (showDateNavigator()) {
@if (!collapsed()) {
<div
class="live-epg-panel__date-nav"
aria-label="EPG date navigation"
>
<button
class="previous-day"
mat-icon-button
type="button"
(click)="navigateDate('prev')"
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="live-epg-panel__selected-date selected-date">
{{
selectedDateValue()
| date
: 'MMMM d, EEEE'
: undefined
: currentLocale()
}}
</span>
<button
class="next-day"
mat-icon-button
type="button"
(click)="navigateDate('next')"
[matTooltip]="'EPG.NEXT_DAY' | translate"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<button
mat-button
type="button"
class="live-epg-panel__date-chip"
[matMenuTriggerFor]="liveEpgDateMenu"
[matTooltip]="
selectedDateValue()
| date: 'MMMM d, EEEE' : undefined : currentLocale()
"
>
<mat-icon>calendar_today</mat-icon>
<span>{{
selectedDateValue()
| date: 'MMM d, EEE' : undefined : currentLocale()
}}</span>
</button>
<mat-menu #liveEpgDateMenu="matMenu" xPosition="before">
<button
class="previous-day"
mat-menu-item
type="button"
(click)="navigateDate('prev')"
>
<mat-icon>chevron_left</mat-icon>
<span>{{ 'EPG.PREVIOUS_DAY' | translate }}</span>
</button>
<button
class="next-day"
mat-menu-item
type="button"
(click)="navigateDate('next')"
>
<mat-icon>chevron_right</mat-icon>
<span>{{ 'EPG.NEXT_DAY' | translate }}</span>
</button>
</mat-menu>
}
}
@if (progress() !== null) {
<div class="live-epg-panel__progress" aria-hidden="true">
<div
class="live-epg-panel__progress-fill"
[style.width.%]="progress()"
></div>
</div>
}
</div>
<div
class="live-epg-panel__body"
[attr.aria-hidden]="collapsed()"
[attr.inert]="collapsed() ? '' : null"
>
<ng-content />
</div>
</section>
@@ -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;
}
}
@@ -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: `
<app-live-epg-panel
[collapsed]="collapsed"
[summary]="summary"
[loading]="loading"
[summaryLabelKey]="summaryLabelKey"
[showDateNavigator]="showDateNavigator"
[selectedDate]="selectedDate"
[showReturnToLive]="showReturnToLive"
(collapsedChange)="collapsed = $event"
(dateNavigation)="dateDirection = $event"
(returnToLive)="returnToLiveCount = returnToLiveCount + 1"
>
<div class="projected-content">Projected EPG</div>
</app-live-epg-panel>
`,
})
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<HostComponent>;
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);
});
});
@@ -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<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly showReturnToLive = input(false);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<EpgDateNavigationDirection>();
readonly returnToLive = output<void>();
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));
}
-3
View File
@@ -145,7 +145,6 @@
min-height: 56px;
}
> app-live-epg-panel,
> app-epg-timeline,
> app-epg-list-view {
flex: 1;
@@ -160,8 +159,6 @@
display: flex;
flex-direction: column;
> app-epg-view,
> app-live-epg-panel,
> app-epg-timeline,
> app-epg-list-view {
flex: 1;
-5
View File
@@ -78,7 +78,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",
@@ -86,7 +85,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",
@@ -101,7 +99,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",
@@ -109,7 +106,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",
@@ -173,7 +169,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",
+2 -69
View File
@@ -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
+5 -7
View File
@@ -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."
}
]
},
-1
View File
@@ -118,7 +118,6 @@ export const maxLinesBaseline = [
'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts',
'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts',
'libs/ui/components/src/lib/season-container/season-container.component.ts',
'libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts',
'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts',