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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-02 00:41:19 +02:00
1 parent 23ead63b1f
commit 3345478bd6
80 files changed
+106 -4816

No files matched your search

-1
View File
@@ -697,7 +697,6 @@ No formal migration system yet. Schema changes are applied via raw SQL in `conne
**State Management**:
- Use NgRx for global application state (playlists)
- Use component stores (`@ngrx/component-store`) for feature-specific state
- Use NgRx signals for reactive data streams
<!-- nx configuration start-->
+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": "الاسم (ي-أ)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "البحث عن قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "فتح قائمة القنوات",
"TOGGLE_FAVORITE_FLAG": "تبديل حالة المفضلة",
"OPEN_EPG_LIST": "فتح قائمة EPG",
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG",
"SHOW_INFO": "عرض معلومات البرنامج (اضغط I)"
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG"
},
"EPG": {
"TITLE": "الدليل الإلكتروني للبرامج",
"NEXT_DAY": "اليوم التالي",
"PREVIOUS_DAY": "اليوم السابق",
"ZOOM_IN": "تكبير",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "إغلاق التصفية",
"SEARCH_PROGRAMS": "البحث في البرامج",
"CLOSE_PROGRAM_SEARCH": "إغلاق بحث البرامج",
"LIVE_NOW": "يُبث الآن",
"LIVE_STREAM": "بث مباشر",
"TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف غير متاح",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "يكشف هذا المزود عن سجل الجدول لهذه القناة، لكنه لا يوفر تشغيلًا قابلًا للأرشيف أو Catchup.",
"EPG_NOT_AVAILABLE_DATE": "عذراً، الدليل غير متاح لهذا التاريخ",
"NO_GUIDE_HINT": "جرب تاريخاً آخر أو حدّث قائمة التشغيل.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "عذراً، الدليل غير متاح لهذه القناة",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "يرجى إضافة/تعديل رابط EPG في إعدادات التطبيق.",
"FETCH_EPG": "جارٍ جلب بيانات EPG...",
"ERROR": "عذراً، فشل تحميل الدليل.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
"RETURN_TO_LIVE": "العودة إلى البث المباشر",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "فتح القائمة",
"SUPPORT_ARIA": "دعم IPTVnator على GitHub",
"SETTINGS": "الإعدادات",
"SETTINGS_ARIA": "فتح الإعدادات",
"BUG_REPORT": "ابلاغ عن خلل",
"WHAT_IS_NEW": "ما الجديد",
@@ -804,10 +789,7 @@
"SEARCH": "بحث",
"RECENT": "الأخيرة",
"RECENTLY_ADDED": "أُضيفت مؤخرًا",
"FAVORITES": "المفضلة",
"HOME": "الرئيسية",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب"
"FAVORITES": "المفضلة"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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": "الاسم (ي-أ)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "قلب على قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "حل قائمة القنوات",
"TOGGLE_FAVORITE_FLAG": "بدل علامة المفضلة",
"OPEN_EPG_LIST": "حل قائمة EPG",
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد",
"SHOW_INFO": "وري معلومات البرنامج (ضغط على I)"
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد"
},
"EPG": {
"TITLE": "دليل البرامج الإلكتروني",
"NEXT_DAY": "اليوم التالي",
"PREVIOUS_DAY": "اليوم السابق",
"ZOOM_IN": "كبر",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "سد الفلتر",
"SEARCH_PROGRAMS": "قلب على البرامج",
"CLOSE_PROGRAM_SEARCH": "سد البحث على البرامج",
"LIVE_NOW": "مباشر الآن",
"LIVE_STREAM": "بث مباشر",
"TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف ما متوفرش",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "هاد المزود كيوفر تاريخ جدول البرامج للقناة، ولكن ما كيوفرش تشغيل أرشيف/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "أوه، EPG ما متوفرش للتاريخ اللي اختاريتيه",
"NO_GUIDE_HINT": "جرب تاريخ آخر أو حدّث اللائحة.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "أوه، EPG ما متوفرش لهاد القناة.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "عفاك زيد/بدل رابط قائمة EPG في إعدادات التطبيق.",
"FETCH_EPG": "جاري جلب بيانات EPG...",
"ERROR": "أوه، EPG ما قدرش يتشارجا.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
"RETURN_TO_LIVE": "رجع للبث المباشر",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "حل القائمة",
"SUPPORT_ARIA": "دعم IPTVnator على GitHub",
"SETTINGS": "الإعدادات",
"SETTINGS_ARIA": "حل الإعدادات",
"BUG_REPORT": "بلغ على شي مشكل",
"WHAT_IS_NEW": "شنو الجديد",
@@ -804,10 +789,7 @@
"SEARCH": "بحث",
"RECENT": "أخير",
"RECENTLY_ADDED": "تزادو مؤخراً",
"FAVORITES": "المفضلة",
"HOME": "الرئيسية",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب"
"FAVORITES": "المفضلة"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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": "Назва (Я-А)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Пошук каналаў",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст",
"ALL_CHANNELS": "Усе каналы",
"GROUPS": "Групы",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Адкрыць спіс каналаў",
"TOGGLE_FAVORITE_FLAG": "Выбраны",
"OPEN_EPG_LIST": "Адкрыць праграму (EPG)",
"OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд",
"SHOW_INFO": "Паказаць інфармацыю аб праграме (націсніце I)"
"OPEN_MULTI_EPG": "Адкрыць мульты-EPG прагляд"
},
"EPG": {
"TITLE": "Электронная праграма перадач",
"NEXT_DAY": "Наступны дзень",
"PREVIOUS_DAY": "Папярэдні дзень",
"ZOOM_IN": "Павялічыць маштаб",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Закрыць фільтр",
"SEARCH_PROGRAMS": "Пошук перадач",
"CLOSE_PROGRAM_SEARCH": "Закрыць пошук перадач",
"LIVE_NOW": "Цяпер",
"LIVE_STREAM": "Прамая трансляцыя",
"TIMESHIFT_AVAILABLE": "Даступная функцыя архіва (timeshift). Націсніце на перадачу для прайгравання.",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Прайграванне архіва недаступна",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Гэты правайдэр прадастаўляе гісторыю перадач для канала, але не падтрымлівае прайграванне архіва/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ой, праграма на гэты дзень, на жаль, адсутнічае.",
"NO_GUIDE_HINT": "Паспрабуйце іншую дату або абнавіце плэйліст.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Праграма для гэтага канала недаступная.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Паспрабуйце змяніць або абнавіць спасылку на EPG у наладах праграмы.",
"FETCH_EPG": "Загружаю праграму ТБ-перадач (EPG)...",
"ERROR": "Ой, не ўдалося загрузіць праграму каналаў (EPG)",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Прайграванне архіва",
"RETURN_TO_LIVE": "Вярнуцца да эфіру",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Адкрыць меню",
"SUPPORT_ARIA": "Падтрымаць IPTVnator на GitHub",
"SETTINGS": "Налады",
"SETTINGS_ARIA": "Адкрыць налады",
"BUG_REPORT": "Паведаміць аб памылцы",
"WHAT_IS_NEW": "Што новага?",
@@ -804,10 +789,7 @@
"SEARCH": "Пошук",
"RECENT": "Нядаўнія",
"RECENTLY_ADDED": "Нядаўна дададзеныя",
"FAVORITES": "Выбранае",
"HOME": "Галоўная",
"PLAYLIST_INFO": "Інфармацыя аб плэйлісце",
"ACCOUNT_INFO": "Інфармацыя аб уліковым запісе"
"FAVORITES": "Выбранае"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Nach Sender suchen",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
"ALL_CHANNELS": "Alle Sender",
"GROUPS": "Gruppen",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Senderliste öffnen",
"TOGGLE_FAVORITE_FLAG": "Als Favoriten hinzufügen/entfernen",
"OPEN_EPG_LIST": "TV-Guide (EPG) öffnen",
"OPEN_MULTI_EPG": "Open Multi-EPG view",
"SHOW_INFO": "Programminfo anzeigen (Taste I)"
"OPEN_MULTI_EPG": "Open Multi-EPG view"
},
"EPG": {
"TITLE": "Elektronischer Programmführer",
"NEXT_DAY": "Zum nächsten Tag",
"PREVIOUS_DAY": "Zum vorherigen Tag",
"ZOOM_IN": "Vergrößern",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Filter schließen",
"SEARCH_PROGRAMS": "Sendungen suchen",
"CLOSE_PROGRAM_SEARCH": "Sendungssuche schließen",
"LIVE_NOW": "Live jetzt",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Die Timeshift-Funktion (Archiv) ist verfügbar, klicken Sie an um abzuspielen",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archivwiedergabe nicht verfügbar",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Dieser Anbieter stellt zwar den Programmverlauf für den Sender bereit, jedoch keine abspielbare Archiv-/Catchup-Wiedergabe.",
"EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar",
"NO_GUIDE_HINT": "Versuchen Sie ein anderes Datum oder aktualisieren Sie die Playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.",
"FETCH_EPG": "EPG-Daten werden geladen...",
"ERROR": "Ooops, EPG konnte nicht geladen werden",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Archivwiedergabe",
"RETURN_TO_LIVE": "Zurück zum Live-TV",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Menü öffnen",
"SUPPORT_ARIA": "IPTVnator auf GitHub unterstützen",
"SETTINGS": "Einstellungen",
"SETTINGS_ARIA": "Einstellungen öffnen",
"BUG_REPORT": "Fehler melden",
"WHAT_IS_NEW": "Was ist neu",
@@ -804,10 +789,7 @@
"SEARCH": "Suche",
"RECENT": "Zuletzt angesehen",
"RECENTLY_ADDED": "Kürzlich hinzugefügt",
"FAVORITES": "Favoriten",
"HOME": "Startseite",
"PLAYLIST_INFO": "Playlist-Info",
"ACCOUNT_INFO": "Account-Info"
"FAVORITES": "Favoriten"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Αναζήτηση καναλιού",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
"ALL_CHANNELS": "Όλα τα κανάλια",
"GROUPS": "Ομάδες",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Άνοιγμα λίστας καναλιών",
"TOGGLE_FAVORITE_FLAG": "Εναλλαγή σημαίας αγαπημένων",
"OPEN_EPG_LIST": "Άνοιγμα λίστας EPG",
"OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG",
"SHOW_INFO": "Εμφάνιση πληροφοριών προγράμματος (Πατήστε το I)"
"OPEN_MULTI_EPG": "Άνοιγμα προβολής Πολλαπλού EPG"
},
"EPG": {
"TITLE": "Ηλεκτρονικός Οδηγός Προγράμματος",
"NEXT_DAY": "Επόμενη μέρα",
"PREVIOUS_DAY": "Προηγούμενη ημέρα",
"ZOOM_IN": "Μεγέθυνση",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Κλείσιμο φίλτρου",
"SEARCH_PROGRAMS": "Αναζήτηση προγραμμάτων",
"CLOSE_PROGRAM_SEARCH": "Κλείσιμο αναζήτησης προγραμμάτων",
"LIVE_NOW": "Ζωντανά τώρα",
"LIVE_STREAM": "Ζωντανή ροή",
"TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Η αναπαραγωγή αρχείου δεν είναι διαθέσιμη",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Αυτός ο πάροχος εκθέτει ιστορικό προγράμματος για το κανάλι, αλλά δεν παρέχει αναπαραγωγή αρχείου/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ωχ, το EPG δεν είναι διαθέσιμο για την επιλεγμένη ημερομηνία",
"NO_GUIDE_HINT": "Δοκιμάστε άλλη ημερομηνία ή ανανεώστε τη λίστα αναπαραγωγής.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ωχ, το EPG δεν είναι διαθέσιμο για αυτό το κανάλι.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Προσθέστε/αλλάξτε τη διεύθυνση URL της λίστας EPG στις ρυθμίσεις της εφαρμογής.",
"FETCH_EPG": "Ανάκτηση δεδομένων EPG...",
"ERROR": "Ωχ, δεν ήταν δυνατή η φόρτωση του EPG.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου",
"RETURN_TO_LIVE": "Επιστροφή στη ζωντανή ροή",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Άνοιγμα μενού",
"SUPPORT_ARIA": "Υποστήριξη του IPTVnator στο GitHub",
"SETTINGS": "Ρυθμίσεις",
"SETTINGS_ARIA": "Άνοιγμα ρυθμίσεων",
"BUG_REPORT": "Αναφορά σφάλματος",
"WHAT_IS_NEW": "Τι νέο υπάρχει",
@@ -804,10 +789,7 @@
"SEARCH": "Αναζήτηση",
"RECENT": "Πρόσφατα",
"RECENTLY_ADDED": "Πρόσφατα προστέθηκε",
"FAVORITES": "Αγαπημένα",
"HOME": "Αρχικό",
"PLAYLIST_INFO": "Πληροφορίες λίστας αναπαραγωγής",
"ACCOUNT_INFO": "Πληροφορίες λογαριασμού"
"FAVORITES": "Αγαπημένα"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Search channel",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist",
"ALL_CHANNELS": "All channels",
"GROUPS": "Groups",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Open channel list",
"TOGGLE_FAVORITE_FLAG": "Toggle favorite flag",
"OPEN_EPG_LIST": "Open EPG list",
"OPEN_MULTI_EPG": "Open Multi-EPG view",
"SHOW_INFO": "Show program info (Press I)"
"OPEN_MULTI_EPG": "Open Multi-EPG view"
},
"EPG": {
"TITLE": "Electronic Program Guide",
"NEXT_DAY": "Next day",
"PREVIOUS_DAY": "Previous day",
"ZOOM_IN": "Zoom in",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Close filter",
"SEARCH_PROGRAMS": "Search programs",
"CLOSE_PROGRAM_SEARCH": "Close program search",
"LIVE_NOW": "Live now",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Timeshift is available, click to play",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archive playback is unavailable",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "This provider exposes schedule history for the channel, but does not expose playable archive/catch-up playback.",
"EPG_NOT_AVAILABLE_DATE": "No program guide",
"NO_GUIDE_HINT": "Try a different date or refresh the playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the URL of the EPG list in the settings of the app.",
"FETCH_EPG": "Fetching EPG data...",
"ERROR": "Ooops, EPG could not be loaded.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Archive playback",
"RETURN_TO_LIVE": "Return to live",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Open menu",
"SUPPORT_ARIA": "Support IPTVnator on GitHub",
"SETTINGS": "Settings",
"SETTINGS_ARIA": "Open settings",
"BUG_REPORT": "Report a bug",
"WHAT_IS_NEW": "What is new",
@@ -804,10 +789,7 @@
"SEARCH": "Search",
"RECENT": "Recent",
"RECENTLY_ADDED": "Recently added",
"FAVORITES": "Favorites",
"HOME": "Home",
"PLAYLIST_INFO": "Playlist info",
"ACCOUNT_INFO": "Account info"
"FAVORITES": "Favorites"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Buscar canal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción",
"ALL_CHANNELS": "Todos los canales",
"GROUPS": "Grupos",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Abrir lista de canales",
"TOGGLE_FAVORITE_FLAG": "Alternar bandera favorita",
"OPEN_EPG_LIST": "Abrir lista EPG",
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG",
"SHOW_INFO": "Mostrar información del programa (presiona I)"
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG"
},
"EPG": {
"TITLE": "Guía electrónica de programación",
"NEXT_DAY": "Día siguiente",
"PREVIOUS_DAY": "Día anterior",
"ZOOM_IN": "Acercar",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Cerrar filtro",
"SEARCH_PROGRAMS": "Buscar programas",
"CLOSE_PROGRAM_SEARCH": "Cerrar búsqueda de programas",
"LIVE_NOW": "Ahora en vivo",
"LIVE_STREAM": "Transmisión en vivo",
"TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "La reproducción del archivo no está disponible",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este proveedor expone el historial de programación del canal, pero no permite la reproducción del archivo/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "Vaya, no está disponible la EPG de esa fecha",
"NO_GUIDE_HINT": "Prueba otra fecha o actualiza la lista de reproducción.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Vaya, no está disponible la EPG del canal.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Por favor añada/cargue la url de la lista EPG en los ajustes de la app.",
"FETCH_EPG": "Obteniendo datos de EPG...",
"ERROR": "¡Vaya! Esta EPG no se pudo cargar.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Reproducción de archivo",
"RETURN_TO_LIVE": "Volver al directo",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Abrir menú",
"SUPPORT_ARIA": "Apoyar IPTVnator en GitHub",
"SETTINGS": "Ajustes",
"SETTINGS_ARIA": "Abrir ajustes",
"BUG_REPORT": "Reportar un bug",
"WHAT_IS_NEW": "Novedades",
@@ -804,10 +789,7 @@
"SEARCH": "Buscar",
"RECENT": "Reciente",
"RECENTLY_ADDED": "Añadidos recientemente",
"FAVORITES": "Favoritos",
"HOME": "Inicio",
"PLAYLIST_INFO": "Información de la lista",
"ACCOUNT_INFO": "Información de la cuenta"
"FAVORITES": "Favoritos"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Rechercher une chaîne",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture",
"ALL_CHANNELS": "Toutes les chaînes",
"GROUPS": "Groupes",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Ouvrir la liste des chaînes",
"TOGGLE_FAVORITE_FLAG": "Basculer le statut de favoris",
"OPEN_EPG_LIST": "Ouvrir la liste EPG",
"OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG",
"SHOW_INFO": "Afficher les infos du programme (Appuyez sur I)"
"OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG"
},
"EPG": {
"TITLE": "Guide électronique des programmes",
"NEXT_DAY": "Jour suivant",
"PREVIOUS_DAY": "Jour précédent",
"ZOOM_IN": "Zoom avant",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Fermer le filtre",
"SEARCH_PROGRAMS": "Rechercher des programmes",
"CLOSE_PROGRAM_SEARCH": "Fermer la recherche de programmes",
"LIVE_NOW": "En direct",
"LIVE_STREAM": "Diffusion en direct",
"TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Lecture des archives indisponible",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ce fournisseur expose l'historique des programmes pour la chaîne, mais n'expose pas la lecture archive/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "L'EPG n'est pas disponible pour la date sélectionnée",
"NO_GUIDE_HINT": "Essayez une autre date ou actualisez la playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "L'EPG n'est pas disponible pour cette chaîne.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Veuillez ajouter/modifier l'URL de la liste EPG dans les réglages de l'application.",
"FETCH_EPG": "Récupération des données EPG...",
"ERROR": "L'EPG n'a pas pu être chargé.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Lecture archive",
"RETURN_TO_LIVE": "Retour au direct",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Ouvrir le menu",
"SUPPORT_ARIA": "Soutenir IPTVnator sur GitHub",
"SETTINGS": "Paramètres",
"SETTINGS_ARIA": "Ouvrir les paramètres",
"BUG_REPORT": "Signaler un bug",
"WHAT_IS_NEW": "Nouveautés",
@@ -804,10 +789,7 @@
"SEARCH": "Rechercher",
"RECENT": "Récent",
"RECENTLY_ADDED": "Récemment ajoutés",
"FAVORITES": "Favoris",
"HOME": "Accueil",
"PLAYLIST_INFO": "Infos de la liste",
"ACCOUNT_INFO": "Infos du compte"
"FAVORITES": "Favoris"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Cerca canale",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist",
"ALL_CHANNELS": "Tutti i canali",
"GROUPS": "Gruppi",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Apri elenco canali",
"TOGGLE_FAVORITE_FLAG": "Attiva/disattiva flag preferiti",
"OPEN_EPG_LIST": "Apri elenco EPG",
"OPEN_MULTI_EPG": "Apri vista Multi-EPG",
"SHOW_INFO": "Mostra info programma (premi I)"
"OPEN_MULTI_EPG": "Apri vista Multi-EPG"
},
"EPG": {
"TITLE": "Guida elettronica programmi",
"NEXT_DAY": "Giorno successivo",
"PREVIOUS_DAY": "Giorno precedente",
"ZOOM_IN": "Ingrandisci",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Chiudi filtro",
"SEARCH_PROGRAMS": "Cerca programmi",
"CLOSE_PROGRAM_SEARCH": "Chiudi ricerca programmi",
"LIVE_NOW": "Live attuale",
"LIVE_STREAM": "Stream live",
"TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Riproduzione archivio non disponibile",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Questo fornitore espone la cronologia della programmazione del canale, ma non la riproduzione di archivio/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Non è disponibile l'EPG per la data selezionata",
"NO_GUIDE_HINT": "Prova una data diversa o aggiorna la playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Non è disponibile l'EPG per questo canale.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Aggiungi/modifica l'URL dell'elenco EPG nelle impostazioni dell'app.",
"FETCH_EPG": "Recupero dati EPG...",
"ERROR": "Non è stato possibile caricare l'EPG.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Riproduzione archivio",
"RETURN_TO_LIVE": "Torna alla diretta",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Apri menu",
"SUPPORT_ARIA": "Supporta IPTVnator su GitHub",
"SETTINGS": "Impostazioni",
"SETTINGS_ARIA": "Apri impostazioni",
"BUG_REPORT": "Segnala un bug",
"WHAT_IS_NEW": "Novità del programma",
@@ -804,10 +789,7 @@
"SEARCH": "Cerca",
"RECENT": "Recenti",
"RECENTLY_ADDED": "Aggiunti di recente",
"FAVORITES": "Preferiti",
"HOME": "Home",
"PLAYLIST_INFO": "Info playlist",
"ACCOUNT_INFO": "Info account"
"FAVORITES": "Preferiti"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "チャンネルを検索",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
"ALL_CHANNELS": "すべてのチャンネル",
"GROUPS": "グループ",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "チャンネルリストを開く",
"TOGGLE_FAVORITE_FLAG": "お気に入りフラグを切り替え",
"OPEN_EPG_LIST": "EPGリストを開く",
"OPEN_MULTI_EPG": "マルチEPGビューを開く",
"SHOW_INFO": "番組情報を表示(Iキー)"
"OPEN_MULTI_EPG": "マルチEPGビューを開く"
},
"EPG": {
"TITLE": "電子番組表",
"NEXT_DAY": "翌日",
"PREVIOUS_DAY": "前日",
"ZOOM_IN": "拡大",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "フィルターを閉じる",
"SEARCH_PROGRAMS": "番組を検索",
"CLOSE_PROGRAM_SEARCH": "番組検索を閉じる",
"LIVE_NOW": "現在放送中",
"LIVE_STREAM": "ライブストリーム",
"TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "アーカイブ再生は利用できません",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "このプロバイダーはチャンネルの番組履歴を提供していますが、アーカイブ/Catchup再生には対応していません。",
"EPG_NOT_AVAILABLE_DATE": "申し訳ありません。選択した日付のEPGは利用できません",
"NO_GUIDE_HINT": "別の日付を試すか、プレイリストを更新してください。",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "申し訳ありません。このチャンネルのEPGは利用できません。",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "アプリの設定でEPGリストのURLを追加/変更してください。",
"FETCH_EPG": "EPGデータを取得中...",
"ERROR": "申し訳ありません。EPGを読み込めませんでした。",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "アーカイブ再生",
"RETURN_TO_LIVE": "ライブに戻る",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "メニューを開く",
"SUPPORT_ARIA": "GitHubでIPTVnatorをサポート",
"SETTINGS": "設定",
"SETTINGS_ARIA": "設定を開く",
"BUG_REPORT": "バグを報告",
"WHAT_IS_NEW": "新着情報",
@@ -804,10 +789,7 @@
"SEARCH": "検索",
"RECENT": "最近",
"RECENTLY_ADDED": "最近追加した項目",
"FAVORITES": "お気に入り",
"HOME": "ホーム",
"PLAYLIST_INFO": "プレイリスト情報",
"ACCOUNT_INFO": "アカウント情報"
"FAVORITES": "お気に入り"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "검색 채널",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오",
"ALL_CHANNELS": "모든 채널",
"GROUPS": "그룹",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "채널 열기 목록",
"TOGGLE_FAVORITE_FLAG": "즐겨찾기 보이기/숨기기",
"OPEN_EPG_LIST": "EPG 목록 열기",
"OPEN_MULTI_EPG": "Open Multi-EPG view",
"SHOW_INFO": "프로그램 정보 표시 (I 키)"
"OPEN_MULTI_EPG": "Open Multi-EPG view"
},
"EPG": {
"TITLE": "전자 프로그램 가이드",
"NEXT_DAY": "다음날",
"PREVIOUS_DAY": "전날",
"ZOOM_IN": "확대",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "필터 닫기",
"SEARCH_PROGRAMS": "프로그램 검색",
"CLOSE_PROGRAM_SEARCH": "프로그램 검색 닫기",
"LIVE_NOW": "Live now",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Timeshift를 사용할 수 있습니다, 클릭하여 재생합니다",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "아카이브 재생을 사용할 수 없습니다",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "이 공급자는 채널의 편성 이력을 제공하지만 재생 가능한 아카이브/Catchup 재생은 제공하지 않습니다.",
"EPG_NOT_AVAILABLE_DATE": "선택한 날짜에는 EPG를 사용할 수 없습니다.",
"NO_GUIDE_HINT": "다른 날짜를 시도하거나 재생 목록을 새로 고치세요.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "이 채널에는 EPG를 사용할 수 없습니다.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "설정에서 EPG의 URL을 추가/변경하십시오.",
"FETCH_EPG": "EPG 데이터 가져 오기...",
"ERROR": "Ooops, EPG could not be loaded.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "아카이브 재생",
"RETURN_TO_LIVE": "라이브로 돌아가기",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Open menu",
"SUPPORT_ARIA": "Support IPTVnator on GitHub",
"SETTINGS": "Settings",
"SETTINGS_ARIA": "Open settings",
"BUG_REPORT": "Report a bug",
"WHAT_IS_NEW": "What is new",
@@ -804,10 +789,7 @@
"SEARCH": "검색",
"RECENT": "최근",
"RECENTLY_ADDED": "최근 추가됨",
"FAVORITES": "즐겨찾기",
"HOME": "홈",
"PLAYLIST_INFO": "재생목록 정보",
"ACCOUNT_INFO": "계정 정보"
"FAVORITES": "즐겨찾기"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Zoek een kanaal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst",
"ALL_CHANNELS": "Alle kanalen",
"GROUPS": "Groepen",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Open kanalenlijst",
"TOGGLE_FAVORITE_FLAG": "Wissel favorietenstatus",
"OPEN_EPG_LIST": "Open TV-gids lijst",
"OPEN_MULTI_EPG": "Open Multi TV-Gids weergave",
"SHOW_INFO": "Programma-info tonen (druk op I)"
"OPEN_MULTI_EPG": "Open Multi TV-Gids weergave"
},
"EPG": {
"TITLE": "TV-Gids",
"NEXT_DAY": "Volgende dag",
"PREVIOUS_DAY": "Vorige dag",
"ZOOM_IN": "Inzoomen",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Filter sluiten",
"SEARCH_PROGRAMS": "Programma's zoeken",
"CLOSE_PROGRAM_SEARCH": "Programmazoekopdracht sluiten",
"LIVE_NOW": "Nu live",
"LIVE_STREAM": "Live stream",
"TIMESHIFT_AVAILABLE": "Doorspoelen is beschikbaar, klik om af te spelen",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archiefweergave is niet beschikbaar",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Deze provider toont de programmageschiedenis voor het kanaal, maar biedt geen afspeelbare archief-/catch-up-weergave.",
"EPG_NOT_AVAILABLE_DATE": "De TV-Gids is niet beschikbaar voor de geselecteerde datum",
"NO_GUIDE_HINT": "Probeer een andere datum of vernieuw de afspeellijst.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "De TV-Gids is niet beschikbaar voor dit kanaal.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Voeg een TV-Gids URL toe of wijzig deze in de instellingen van de app.",
"FETCH_EPG": "Ophalen TV-Gids...",
"ERROR": "TV-Gids kan niet geladen worden.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Archief afspelen",
"RETURN_TO_LIVE": "Terug naar live",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Open menu",
"SUPPORT_ARIA": "Ondersteun IPTVnator op GitHub",
"SETTINGS": "Instellingen",
"SETTINGS_ARIA": "Open instellingen",
"BUG_REPORT": "Meld een bug",
"WHAT_IS_NEW": "Wat is er nieuw",
@@ -804,10 +789,7 @@
"SEARCH": "Zoeken",
"RECENT": "Recent",
"RECENTLY_ADDED": "Recent toegevoegd",
"FAVORITES": "Favorieten",
"HOME": "Home",
"PLAYLIST_INFO": "Afspeellijstinfo",
"ACCOUNT_INFO": "Accountinfo"
"FAVORITES": "Favorieten"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Szukaj kanału",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania",
"ALL_CHANNELS": "Wszystkie kanały",
"GROUPS": "Grupy",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Otwórz listę kanałów",
"TOGGLE_FAVORITE_FLAG": "Przełącz flagę ulubionych",
"OPEN_EPG_LIST": "Otwórz listę EPG",
"OPEN_MULTI_EPG": "Otwórz widok Multi-EPG",
"SHOW_INFO": "Pokaż informacje o programie (naciśnij I)"
"OPEN_MULTI_EPG": "Otwórz widok Multi-EPG"
},
"EPG": {
"TITLE": "Elektroniczny przewodnik po programach",
"NEXT_DAY": "Następny dzień",
"PREVIOUS_DAY": "Poprzedni dzień",
"ZOOM_IN": "Powiększ",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Zamknij filtr",
"SEARCH_PROGRAMS": "Szukaj programów",
"CLOSE_PROGRAM_SEARCH": "Zamknij wyszukiwanie programów",
"LIVE_NOW": "Na żywo teraz",
"LIVE_STREAM": "Transmisja na żywo",
"TIMESHIFT_AVAILABLE": "Dostępne jest przesunięcie czasowe, kliknij, aby odtworzyć",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Odtwarzanie archiwum niedostępne",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ten dostawca udostępnia historię programu dla kanału, ale nie udostępnia odtwarzalnego archiwum/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ups, EPG nie jest dostępne dla wybranej daty",
"NO_GUIDE_HINT": "Spróbuj innej daty lub odśwież playlistę.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, EPG nie jest dostępne dla tego kanału.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Dodaj/zmień URL listy EPG w ustawieniach aplikacji.",
"FETCH_EPG": "Pobieranie danych EPG...",
"ERROR": "Ups, nie można załadować EPG.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Odtwarzanie archiwum",
"RETURN_TO_LIVE": "Wróć do transmisji na żywo",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Otwórz menu",
"SUPPORT_ARIA": "Wsparcie IPTVnator na GitHubie",
"SETTINGS": "Ustawienia",
"SETTINGS_ARIA": "Otwórz ustawienia",
"BUG_REPORT": "Zgłoś błąd",
"WHAT_IS_NEW": "Co nowego",
@@ -804,10 +789,7 @@
"SEARCH": "Szukaj",
"RECENT": "Ostatnie",
"RECENTLY_ADDED": "Ostatnio dodane",
"FAVORITES": "Ulubione",
"HOME": "Strona główna",
"PLAYLIST_INFO": "Informacje o liście odtwarzania",
"ACCOUNT_INFO": "Informacje o koncie"
"FAVORITES": "Ulubione"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Pesquisar canal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist",
"ALL_CHANNELS": "Todos os canais",
"GROUPS": "Grupos",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Abrir lista de canais",
"TOGGLE_FAVORITE_FLAG": "Alternar sinalizador de favorito",
"OPEN_EPG_LIST": "Abrir lista de EPG",
"OPEN_MULTI_EPG": "Abrir visualização Multi-EPG",
"SHOW_INFO": "Mostrar informações do programa (pressione I)"
"OPEN_MULTI_EPG": "Abrir visualização Multi-EPG"
},
"EPG": {
"TITLE": "Guia Eletrônico de Programação",
"NEXT_DAY": "Próximo dia",
"PREVIOUS_DAY": "Dia anterior",
"ZOOM_IN": "Mais zoom",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Fechar filtro",
"SEARCH_PROGRAMS": "Pesquisar programas",
"CLOSE_PROGRAM_SEARCH": "Fechar pesquisa de programas",
"LIVE_NOW": "Ao vivo agora",
"LIVE_STREAM": "Transmissão ao vivo",
"TIMESHIFT_AVAILABLE": "Timeshift está disponível, clique para reproduzir",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Reprodução de arquivo indisponível",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este provedor expõe o histórico de programação do canal, mas não disponibiliza reprodução de arquivo/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "Ooops, EPG não está disponível para a data selecionada",
"NO_GUIDE_HINT": "Tente uma data diferente ou atualize a lista de reprodução.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG não está disponível para este canal.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Adicione/altere a URL da lista de EPG nas configurações do aplicativo.",
"FETCH_EPG": "Buscando dados do EPG...",
"ERROR": "Ooops, EPG não pôde ser carregado.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Reprodução do arquivo",
"RETURN_TO_LIVE": "Voltar ao vivo",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Abrir menu",
"SUPPORT_ARIA": "Apoie o IPTVnator no GitHub",
"SETTINGS": "Configurações",
"SETTINGS_ARIA": "Abrir configurações",
"BUG_REPORT": "Reportar um bug",
"WHAT_IS_NEW": "O que há de novo",
@@ -804,10 +789,7 @@
"SEARCH": "Pesquisar",
"RECENT": "Recente",
"RECENTLY_ADDED": "Adicionados recentemente",
"FAVORITES": "Favoritos",
"HOME": "Início",
"PLAYLIST_INFO": "Informações da playlist",
"ACCOUNT_INFO": "Informações da conta"
"FAVORITES": "Favoritos"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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": "Имени (Я-А)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Поиск каналов",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
"ALL_CHANNELS": "Все каналы",
"GROUPS": "Группы",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Открыть список каналов",
"TOGGLE_FAVORITE_FLAG": "Избранный",
"OPEN_EPG_LIST": "Открыть программу (EPG)",
"OPEN_MULTI_EPG": "Открыть Multi-EPG вид",
"SHOW_INFO": "Показать информацию о программе (нажмите I)"
"OPEN_MULTI_EPG": "Открыть Multi-EPG вид"
},
"EPG": {
"TITLE": "Электронный программный гид",
"NEXT_DAY": "Следующий день",
"PREVIOUS_DAY": "Предыдущий день",
"ZOOM_IN": "Увеличить",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Закрыть фильтр",
"SEARCH_PROGRAMS": "Поиск программ",
"CLOSE_PROGRAM_SEARCH": "Закрыть поиск программ",
"LIVE_NOW": "Сейчас",
"LIVE_STREAM": "Прямая трансляция",
"TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Воспроизведение архива недоступно",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Этот провайдер предоставляет историю программы для канала, но не предоставляет воспроизводимый архив/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ой, программа на этот день, к сожалению, отсутствует.",
"NO_GUIDE_HINT": "Попробуйте другую дату или обновите плейлист.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Программа для этого канала недоступна.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.",
"FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...",
"ERROR": "Ой, не удалось загрузить программу каналов (EPG)",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Архивная запись",
"RETURN_TO_LIVE": "Вернуться к эфиру",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Открыть меню",
"SUPPORT_ARIA": "Поддержать IPTVnator на GitHub",
"SETTINGS": "Настройки",
"SETTINGS_ARIA": "Открыть настройки",
"BUG_REPORT": "Сообщить об ошибке",
"WHAT_IS_NEW": "Что нового?",
@@ -804,10 +789,7 @@
"SEARCH": "Поиск",
"RECENT": "Недавние",
"RECENTLY_ADDED": "Недавно добавленные",
"FAVORITES": "Избранное",
"HOME": "Домой",
"PLAYLIST_INFO": "Информация о плейлисте",
"ACCOUNT_INFO": "Информация об аккаунте"
"FAVORITES": "Избранное"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Kanal ara",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin",
"ALL_CHANNELS": "Tüm kanallar",
"GROUPS": "Gruplar",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Kanal listesini aç",
"TOGGLE_FAVORITE_FLAG": "Favori işaretini değiştir",
"OPEN_EPG_LIST": "EPG listesini aç",
"OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç",
"SHOW_INFO": "Program bilgisini göster (I tuşuna basın)"
"OPEN_MULTI_EPG": "Çoklu EPG görünümünü aç"
},
"EPG": {
"TITLE": "Elektronik Program Rehberi",
"NEXT_DAY": "Sonraki gün",
"PREVIOUS_DAY": "Önceki gün",
"ZOOM_IN": "Yakınlaştır",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "Filtreyi kapat",
"SEARCH_PROGRAMS": "Programları ara",
"CLOSE_PROGRAM_SEARCH": "Program aramayı kapat",
"LIVE_NOW": "Şu an canlı",
"LIVE_STREAM": "Canlı yayın",
"TIMESHIFT_AVAILABLE": "Zaman kaydırma mevcut, tıklayarak oynat",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Arşiv oynatma kullanılamıyor",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Bu sağlayıcı kanal için yayın akışı geçmişini sunar, ancak oynatılabilir arşiv/Catchup oynatma sunmaz.",
"EPG_NOT_AVAILABLE_DATE": "Üzgünüz, seçilen tarih için EPG mevcut değil",
"NO_GUIDE_HINT": "Başka bir tarih deneyin veya oynatma listesini yenileyin.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Üzgünüz, bu kanal için EPG mevcut değil.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Lütfen uygulamanın ayarlarında EPG listesi URL'sini ekleyin veya değiştirin.",
"FETCH_EPG": "EPG verileri alınıyor...",
"ERROR": "Üzgünüz, EPG yüklenemedi.",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "Arşiv oynatma",
"RETURN_TO_LIVE": "Canlı yayına dön",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "Menüyü aç",
"SUPPORT_ARIA": "IPTVnator'u GitHub'da destekleyin",
"SETTINGS": "Ayarlar",
"SETTINGS_ARIA": "Ayarları aç",
"BUG_REPORT": "Hata bildir",
"WHAT_IS_NEW": "Yeni ne var?",
@@ -804,10 +789,7 @@
"SEARCH": "Ara",
"RECENT": "Son İzlenenler",
"RECENTLY_ADDED": "Yeni Eklenenler",
"FAVORITES": "Favoriler",
"HOME": "Ana Sayfa",
"PLAYLIST_INFO": "Oynatma Listesi Bilgisi",
"ACCOUNT_INFO": "Hesap Bilgisi"
"FAVORITES": "Favoriler"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "搜索频道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表",
"ALL_CHANNELS": "所有频道",
"GROUPS": "分组",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "打开频道列表",
"TOGGLE_FAVORITE_FLAG": "切换收藏夹标志",
"OPEN_EPG_LIST": "打开电子节目单EPG列表",
"OPEN_MULTI_EPG": "打开多EPG显示",
"SHOW_INFO": "显示节目信息(按 I 键)"
"OPEN_MULTI_EPG": "打开多EPG显示"
},
"EPG": {
"TITLE": "电子节目指南",
"NEXT_DAY": "明天",
"PREVIOUS_DAY": "昨天",
"ZOOM_IN": "放大",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "关闭筛选",
"SEARCH_PROGRAMS": "搜索节目",
"CLOSE_PROGRAM_SEARCH": "关闭节目搜索",
"LIVE_NOW": "正在播放",
"LIVE_STREAM": "正在直播",
"TIMESHIFT_AVAILABLE": "时移功能可用,点击播放",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "归档播放不可用",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此提供商提供了该频道的节目历史,但不提供可播放的归档/Catchup 回看。",
"EPG_NOT_AVAILABLE_DATE": "错误:电子节目单EPG在所选日期不可用",
"NO_GUIDE_HINT": "尝试其他日期或刷新播放列表。",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "错误:电子节目单EPG不适用于此频道",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "请添加/更改设置项中电子节目单的URL",
"FETCH_EPG": "获取 EPG 数据...",
"ERROR": "错误: 获取 EPG 数据失败",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "回看播放",
"RETURN_TO_LIVE": "返回直播",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "打开菜单",
"SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator",
"SETTINGS": "设置",
"SETTINGS_ARIA": "打开设置",
"BUG_REPORT": "问题反馈",
"WHAT_IS_NEW": "更新日志",
@@ -804,10 +789,7 @@
"SEARCH": "搜索",
"RECENT": "最近",
"RECENTLY_ADDED": "最近添加",
"FAVORITES": "收藏",
"HOME": "首页",
"PLAYLIST_INFO": "播放列表信息",
"ACCOUNT_INFO": "账户信息"
"FAVORITES": "收藏"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
+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)",
@@ -483,7 +481,6 @@
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "搜尋頻道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單",
"ALL_CHANNELS": "所有頻道",
"GROUPS": "群組",
@@ -557,14 +554,9 @@
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "打開頻道列表",
"TOGGLE_FAVORITE_FLAG": "切換我的最愛標記",
"OPEN_EPG_LIST": "開啟 EPG 列表",
"OPEN_MULTI_EPG": "開啟多 EPG 視圖",
"SHOW_INFO": "顯示節目資訊(按 I 鍵)"
"OPEN_MULTI_EPG": "開啟多 EPG 視圖"
},
"EPG": {
"TITLE": "電子節目指南",
"NEXT_DAY": "下一天",
"PREVIOUS_DAY": "上一天",
"ZOOM_IN": "放大",
@@ -573,14 +565,9 @@
"CLOSE_FILTER": "關閉篩選",
"SEARCH_PROGRAMS": "搜尋節目",
"CLOSE_PROGRAM_SEARCH": "關閉節目搜尋",
"LIVE_NOW": "現在播放",
"LIVE_STREAM": "現場直播",
"TIMESHIFT_AVAILABLE": "時間平移功能可用,點選以播放",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "無法播放存檔內容",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "此供應商有提供本頻道的歷史節目表,但未提供可播放的存檔/Catchup 播放功能。",
"EPG_NOT_AVAILABLE_DATE": "抱歉,選擇的日期上 EPG 無法使用",
"NO_GUIDE_HINT": "嘗試其他日期或重新整理播放清單。",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "抱歉,此頻道上的 EPG 無法使用。",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "請在應用程式設定中增加/更改 EPG 列表的URL。",
"FETCH_EPG": "取得 EPG 資料...",
"ERROR": "抱歉,無法載入 EPG。",
@@ -615,7 +602,6 @@
"TRUST_TLS_HOST_WARNING": "Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.",
"CURRENT_PROGRAM": "Current program",
"ARCHIVE_PLAYBACK": "回看播放",
"RETURN_TO_LIVE": "返回直播",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
"TIMELINE": {
@@ -679,7 +665,6 @@
"MENU": {
"OPEN": "開啟選單",
"SUPPORT_ARIA": "在 GitHub 上支持 IPTVnator",
"SETTINGS": "設定",
"SETTINGS_ARIA": "開啟設定",
"BUG_REPORT": "回報問題",
"WHAT_IS_NEW": "更新內容",
@@ -804,10 +789,7 @@
"SEARCH": "搜尋",
"RECENT": "最近觀看",
"RECENTLY_ADDED": "最近新增",
"FAVORITES": "我的最愛",
"HOME": "首頁",
"PLAYLIST_INFO": "播放清單資訊",
"ACCOUNT_INFO": "帳戶資訊"
"FAVORITES": "我的最愛"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
-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).
+4 -5
View File
@@ -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.
+1 -2
View File
@@ -564,8 +564,7 @@ class EpgService {
| Component | Purpose |
| ----------------------------- | ------------------------------------ |
| `EpgListComponent` | Timeline view for single channel |
| `EpgListItemComponent` | Individual program in timeline |
| `EpgTimelineComponent` | Horizontal timeline for one channel |
| `EpgItemDescriptionComponent` | Program details dialog |
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
-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,7 @@
display: flex;
flex-direction: column;
> app-epg-view,
> app-epg-list {
> app-epg-timeline {
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-date';
export * from './lib/epg-timeline/epg-timeline.component';
export * from './lib/epg-timeline/epg-timeline-empty-state.component';
@@ -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,
});
}
}
}
@@ -1,125 +0,0 @@
import { format } from 'date-fns';
import type { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { EPG_DATE_KEY_FORMAT } from '../epg-date';
export function trackProgram(index: number, program: EpgProgram): string {
const start = getProgramTimeMs(program.start, program.startTimestamp);
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
return [
program.channel ?? '',
Number.isFinite(start) ? start : program.start,
Number.isFinite(stop) ? stop : program.stop,
program.title ?? '',
index,
].join('|');
}
export function deduplicateProgramsByTimeSlot(
programs: EpgProgram[]
): EpgProgram[] {
const programsByTimeSlot = new Map<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}`;
}
export function getProgramTimeMs(
isoValue: string,
timestampValue?: number | null
): number {
if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) {
return Number(timestampValue) * 1000;
}
return Date.parse(isoValue);
}
export function getProgramDateKey(
isoValue: string,
timestampValue?: number | null
): string {
const programTimeMs = getProgramTimeMs(isoValue, timestampValue);
if (!Number.isFinite(programTimeMs)) {
return '';
}
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
}
export function areProgramsSame(
left: EpgProgram,
right: EpgProgram
): boolean {
return (
(left.channel ?? '') === (right.channel ?? '') &&
getProgramTimeMs(left.start, left.startTimestamp) ===
getProgramTimeMs(right.start, right.startTimestamp) &&
getProgramTimeMs(left.stop, left.stopTimestamp) ===
getProgramTimeMs(right.stop, right.stopTimestamp)
);
}
function buildProgramTimeSlotKey(program: EpgProgram): string {
return [
getProgramTimeMs(program.start, program.startTimestamp),
getProgramTimeMs(program.stop, program.stopTimestamp),
].join('|');
}
function selectMoreInformativeProgram(
existingProgram: EpgProgram,
candidateProgram: EpgProgram
): EpgProgram {
return getProgramMetadataScore(candidateProgram) >
getProgramMetadataScore(existingProgram)
? candidateProgram
: existingProgram;
}
function getProgramMetadataScore(program: EpgProgram): number {
return (
getTextScore(program.desc) * 8 +
getTextScore(program.category) * 4 +
getTextScore(program.iconUrl) * 2 +
getTextScore(program.rating) * 2 +
getTextScore(program.episodeNum) * 2 +
getTextScore(program.title)
);
}
function getTextScore(value: string | null | undefined): number {
return value?.trim() ? 1 : 0;
}
@@ -0,0 +1,6 @@
import type { EpgProgram } from '@iptvnator/shared/interfaces';
export interface EpgProgramActivationEvent {
program: EpgProgram;
type: 'live' | 'timeshift';
}
+40
View File
@@ -0,0 +1,40 @@
import { format } from 'date-fns';
import type { EpgProgram } from '@iptvnator/shared/interfaces';
import { EPG_DATE_KEY_FORMAT } from './epg-date';
export function getProgramTimeMs(
isoValue: string,
timestampValue?: number | null
): number {
if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) {
return Number(timestampValue) * 1000;
}
return Date.parse(isoValue);
}
export function getProgramDateKey(
isoValue: string,
timestampValue?: number | null
): string {
const programTimeMs = getProgramTimeMs(isoValue, timestampValue);
if (!Number.isFinite(programTimeMs)) {
return '';
}
return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT);
}
export function areProgramsSame(
left: EpgProgram,
right: EpgProgram
): boolean {
return (
(left.channel ?? '') === (right.channel ?? '') &&
getProgramTimeMs(left.start, left.startTimestamp) ===
getProgramTimeMs(right.start, right.startTimestamp) &&
getProgramTimeMs(left.stop, left.stopTimestamp) ===
getProgramTimeMs(right.stop, right.stopTimestamp)
);
}
@@ -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
@@ -138,7 +138,6 @@
min-height: 56px;
}
> app-live-epg-panel,
> app-epg-timeline {
flex: 1;
min-height: 0;
@@ -152,8 +151,6 @@
display: flex;
flex-direction: column;
> app-epg-view,
> app-live-epg-panel,
> app-epg-timeline {
flex: 1;
min-height: 0;
-5
View File
@@ -77,7 +77,6 @@
"@angular/platform-browser": "21.2.9",
"@angular/platform-browser-dynamic": "21.2.9",
"@angular/router": "21.2.9",
"@ngrx/component-store": "21.0.1",
"@ngrx/effects": "21.0.1",
"@ngrx/entity": "21.0.1",
"@ngrx/router-store": "21.0.1",
@@ -85,7 +84,6 @@
"@ngrx/store": "21.0.1",
"@ngrx/store-devtools": "21.0.1",
"@ngx-pwa/local-storage": "21.0.0",
"@videojs/http-streaming": "2.15.0",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"angularx-qrcode": "21.0.4",
"artplayer": "5.3.0",
@@ -100,7 +98,6 @@
"fix-path": "5.0.0",
"hls.js": "1.6.13",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
"lodash": "4.18.1",
"marked": "18.0.5",
"mpegts.js": "1.8.0",
"ms": "2.1.3",
@@ -108,7 +105,6 @@
"ngx-skeleton-loader": "11.3.0",
"rxjs": "7.8.2",
"saxes": "6.0.0",
"semver": "7.7.3",
"uuid": "9.0.0",
"video.js": "8.23.4",
"videojs-contrib-quality-levels": "4.1.0",
@@ -172,7 +168,6 @@
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/jest": "^30.0.0",
"@types/lodash": "4.17.20",
"@types/mocha": "9.0.0",
"@types/node": "20.19.9",
"@types/uuid": "^10.0.0",
+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