mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
23ead63b1f
commit
3345478bd6
80 files changed
+106
-4816
No files matched your search
@@ -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-->
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
-29
@@ -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)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
-61
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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,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';
|
||||
|
||||
-44
@@ -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>
|
||||
-115
@@ -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;
|
||||
}
|
||||
}
|
||||
-118
@@ -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,
|
||||
};
|
||||
}
|
||||
-171
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 +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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-69
@@ -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
|
||||
|
||||
Reference in new issue
Block a user