mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(ui): shared cinematic detail pieces (chip, light primary, icon button, more menu, cast row, trailer dialog)
Adds the building blocks of the redesigned VOD details and dashboard hero: a pill meta chip, the light primary button with a two-line label, a 44px ghost icon button with tooltip, the right-aligned "…" dropdown menu that flips upward and hosts the alternative-sources panel, hero credits plus a Cast & crew row, a horizontal rail with prev/next arrows, a Similar rail and the trailer modal. Shared detail utilities (season suffix, remaining time, duration, short country names) move to portal/shared/util, with the new CTA and rating theme tokens and the i18n keys for all locales. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6357da6af
commit
3fd65c935c
63 files changed
+3025
-92
No files matched your search
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "ملء الخلفية المحيطة",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "تشغيل الإعلانات في خلفية التفاصيل",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "بعد ثوانٍ من فتح فيلم أو مسلسل، يُشغَّل إعلانه بدون صوت خلف التفاصيل. متوقف على الاتصالات المحدودة ومع تقليل الحركة.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات التالية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "في النوافذ العريضة، تثبيت مشغل المسلسل على اليسار وعرض الحلقات القادمة بجانبه.",
|
||||
"PLAYER_UP_NEXT_CARD": "بطاقة «التالي»",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "جارٍ تحميل المزيد…",
|
||||
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
|
||||
"RETRY": "إعادة المحاولة"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "الإعلان",
|
||||
"TRAILER_OF": "{{title}} · الإعلان",
|
||||
"MORE_ACTIONS": "إجراءات إضافية",
|
||||
"STARRING": "بطولة",
|
||||
"AND_MORE": "وآخرون",
|
||||
"CAST_AND_CREW": "طاقم التمثيل والعمل",
|
||||
"SCROLL_PREVIOUS": "السابق",
|
||||
"SCROLL_NEXT": "التالي",
|
||||
"EPISODES": "الحلقات",
|
||||
"EPISODE_NUMBER": "الحلقة {{episode}}",
|
||||
"RESET_PROGRESS": "إعادة ضبط التقدم",
|
||||
"OTHER_SOURCES": "مصادر أخرى",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "فتح في مشغّل خارجي",
|
||||
"SHOW_IN_CATEGORY": "عرض في الفئة",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "إخفاء من «متابعة المشاهدة»",
|
||||
"GROUP_WATCHING": "المشاهدة",
|
||||
"GROUP_SOURCE": "المصدر",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} س {{minutes}} د",
|
||||
"DURATION_MINUTES": "{{minutes}} د",
|
||||
"WATCHED": "تمت مشاهدته",
|
||||
"TRAILER_MUTE": "كتم صوت الإعلان",
|
||||
"TRAILER_UNMUTE": "تشغيل صوت الإعلان",
|
||||
"PROGRESS_RESET": "تمت إعادة ضبط التقدم",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "تم الإخفاء من «متابعة المشاهدة»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "خلفية محيطة",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "خدّم الإعلانات فخلفية التفاصيل",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "بعد شي ثواني من فتح فيلم ولا مسلسل، الإعلان ديالو كيتخدّم بلا صوت مورا التفاصيل. مطفي مع الاتصالات المحدودة ومع تقليل الحركة.",
|
||||
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات الجاية",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "فالنوافذ العريضة، حط مشغل السلسلة على اليسار وبيّن الحلقات الجاية بحداه.",
|
||||
"PLAYER_UP_NEXT_CARD": "بطاقة «الجاي»",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "جارٍ تحميل المزيد…",
|
||||
"LOAD_MORE_FAILED": "تعذر تحميل المزيد من العناصر",
|
||||
"RETRY": "عاود المحاولة"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "الإعلان",
|
||||
"TRAILER_OF": "{{title}} · الإعلان",
|
||||
"MORE_ACTIONS": "إجراءات أخرى",
|
||||
"STARRING": "بطولة",
|
||||
"AND_MORE": "وآخرين",
|
||||
"CAST_AND_CREW": "الممثلين والطاقم",
|
||||
"SCROLL_PREVIOUS": "اللي قبل",
|
||||
"SCROLL_NEXT": "اللي بعد",
|
||||
"EPISODES": "الحلقات",
|
||||
"EPISODE_NUMBER": "الحلقة {{episode}}",
|
||||
"RESET_PROGRESS": "رجّع التقدم",
|
||||
"OTHER_SOURCES": "مصادر أخرى",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "حلّ فمشغّل خارجي",
|
||||
"SHOW_IN_CATEGORY": "ورّي فالفئة",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "خبّي من «كمّل المشاهدة»",
|
||||
"GROUP_WATCHING": "المشاهدة",
|
||||
"GROUP_SOURCE": "المصدر",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} س {{minutes}} د",
|
||||
"DURATION_MINUTES": "{{minutes}} د",
|
||||
"WATCHED": "تشاف",
|
||||
"TRAILER_MUTE": "سكّت الإعلان",
|
||||
"TRAILER_UNMUTE": "خدّم صوت الإعلان",
|
||||
"PROGRESS_RESET": "ترجّع التقدم",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "تخبّى من «كمّل المشاهدة»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Атмасфернае запаўненне",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Прайграваць трэйлеры на фоне дэталяў",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Праз некалькі секунд пасля адкрыцця фільма ці серыяла яго трэйлер прайграецца без гуку на фоне апісання. Выключана пры лімітаваным злучэнні і паменшанай анімацыі.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панэль наступных эпізодаў",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "На шырокіх вокнах замацоўваць прайгравальнік серыяла злева і паказваць наступныя эпізоды побач з ім.",
|
||||
"PLAYER_UP_NEXT_CARD": "Картка «Далей»",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Загружаем яшчэ…",
|
||||
"LOAD_MORE_FAILED": "Не атрымалася загрузіць больш элементаў",
|
||||
"RETRY": "Паўтарыць"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Трэйлер",
|
||||
"TRAILER_OF": "{{title}} · Трэйлер",
|
||||
"MORE_ACTIONS": "Яшчэ дзеянні",
|
||||
"STARRING": "У ролях",
|
||||
"AND_MORE": "і іншыя",
|
||||
"CAST_AND_CREW": "Акцёры і здымачная група",
|
||||
"SCROLL_PREVIOUS": "Назад",
|
||||
"SCROLL_NEXT": "Далей",
|
||||
"EPISODES": "Серыі",
|
||||
"EPISODE_NUMBER": "Серыя {{episode}}",
|
||||
"RESET_PROGRESS": "Скінуць прагрэс",
|
||||
"OTHER_SOURCES": "Іншыя крыніцы",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Адкрыць у знешнім плэеры",
|
||||
"SHOW_IN_CATEGORY": "Паказаць у катэгорыі",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Схаваць з «Працягнуць прагляд»",
|
||||
"GROUP_WATCHING": "Прагляд",
|
||||
"GROUP_SOURCE": "Крыніца",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} г {{minutes}} хв",
|
||||
"DURATION_MINUTES": "{{minutes}} хв",
|
||||
"WATCHED": "прагледжана",
|
||||
"TRAILER_MUTE": "Выключыць гук трэйлера",
|
||||
"TRAILER_UNMUTE": "Уключыць гук трэйлера",
|
||||
"PROGRESS_RESET": "Прагрэс скінуты",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Схавана з «Працягнуць прагляд»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Інфармацыя",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Verwendet die gemeinsamen IPTVnator-Steuerelemente in HTML5, Video.js und ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Umgebungs-Hintergrund",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Füllt den Bereich um den Player mit einem unscharfen, gedimmten Poster auf, anstelle von einfachen schwarzen Balken.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Trailer im Hintergrund der Details abspielen",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Einige Sekunden nach dem Öffnen eines Films oder einer Serie läuft der Trailer stumm hinter den Details. Bei getakteten Verbindungen und reduzierter Bewegung aus.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Folgenleiste „Als Nächstes“",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
|
||||
"PLAYER_UP_NEXT_CARD": "Karte „Als Nächstes“",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Lade mehr…",
|
||||
"LOAD_MORE_FAILED": "Weitere Einträge konnten nicht geladen werden",
|
||||
"RETRY": "Erneut versuchen"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Trailer",
|
||||
"TRAILER_OF": "{{title}} · Trailer",
|
||||
"MORE_ACTIONS": "Weitere Aktionen",
|
||||
"STARRING": "Mit",
|
||||
"AND_MORE": "und weitere",
|
||||
"CAST_AND_CREW": "Besetzung & Crew",
|
||||
"SCROLL_PREVIOUS": "Zurück",
|
||||
"SCROLL_NEXT": "Weiter",
|
||||
"EPISODES": "Episoden",
|
||||
"EPISODE_NUMBER": "Episode {{episode}}",
|
||||
"RESET_PROGRESS": "Fortschritt zurücksetzen",
|
||||
"OTHER_SOURCES": "Andere Quellen",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "In externem Player öffnen",
|
||||
"SHOW_IN_CATEGORY": "In Kategorie anzeigen",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Aus „Weiterschauen“ ausblenden",
|
||||
"GROUP_WATCHING": "Ansehen",
|
||||
"GROUP_SOURCE": "Quelle",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "gesehen",
|
||||
"TRAILER_MUTE": "Trailer stummschalten",
|
||||
"TRAILER_UNMUTE": "Trailer-Ton einschalten",
|
||||
"PROGRESS_RESET": "Fortschritt zurückgesetzt",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Aus „Weiterschauen“ ausgeblendet"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Information",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Περιβαλλοντικό γέμισμα",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Αναπαραγωγή τρέιλερ στο φόντο των λεπτομερειών",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Λίγα δευτερόλεπτα μετά το άνοιγμα μιας ταινίας ή σειράς, το τρέιλερ παίζει χωρίς ήχο πίσω από τις λεπτομέρειες. Ανενεργό σε συνδέσεις με χρέωση και με μειωμένη κίνηση.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Λίστα επόμενων επεισοδίων",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Σε πλατιά παράθυρα, ο αναπαραγωγός σειρών αγκυρώνεται στα αριστερά και τα επόμενα επεισόδια εμφανίζονται δίπλα του.",
|
||||
"PLAYER_UP_NEXT_CARD": "Κάρτα «Επόμενο»",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Φόρτωση περισσότερων…",
|
||||
"LOAD_MORE_FAILED": "Δεν ήταν δυνατή η φόρτωση περισσότερων στοιχείων",
|
||||
"RETRY": "Επανάληψη"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Τρέιλερ",
|
||||
"TRAILER_OF": "{{title}} · Τρέιλερ",
|
||||
"MORE_ACTIONS": "Περισσότερες ενέργειες",
|
||||
"STARRING": "Πρωταγωνιστούν",
|
||||
"AND_MORE": "και άλλοι",
|
||||
"CAST_AND_CREW": "Ηθοποιοί και συντελεστές",
|
||||
"SCROLL_PREVIOUS": "Προηγούμενο",
|
||||
"SCROLL_NEXT": "Επόμενο",
|
||||
"EPISODES": "Επεισόδια",
|
||||
"EPISODE_NUMBER": "Επεισόδιο {{episode}}",
|
||||
"RESET_PROGRESS": "Επαναφορά προόδου",
|
||||
"OTHER_SOURCES": "Άλλες πηγές",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Άνοιγμα σε εξωτερικό player",
|
||||
"SHOW_IN_CATEGORY": "Εμφάνιση στην κατηγορία",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Απόκρυψη από το «Συνέχεια παρακολούθησης»",
|
||||
"GROUP_WATCHING": "Παρακολούθηση",
|
||||
"GROUP_SOURCE": "Πηγή",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} ώ {{minutes}} λ",
|
||||
"DURATION_MINUTES": "{{minutes}} λεπ.",
|
||||
"WATCHED": "παρακολουθήθηκε",
|
||||
"TRAILER_MUTE": "Σίγαση τρέιλερ",
|
||||
"TRAILER_UNMUTE": "Ενεργοποίηση ήχου τρέιλερ",
|
||||
"PROGRESS_RESET": "Η πρόοδος επαναφέρθηκε",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Αποκρύφθηκε από το «Συνέχεια παρακολούθησης»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Πληροφορίες",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Ambient background fill",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Play trailers in details background",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "A few seconds after opening a movie or series, its trailer plays muted behind the details. Off on metered connections and with reduced motion.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Up Next episode rail",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
|
||||
"PLAYER_UP_NEXT_CARD": "\"Up next\" card",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Loading more…",
|
||||
"LOAD_MORE_FAILED": "Couldn't load more items",
|
||||
"RETRY": "Retry"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Trailer",
|
||||
"TRAILER_OF": "{{title}} · Trailer",
|
||||
"MORE_ACTIONS": "More actions",
|
||||
"STARRING": "Starring",
|
||||
"AND_MORE": "and more",
|
||||
"CAST_AND_CREW": "Cast & crew",
|
||||
"SCROLL_PREVIOUS": "Previous",
|
||||
"SCROLL_NEXT": "Next",
|
||||
"EPISODES": "Episodes",
|
||||
"EPISODE_NUMBER": "Episode {{episode}}",
|
||||
"RESET_PROGRESS": "Reset progress",
|
||||
"OTHER_SOURCES": "Other sources",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Open in external player",
|
||||
"SHOW_IN_CATEGORY": "Show in category",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Hide from Continue Watching",
|
||||
"GROUP_WATCHING": "Watching",
|
||||
"GROUP_SOURCE": "Source",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "watched",
|
||||
"TRAILER_MUTE": "Mute trailer",
|
||||
"TRAILER_UNMUTE": "Unmute trailer",
|
||||
"PROGRESS_RESET": "Progress reset",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Hidden from Continue Watching"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Information",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa los controles compartidos de IPTVnator en HTML5, Video.js y ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Relleno de fondo ambiental",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Rellena el espacio alrededor del reproductor con una imagen de póster desenfocada y oscurecida en lugar de barras negras simples.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Reproducir tráilers de fondo en los detalles",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Unos segundos después de abrir una película o serie, su tráiler se reproduce en silencio detrás de los detalles. Desactivado con conexiones limitadas y movimiento reducido.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Panel de próximos episodios",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "En ventanas anchas, ancla el reproductor de series a la izquierda y muestra los próximos episodios a su lado.",
|
||||
"PLAYER_UP_NEXT_CARD": "Tarjeta «A continuación»",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Cargando más…",
|
||||
"LOAD_MORE_FAILED": "No se pudieron cargar más elementos",
|
||||
"RETRY": "Reintentar"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Tráiler",
|
||||
"TRAILER_OF": "{{title}} · Tráiler",
|
||||
"MORE_ACTIONS": "Más acciones",
|
||||
"STARRING": "Reparto",
|
||||
"AND_MORE": "y más",
|
||||
"CAST_AND_CREW": "Reparto y equipo",
|
||||
"SCROLL_PREVIOUS": "Anterior",
|
||||
"SCROLL_NEXT": "Siguiente",
|
||||
"EPISODES": "Episodios",
|
||||
"EPISODE_NUMBER": "Episodio {{episode}}",
|
||||
"RESET_PROGRESS": "Restablecer progreso",
|
||||
"OTHER_SOURCES": "Otras fuentes",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Abrir en reproductor externo",
|
||||
"SHOW_IN_CATEGORY": "Mostrar en la categoría",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Ocultar de «Continuar viendo»",
|
||||
"GROUP_WATCHING": "Visualización",
|
||||
"GROUP_SOURCE": "Fuente",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "visto",
|
||||
"TRAILER_MUTE": "Silenciar tráiler",
|
||||
"TRAILER_UNMUTE": "Activar sonido del tráiler",
|
||||
"PROGRESS_RESET": "Progreso restablecido",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Oculto de «Continuar viendo»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Información",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Utilise les commandes partagées d’IPTVnator dans HTML5, Video.js et ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Mode ambiant",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Remplit l'espace autour du lecteur avec une affiche floue et assombrie au lieu de barres noires simples.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Lire les bandes-annonces en fond des détails",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Quelques secondes après l’ouverture d’un film ou d’une série, sa bande-annonce est lue en sourdine derrière les détails. Désactivé sur connexion limitée et avec les animations réduites.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Rail d'épisodes « À suivre »",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Sur les fenêtres larges, ancre le lecteur de séries à gauche et affiche les épisodes à venir à côté.",
|
||||
"PLAYER_UP_NEXT_CARD": "Carte « À suivre »",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Chargement de la suite…",
|
||||
"LOAD_MORE_FAILED": "Impossible de charger plus d'éléments",
|
||||
"RETRY": "Réessayer"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Bande-annonce",
|
||||
"TRAILER_OF": "{{title}} · Bande-annonce",
|
||||
"MORE_ACTIONS": "Plus d’actions",
|
||||
"STARRING": "Avec",
|
||||
"AND_MORE": "et plus",
|
||||
"CAST_AND_CREW": "Distribution et équipe",
|
||||
"SCROLL_PREVIOUS": "Précédent",
|
||||
"SCROLL_NEXT": "Suivant",
|
||||
"EPISODES": "Épisodes",
|
||||
"EPISODE_NUMBER": "Épisode {{episode}}",
|
||||
"RESET_PROGRESS": "Réinitialiser la progression",
|
||||
"OTHER_SOURCES": "Autres sources",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Ouvrir dans un lecteur externe",
|
||||
"SHOW_IN_CATEGORY": "Afficher dans la catégorie",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Masquer de « Reprendre »",
|
||||
"GROUP_WATCHING": "Visionnage",
|
||||
"GROUP_SOURCE": "Source",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "vu",
|
||||
"TRAILER_MUTE": "Couper le son de la bande-annonce",
|
||||
"TRAILER_UNMUTE": "Activer le son de la bande-annonce",
|
||||
"PROGRESS_RESET": "Progression réinitialisée",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Masqué de « Reprendre »"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Information",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Az IPTVnator közös vezérlőinek használata a HTML5-, Video.js- és ArtPlayer-lejátszóban.",
|
||||
"PLAYER_AMBIENT_MODE": "Ambient background fill",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Előzetesek lejátszása a részletek hátterében",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Néhány másodperccel egy film vagy sorozat megnyitása után az előzetes némán lejátszódik a részletek mögött. Forgalomkorlátos kapcsolaton és csökkentett mozgásnál kikapcsolva.",
|
||||
"PLAYER_UP_NEXT_RAIL": "„Következik” epizódsáv",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Széles ablakban a sorozatlejátszó balra kerül, mellette pedig megjelennek a következő epizódok.",
|
||||
"PLAYER_UP_NEXT_CARD": "„Következik” kártya",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "További elemek betöltése…",
|
||||
"LOAD_MORE_FAILED": "Nem sikerült több elemet betölteni",
|
||||
"RETRY": "Újra"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Előzetes",
|
||||
"TRAILER_OF": "{{title}} · Előzetes",
|
||||
"MORE_ACTIONS": "További műveletek",
|
||||
"STARRING": "Szereplők",
|
||||
"AND_MORE": "és mások",
|
||||
"CAST_AND_CREW": "Szereplők és stáb",
|
||||
"SCROLL_PREVIOUS": "Előző",
|
||||
"SCROLL_NEXT": "Következő",
|
||||
"EPISODES": "Epizódok",
|
||||
"EPISODE_NUMBER": "{{episode}}. epizód",
|
||||
"RESET_PROGRESS": "Haladás törlése",
|
||||
"OTHER_SOURCES": "Más források",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Megnyitás külső lejátszóban",
|
||||
"SHOW_IN_CATEGORY": "Megjelenítés a kategóriában",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Elrejtés a „Folytatás” sorból",
|
||||
"GROUP_WATCHING": "Megtekintés",
|
||||
"GROUP_SOURCE": "Forrás",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} ó {{minutes}} p",
|
||||
"DURATION_MINUTES": "{{minutes}} perc",
|
||||
"WATCHED": "megnézve",
|
||||
"TRAILER_MUTE": "Előzetes némítása",
|
||||
"TRAILER_UNMUTE": "Előzetes hangjának bekapcsolása",
|
||||
"PROGRESS_RESET": "Haladás törölve",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Elrejtve a „Folytatás” sorból"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Információk",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa i controlli condivisi di IPTVnator in HTML5, Video.js e ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Riempimento ambientale dello sfondo",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Riempie lo spazio intorno al lettore con un poster sfocato e scurito invece delle semplici barre nere.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Riproduci i trailer sullo sfondo dei dettagli",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Qualche secondo dopo l’apertura di un film o di una serie, il trailer viene riprodotto senza audio dietro i dettagli. Disattivato con connessioni a consumo e animazioni ridotte.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Pannello \"A seguire\" con i prossimi episodi",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Nelle finestre ampie, ancora il lettore delle serie a sinistra e mostra i prossimi episodi accanto.",
|
||||
"PLAYER_UP_NEXT_CARD": "Scheda \"A seguire\"",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Caricamento di altri elementi…",
|
||||
"LOAD_MORE_FAILED": "Impossibile caricare altri elementi",
|
||||
"RETRY": "Riprova"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Trailer",
|
||||
"TRAILER_OF": "{{title}} · Trailer",
|
||||
"MORE_ACTIONS": "Altre azioni",
|
||||
"STARRING": "Con",
|
||||
"AND_MORE": "e altri",
|
||||
"CAST_AND_CREW": "Cast e troupe",
|
||||
"SCROLL_PREVIOUS": "Precedente",
|
||||
"SCROLL_NEXT": "Successivo",
|
||||
"EPISODES": "Episodi",
|
||||
"EPISODE_NUMBER": "Episodio {{episode}}",
|
||||
"RESET_PROGRESS": "Azzera avanzamento",
|
||||
"OTHER_SOURCES": "Altre fonti",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Apri nel lettore esterno",
|
||||
"SHOW_IN_CATEGORY": "Mostra nella categoria",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Nascondi da «Continua a guardare»",
|
||||
"GROUP_WATCHING": "Visione",
|
||||
"GROUP_SOURCE": "Fonte",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "visto",
|
||||
"TRAILER_MUTE": "Disattiva audio del trailer",
|
||||
"TRAILER_UNMUTE": "Attiva audio del trailer",
|
||||
"PROGRESS_RESET": "Avanzamento azzerato",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Nascosto da «Continua a guardare»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informazioni",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。",
|
||||
"PLAYER_AMBIENT_MODE": "プレーヤー周囲のぼかし背景",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "詳細の背景で予告編を再生",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "映画やシリーズを開いて数秒後、予告編が詳細の背後でミュート再生されます。従量制接続や視差効果を減らす設定ではオフになります。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「次に再生」エピソードレール",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "ウィンドウ幅が広い場合、シリーズのプレーヤーを左側に配置し、その横に次のエピソードの一覧を表示します。",
|
||||
"PLAYER_UP_NEXT_CARD": "「次に再生」カード",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "さらに読み込み中…",
|
||||
"LOAD_MORE_FAILED": "追加の項目を読み込めませんでした",
|
||||
"RETRY": "再試行"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "予告編",
|
||||
"TRAILER_OF": "{{title}} · 予告編",
|
||||
"MORE_ACTIONS": "その他の操作",
|
||||
"STARRING": "出演",
|
||||
"AND_MORE": "ほか",
|
||||
"CAST_AND_CREW": "キャストとスタッフ",
|
||||
"SCROLL_PREVIOUS": "前へ",
|
||||
"SCROLL_NEXT": "次へ",
|
||||
"EPISODES": "エピソード",
|
||||
"EPISODE_NUMBER": "第{{episode}}話",
|
||||
"RESET_PROGRESS": "再生位置をリセット",
|
||||
"OTHER_SOURCES": "他のソース",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "外部プレーヤーで開く",
|
||||
"SHOW_IN_CATEGORY": "カテゴリで表示",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "「続きを見る」から隠す",
|
||||
"GROUP_WATCHING": "視聴",
|
||||
"GROUP_SOURCE": "ソース",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}}時間{{minutes}}分",
|
||||
"DURATION_MINUTES": "{{minutes}}分",
|
||||
"WATCHED": "視聴済み",
|
||||
"TRAILER_MUTE": "予告編をミュート",
|
||||
"TRAILER_UNMUTE": "予告編の音を出す",
|
||||
"PROGRESS_RESET": "再生位置をリセットしました",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "「続きを見る」から隠しました"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "情報",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다.",
|
||||
"PLAYER_AMBIENT_MODE": "주변 배경 채우기",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "상세 화면 배경에서 예고편 재생",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "영화나 시리즈를 연 지 몇 초 후 예고편이 상세 정보 뒤에서 음소거로 재생됩니다. 데이터 절약 연결과 동작 줄이기 설정에서는 꺼집니다.",
|
||||
"PLAYER_UP_NEXT_RAIL": "다음 에피소드 레일",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "넓은 창에서 시리즈 플레이어를 왼쪽에 고정하고 그 옆에 다음 에피소드를 표시합니다.",
|
||||
"PLAYER_UP_NEXT_CARD": "다음 에피소드 카드",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "더 불러오는 중…",
|
||||
"LOAD_MORE_FAILED": "추가 항목을 불러오지 못했습니다",
|
||||
"RETRY": "다시 시도"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "예고편",
|
||||
"TRAILER_OF": "{{title}} · 예고편",
|
||||
"MORE_ACTIONS": "추가 작업",
|
||||
"STARRING": "출연",
|
||||
"AND_MORE": "외",
|
||||
"CAST_AND_CREW": "출연진 및 제작진",
|
||||
"SCROLL_PREVIOUS": "이전",
|
||||
"SCROLL_NEXT": "다음",
|
||||
"EPISODES": "에피소드",
|
||||
"EPISODE_NUMBER": "{{episode}}화",
|
||||
"RESET_PROGRESS": "진행 상태 초기화",
|
||||
"OTHER_SOURCES": "다른 소스",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "외부 플레이어로 열기",
|
||||
"SHOW_IN_CATEGORY": "카테고리에서 보기",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "‘이어서 보기’에서 숨기기",
|
||||
"GROUP_WATCHING": "시청",
|
||||
"GROUP_SOURCE": "소스",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}}시간 {{minutes}}분",
|
||||
"DURATION_MINUTES": "{{minutes}}분",
|
||||
"WATCHED": "시청함",
|
||||
"TRAILER_MUTE": "예고편 음소거",
|
||||
"TRAILER_UNMUTE": "예고편 소리 켜기",
|
||||
"PROGRESS_RESET": "진행 상태를 초기화했습니다",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "‘이어서 보기’에서 숨겼습니다"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "정보",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Gebruik de gedeelde IPTVnator-bediening in HTML5, Video.js en ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Ambient achtergrond",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Vult de ruimte rond de speler met een vervaagde, gedimde posterafbeelding in plaats van zwarte balken.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Trailers afspelen achter de details",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Enkele seconden na het openen van een film of serie speelt de trailer gedempt achter de details. Uit bij gemeten verbindingen en verminderde beweging.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Hierna-paneel met afleveringen",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Zet de seriespeler in brede vensters links vast en toont de komende afleveringen ernaast.",
|
||||
"PLAYER_UP_NEXT_CARD": "'Hierna'-kaart",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Meer laden…",
|
||||
"LOAD_MORE_FAILED": "Kan geen extra items laden",
|
||||
"RETRY": "Opnieuw proberen"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Trailer",
|
||||
"TRAILER_OF": "{{title}} · Trailer",
|
||||
"MORE_ACTIONS": "Meer acties",
|
||||
"STARRING": "Met",
|
||||
"AND_MORE": "en meer",
|
||||
"CAST_AND_CREW": "Cast & crew",
|
||||
"SCROLL_PREVIOUS": "Vorige",
|
||||
"SCROLL_NEXT": "Volgende",
|
||||
"EPISODES": "Afleveringen",
|
||||
"EPISODE_NUMBER": "Aflevering {{episode}}",
|
||||
"RESET_PROGRESS": "Voortgang wissen",
|
||||
"OTHER_SOURCES": "Andere bronnen",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Openen in externe speler",
|
||||
"SHOW_IN_CATEGORY": "Tonen in categorie",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Verbergen uit ‘Verder kijken’",
|
||||
"GROUP_WATCHING": "Kijken",
|
||||
"GROUP_SOURCE": "Bron",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} u {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "bekeken",
|
||||
"TRAILER_MUTE": "Trailer dempen",
|
||||
"TRAILER_UNMUTE": "Trailergeluid aan",
|
||||
"PROGRESS_RESET": "Voortgang gewist",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Verborgen uit ‘Verder kijken’"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informatie",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Używaj wspólnych elementów sterowania IPTVnator w HTML5, Video.js i ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Rozmyte tło",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Wypełnij przestrzeń wokół odtwarzacza rozmytym, przyciemnianym plakatem zamiast zwykłych czarnych pasków.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Odtwarzaj zwiastuny w tle szczegółów",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Kilka sekund po otwarciu filmu lub serialu zwiastun odtwarza się bez dźwięku za szczegółami. Wyłączone przy połączeniach taryfowych i ograniczonym ruchu.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Panel następnych odcinków",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "W szerokich oknach zadokuj odtwarzacz serialu po lewej stronie i pokaż obok kolejne odcinki.",
|
||||
"PLAYER_UP_NEXT_CARD": "Karta „Następny”",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Wczytywanie kolejnych…",
|
||||
"LOAD_MORE_FAILED": "Nie udało się wczytać kolejnych pozycji",
|
||||
"RETRY": "Ponów"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Zwiastun",
|
||||
"TRAILER_OF": "{{title}} · Zwiastun",
|
||||
"MORE_ACTIONS": "Więcej działań",
|
||||
"STARRING": "W rolach głównych",
|
||||
"AND_MORE": "i inni",
|
||||
"CAST_AND_CREW": "Obsada i ekipa",
|
||||
"SCROLL_PREVIOUS": "Poprzednie",
|
||||
"SCROLL_NEXT": "Następne",
|
||||
"EPISODES": "Odcinki",
|
||||
"EPISODE_NUMBER": "Odcinek {{episode}}",
|
||||
"RESET_PROGRESS": "Resetuj postęp",
|
||||
"OTHER_SOURCES": "Inne źródła",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Otwórz w zewnętrznym odtwarzaczu",
|
||||
"SHOW_IN_CATEGORY": "Pokaż w kategorii",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Ukryj z „Kontynuuj oglądanie”",
|
||||
"GROUP_WATCHING": "Oglądanie",
|
||||
"GROUP_SOURCE": "Źródło",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} godz. {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "obejrzane",
|
||||
"TRAILER_MUTE": "Wycisz zwiastun",
|
||||
"TRAILER_UNMUTE": "Włącz dźwięk zwiastuna",
|
||||
"PROGRESS_RESET": "Postęp zresetowany",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Ukryto z „Kontynuuj oglądanie”"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informacje",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa os controlos partilhados do IPTVnator em HTML5, Video.js e ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Preenchimento de fundo ambiente",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Preenche o espaço ao redor do reprodutor com um pôster desfocado e escurecido em vez de barras pretas simples.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Reproduzir trailers ao fundo dos detalhes",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Alguns segundos após abrir um filme ou série, o trailer é reproduzido sem som atrás dos detalhes. Desativado em conexões limitadas e com movimento reduzido.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Painel de próximos episódios",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Em janelas largas, ancora o reprodutor de séries à esquerda e mostra os próximos episódios ao lado.",
|
||||
"PLAYER_UP_NEXT_CARD": "Cartão \"A seguir\"",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "A carregar mais…",
|
||||
"LOAD_MORE_FAILED": "Não foi possível carregar mais itens",
|
||||
"RETRY": "Tentar novamente"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Trailer",
|
||||
"TRAILER_OF": "{{title}} · Trailer",
|
||||
"MORE_ACTIONS": "Mais ações",
|
||||
"STARRING": "Com",
|
||||
"AND_MORE": "e mais",
|
||||
"CAST_AND_CREW": "Elenco e equipe",
|
||||
"SCROLL_PREVIOUS": "Anterior",
|
||||
"SCROLL_NEXT": "Próximo",
|
||||
"EPISODES": "Episódios",
|
||||
"EPISODE_NUMBER": "Episódio {{episode}}",
|
||||
"RESET_PROGRESS": "Redefinir progresso",
|
||||
"OTHER_SOURCES": "Outras fontes",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Abrir no reprodutor externo",
|
||||
"SHOW_IN_CATEGORY": "Mostrar na categoria",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Ocultar de «Continuar assistindo»",
|
||||
"GROUP_WATCHING": "Assistindo",
|
||||
"GROUP_SOURCE": "Fonte",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min",
|
||||
"DURATION_MINUTES": "{{minutes}} min",
|
||||
"WATCHED": "assistido",
|
||||
"TRAILER_MUTE": "Silenciar trailer",
|
||||
"TRAILER_UNMUTE": "Ativar som do trailer",
|
||||
"PROGRESS_RESET": "Progresso redefinido",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Oculto de «Continuar assistindo»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Informação",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer.",
|
||||
"PLAYER_AMBIENT_MODE": "Заливка фона за плеером",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Воспроизводить трейлеры на фоне деталей",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Через несколько секунд после открытия фильма или сериала его трейлер играет без звука на фоне описания. Отключено при лимитном соединении и уменьшенной анимации.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Панель следующих серий",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
|
||||
"PLAYER_UP_NEXT_CARD": "Карточка «Далее»",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Загружаем ещё…",
|
||||
"LOAD_MORE_FAILED": "Не удалось загрузить больше элементов",
|
||||
"RETRY": "Повторить"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Трейлер",
|
||||
"TRAILER_OF": "{{title}} · Трейлер",
|
||||
"MORE_ACTIONS": "Ещё действия",
|
||||
"STARRING": "В ролях",
|
||||
"AND_MORE": "и другие",
|
||||
"CAST_AND_CREW": "Актёры и съёмочная группа",
|
||||
"SCROLL_PREVIOUS": "Назад",
|
||||
"SCROLL_NEXT": "Вперёд",
|
||||
"EPISODES": "Серии",
|
||||
"EPISODE_NUMBER": "Серия {{episode}}",
|
||||
"RESET_PROGRESS": "Сбросить прогресс",
|
||||
"OTHER_SOURCES": "Другие источники",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Открыть во внешнем плеере",
|
||||
"SHOW_IN_CATEGORY": "Показать в категории",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "Скрыть из «Продолжить просмотр»",
|
||||
"GROUP_WATCHING": "Просмотр",
|
||||
"GROUP_SOURCE": "Источник",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} ч {{minutes}} мин",
|
||||
"DURATION_MINUTES": "{{minutes}} мин",
|
||||
"WATCHED": "просмотрено",
|
||||
"TRAILER_MUTE": "Выключить звук трейлера",
|
||||
"TRAILER_UNMUTE": "Включить звук трейлера",
|
||||
"PROGRESS_RESET": "Прогресс сброшен",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "Скрыто из «Продолжить просмотр»"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Инфо",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js ve ArtPlayer'da IPTVnator'ın ortak kontrollerini kullan.",
|
||||
"PLAYER_AMBIENT_MODE": "Ortam ışığı dolgusu",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Oynatıcının çevresindeki boşluğu siyah çubuklar yerine bulanık ve karartılmış bir poster ile doldurur.",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "Fragmanları ayrıntıların arka planında oynat",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "Bir film veya dizi açıldıktan birkaç saniye sonra fragmanı ayrıntıların arkasında sessiz oynatılır. Kotalı bağlantılarda ve azaltılmış hareketle kapalı.",
|
||||
"PLAYER_UP_NEXT_RAIL": "Sıradaki bölümler paneli",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Geniş pencerelerde dizi oynatıcısını sola yaslar ve sıradaki bölümleri yanında gösterir.",
|
||||
"PLAYER_UP_NEXT_CARD": "\"Sıradaki\" kartı",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "Daha fazla yükleniyor…",
|
||||
"LOAD_MORE_FAILED": "Daha fazla öğe yüklenemedi",
|
||||
"RETRY": "Yeniden dene"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "Fragman",
|
||||
"TRAILER_OF": "{{title}} · Fragman",
|
||||
"MORE_ACTIONS": "Diğer işlemler",
|
||||
"STARRING": "Oyuncular",
|
||||
"AND_MORE": "ve diğerleri",
|
||||
"CAST_AND_CREW": "Oyuncular ve ekip",
|
||||
"SCROLL_PREVIOUS": "Önceki",
|
||||
"SCROLL_NEXT": "Sonraki",
|
||||
"EPISODES": "Bölümler",
|
||||
"EPISODE_NUMBER": "Bölüm {{episode}}",
|
||||
"RESET_PROGRESS": "İlerlemeyi sıfırla",
|
||||
"OTHER_SOURCES": "Diğer kaynaklar",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "Harici oynatıcıda aç",
|
||||
"SHOW_IN_CATEGORY": "Kategoride göster",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "“İzlemeye devam et”ten gizle",
|
||||
"GROUP_WATCHING": "İzleme",
|
||||
"GROUP_SOURCE": "Kaynak",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} sa {{minutes}} dk",
|
||||
"DURATION_MINUTES": "{{minutes}} dk",
|
||||
"WATCHED": "izlendi",
|
||||
"TRAILER_MUTE": "Fragmanı sessize al",
|
||||
"TRAILER_UNMUTE": "Fragman sesini aç",
|
||||
"PROGRESS_RESET": "İlerleme sıfırlandı",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "“İzlemeye devam et”ten gizlendi"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "Bilgi",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。",
|
||||
"PLAYER_AMBIENT_MODE": "环境背景填充",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "在详情背景中播放预告片",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "打开电影或剧集几秒后,预告片会在详情背后静音播放。按流量计费的连接和减弱动态效果时关闭。",
|
||||
"PLAYER_UP_NEXT_RAIL": "「接下来播放」剧集栏",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在宽窗口下,将剧集播放器停靠在左侧,并在旁边显示后续剧集。",
|
||||
"PLAYER_UP_NEXT_CARD": "「接下来播放」卡片",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "正在加载更多…",
|
||||
"LOAD_MORE_FAILED": "无法加载更多内容",
|
||||
"RETRY": "重试"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "预告片",
|
||||
"TRAILER_OF": "{{title}} · 预告片",
|
||||
"MORE_ACTIONS": "更多操作",
|
||||
"STARRING": "主演",
|
||||
"AND_MORE": "等",
|
||||
"CAST_AND_CREW": "演员与制作人员",
|
||||
"SCROLL_PREVIOUS": "上一个",
|
||||
"SCROLL_NEXT": "下一个",
|
||||
"EPISODES": "剧集",
|
||||
"EPISODE_NUMBER": "第 {{episode}} 集",
|
||||
"RESET_PROGRESS": "重置进度",
|
||||
"OTHER_SOURCES": "其他来源",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "在外部播放器中打开",
|
||||
"SHOW_IN_CATEGORY": "在分类中显示",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "从“继续观看”中隐藏",
|
||||
"GROUP_WATCHING": "观看",
|
||||
"GROUP_SOURCE": "来源",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} 小时 {{minutes}} 分钟",
|
||||
"DURATION_MINUTES": "{{minutes}} 分钟",
|
||||
"WATCHED": "已观看",
|
||||
"TRAILER_MUTE": "预告片静音",
|
||||
"TRAILER_UNMUTE": "取消预告片静音",
|
||||
"PROGRESS_RESET": "进度已重置",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "已从“继续观看”中隐藏"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "信息",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。",
|
||||
"PLAYER_AMBIENT_MODE": "環境背景填充",
|
||||
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND": "在詳細資訊背景播放預告片",
|
||||
"PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION": "開啟電影或影集幾秒後,預告片會在詳細資訊後方靜音播放。計量連線與減少動態效果時關閉。",
|
||||
"PLAYER_UP_NEXT_RAIL": "即將播放集數側欄",
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在較寬的視窗中,將影集播放器停靠在左側,並在旁邊顯示即將播放的集數。",
|
||||
"PLAYER_UP_NEXT_CARD": "「即將播放」卡片",
|
||||
@@ -1358,6 +1360,32 @@
|
||||
"LOADING_MORE": "正在載入更多…",
|
||||
"LOAD_MORE_FAILED": "無法載入更多內容",
|
||||
"RETRY": "重試"
|
||||
},
|
||||
"DETAIL": {
|
||||
"TRAILER": "預告片",
|
||||
"TRAILER_OF": "{{title}} · 預告片",
|
||||
"MORE_ACTIONS": "更多動作",
|
||||
"STARRING": "主演",
|
||||
"AND_MORE": "等",
|
||||
"CAST_AND_CREW": "演員與工作人員",
|
||||
"SCROLL_PREVIOUS": "上一個",
|
||||
"SCROLL_NEXT": "下一個",
|
||||
"EPISODES": "集數",
|
||||
"EPISODE_NUMBER": "第 {{episode}} 集",
|
||||
"RESET_PROGRESS": "重設進度",
|
||||
"OTHER_SOURCES": "其他來源",
|
||||
"OPEN_IN_EXTERNAL_PLAYER": "在外部播放器開啟",
|
||||
"SHOW_IN_CATEGORY": "在分類中顯示",
|
||||
"HIDE_FROM_CONTINUE_WATCHING": "從「繼續觀看」隱藏",
|
||||
"GROUP_WATCHING": "觀看",
|
||||
"GROUP_SOURCE": "來源",
|
||||
"DURATION_HOURS_MINUTES": "{{hours}} 小時 {{minutes}} 分鐘",
|
||||
"DURATION_MINUTES": "{{minutes}} 分鐘",
|
||||
"WATCHED": "已觀看",
|
||||
"TRAILER_MUTE": "預告片靜音",
|
||||
"TRAILER_UNMUTE": "取消預告片靜音",
|
||||
"PROGRESS_RESET": "進度已重設",
|
||||
"HIDDEN_FROM_CONTINUE_WATCHING": "已從「繼續觀看」隱藏"
|
||||
}
|
||||
},
|
||||
"INFORMATION": "資訊",
|
||||
|
||||
@@ -147,6 +147,16 @@ html {
|
||||
);
|
||||
--app-hero-surface: rgba(47, 123, 255, 0.04);
|
||||
|
||||
// Detail / hero call to action. The dark theme's light primary (#f2f4f8
|
||||
// on #0a0e14) inverts to a dark button on the light theme, so it reads as
|
||||
// the one solid CTA on either page background.
|
||||
--app-cta-bg: #1a1d23;
|
||||
--app-cta-fg: #f5f6f8;
|
||||
--app-cta-hover-bg: #2a2f3a;
|
||||
--app-cta-meta-fg: rgba(245, 246, 248, 0.68);
|
||||
// Star rating chip: amber on white needs a deeper tone than gold on dark.
|
||||
--app-rating-color: #a16207;
|
||||
|
||||
.dark-theme {
|
||||
@include mat.all-component-colors($dark-theme);
|
||||
// Only the color roles differ per theme: the elevation shadows are
|
||||
@@ -236,6 +246,13 @@ html {
|
||||
rgba(92, 214, 255, 0.07) 100%
|
||||
);
|
||||
--app-hero-surface: rgba(30, 35, 48, 0.9);
|
||||
|
||||
// Detail / hero call to action (light primary) and rating accent
|
||||
--app-cta-bg: #f2f4f8;
|
||||
--app-cta-fg: #0a0e14;
|
||||
--app-cta-hover-bg: #ffffff;
|
||||
--app-cta-meta-fg: #5a6274;
|
||||
--app-rating-color: #ffcf5c;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,3 +43,6 @@ export * from './lib/downloads/episode-download-identity';
|
||||
export * from './lib/portal-watch-state';
|
||||
export * from './lib/vod-watched-toggle';
|
||||
export * from './lib/pending-playback-start';
|
||||
export * from './lib/detail/season-title';
|
||||
export * from './lib/detail/playback-progress';
|
||||
export * from './lib/detail/country-short-name';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { shortCountryList, shortCountryName } from './country-short-name';
|
||||
|
||||
describe('shortCountryName', () => {
|
||||
it('shortens by ISO code first', () => {
|
||||
expect(shortCountryName('United States of America', 'US')).toBe('USA');
|
||||
expect(shortCountryName('United Kingdom', 'gb')).toBe('UK');
|
||||
});
|
||||
|
||||
it('shortens known full names without a code', () => {
|
||||
expect(shortCountryName('United States')).toBe('USA');
|
||||
expect(shortCountryName('Russian Federation')).toBe('Russia');
|
||||
expect(shortCountryName('Korea, Republic of')).toBe('South Korea');
|
||||
});
|
||||
|
||||
it('keeps unknown names and falls back to the code', () => {
|
||||
expect(shortCountryName('Fictionland')).toBe('Fictionland');
|
||||
expect(shortCountryName('', 'xx')).toBe('XX');
|
||||
});
|
||||
|
||||
it('splits provider lists', () => {
|
||||
expect(shortCountryList('United States, Canada / France')).toEqual([
|
||||
'USA',
|
||||
'Canada',
|
||||
'France',
|
||||
]);
|
||||
expect(shortCountryList(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Short display names for the countries providers spell out in full. */
|
||||
const SHORT_BY_CODE: Record<string, string> = {
|
||||
US: 'USA',
|
||||
GB: 'UK',
|
||||
AE: 'UAE',
|
||||
RU: 'Russia',
|
||||
KR: 'South Korea',
|
||||
KP: 'North Korea',
|
||||
CZ: 'Czechia',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
IR: 'Iran',
|
||||
SY: 'Syria',
|
||||
VN: 'Vietnam',
|
||||
TW: 'Taiwan',
|
||||
BO: 'Bolivia',
|
||||
VE: 'Venezuela',
|
||||
TZ: 'Tanzania',
|
||||
LA: 'Laos',
|
||||
MD: 'Moldova',
|
||||
MK: 'North Macedonia',
|
||||
CD: 'DR Congo',
|
||||
CG: 'Congo',
|
||||
FM: 'Micronesia',
|
||||
BN: 'Brunei',
|
||||
VA: 'Vatican',
|
||||
};
|
||||
|
||||
const SHORT_BY_NAME: Record<string, string> = {
|
||||
'united states of america': 'USA',
|
||||
'united states': 'USA',
|
||||
usa: 'USA',
|
||||
'united kingdom': 'UK',
|
||||
'great britain': 'UK',
|
||||
'united arab emirates': 'UAE',
|
||||
'russian federation': 'Russia',
|
||||
россия: 'Россия',
|
||||
сша: 'США',
|
||||
'republic of korea': 'South Korea',
|
||||
'korea, republic of': 'South Korea',
|
||||
'czech republic': 'Czechia',
|
||||
'islamic republic of iran': 'Iran',
|
||||
'syrian arab republic': 'Syria',
|
||||
'viet nam': 'Vietnam',
|
||||
'taiwan, province of china': 'Taiwan',
|
||||
'lao people’s democratic republic': 'Laos',
|
||||
"lao people's democratic republic": 'Laos',
|
||||
'republic of moldova': 'Moldova',
|
||||
'the netherlands': 'Netherlands',
|
||||
'federal republic of germany': 'Germany',
|
||||
};
|
||||
|
||||
/**
|
||||
* "United States of America" → "USA", "GB" → "UK". Unknown names come back
|
||||
* trimmed, so the chip never loses a country it cannot shorten.
|
||||
*/
|
||||
export function shortCountryName(
|
||||
name: string | null | undefined,
|
||||
code?: string | null
|
||||
): string {
|
||||
const trimmed = (name ?? '').trim();
|
||||
const byCode = code ? SHORT_BY_CODE[code.trim().toUpperCase()] : undefined;
|
||||
if (byCode) {
|
||||
return byCode;
|
||||
}
|
||||
const byName = SHORT_BY_NAME[trimmed.toLowerCase()];
|
||||
if (byName) {
|
||||
return byName;
|
||||
}
|
||||
return trimmed || (code ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
/** First country of a comma-separated provider list, shortened. */
|
||||
export function shortCountryList(value: string | null | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(/[,;/]/)
|
||||
.map((part) => shortCountryName(part))
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
formatDurationLabel,
|
||||
formatRemainingLabel,
|
||||
parseDurationSeconds,
|
||||
playbackProgressPercent,
|
||||
} from './playback-progress';
|
||||
|
||||
describe('playbackProgressPercent', () => {
|
||||
it('floors the watched share to an integer percent', () => {
|
||||
expect(
|
||||
playbackProgressPercent({ positionSeconds: 924, durationSeconds: 1000 })
|
||||
).toBe(92);
|
||||
});
|
||||
|
||||
it('returns null without a duration', () => {
|
||||
expect(playbackProgressPercent({ positionSeconds: 10 })).toBeNull();
|
||||
expect(playbackProgressPercent(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRemainingLabel', () => {
|
||||
it('picks seconds, minutes, hours and hours+minutes', () => {
|
||||
expect(
|
||||
formatRemainingLabel({ positionSeconds: 70, durationSeconds: 100 })
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
|
||||
params: { seconds: 30 },
|
||||
});
|
||||
expect(
|
||||
formatRemainingLabel({ positionSeconds: 600, durationSeconds: 1920 })
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: 22 },
|
||||
});
|
||||
expect(
|
||||
formatRemainingLabel({ positionSeconds: 0, durationSeconds: 7200 })
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
|
||||
params: { hours: 2 },
|
||||
});
|
||||
expect(
|
||||
formatRemainingLabel({ positionSeconds: 60, durationSeconds: 3960 })
|
||||
).toEqual({
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
|
||||
params: { hours: 1, minutes: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null without a duration', () => {
|
||||
expect(formatRemainingLabel({ positionSeconds: 5 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDurationLabel', () => {
|
||||
it('formats minutes and hours', () => {
|
||||
expect(formatDurationLabel(48 * 60)).toEqual({
|
||||
key: 'PORTALS.DETAIL.DURATION_MINUTES',
|
||||
params: { minutes: 48 },
|
||||
});
|
||||
expect(formatDurationLabel(6720)).toEqual({
|
||||
key: 'PORTALS.DETAIL.DURATION_HOURS_MINUTES',
|
||||
params: { hours: 1, minutes: 52 },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown durations', () => {
|
||||
expect(formatDurationLabel(0)).toBeNull();
|
||||
expect(formatDurationLabel(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDurationSeconds', () => {
|
||||
it.each([
|
||||
['45 min', 2700],
|
||||
['1h 30min', 5400],
|
||||
['01:52:10', 6730],
|
||||
['52:10', 3130],
|
||||
['90', 90],
|
||||
[120, 120],
|
||||
['', 0],
|
||||
[undefined, 0],
|
||||
])('parses %p', (input, expected) => {
|
||||
expect(parseDurationSeconds(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
export interface RemainingTimeLabel {
|
||||
readonly key: string;
|
||||
readonly params: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ProgressPosition {
|
||||
positionSeconds: number;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
/** 0–100 watched share, or null when the duration is unknown. */
|
||||
export function playbackProgressPercent(
|
||||
position: ProgressPosition | null | undefined
|
||||
): number | null {
|
||||
if (
|
||||
!position ||
|
||||
position.durationSeconds == null ||
|
||||
position.durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ratio = position.positionSeconds / position.durationSeconds;
|
||||
if (!Number.isFinite(ratio)) {
|
||||
return null;
|
||||
}
|
||||
// Integer percent keeps "92% watched" out of "92.4% watched" territory,
|
||||
// and matches the resolution of a 3px-tall progress bar.
|
||||
return Math.max(0, Math.min(100, Math.floor(ratio * 100)));
|
||||
}
|
||||
|
||||
/** "22m left" / "1h 5m left" as an i18n key with parameters. */
|
||||
export function formatRemainingLabel(
|
||||
position: ProgressPosition | null | undefined
|
||||
): RemainingTimeLabel | null {
|
||||
if (
|
||||
!position ||
|
||||
position.durationSeconds == null ||
|
||||
position.durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.round(position.durationSeconds - position.positionSeconds)
|
||||
);
|
||||
if (remaining < 60) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
|
||||
params: { seconds: remaining },
|
||||
};
|
||||
}
|
||||
const totalMinutes = Math.round(remaining / 60);
|
||||
if (totalMinutes < 60) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: totalMinutes },
|
||||
};
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (minutes === 0) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
|
||||
params: { hours },
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
|
||||
params: { hours, minutes },
|
||||
};
|
||||
}
|
||||
|
||||
/** "1 h 52 min" / "48 min" as an i18n key with parameters. */
|
||||
export function formatDurationLabel(
|
||||
totalSeconds: number | null | undefined
|
||||
): RemainingTimeLabel | null {
|
||||
if (!totalSeconds || !Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
const totalMinutes = Math.max(1, Math.round(totalSeconds / 60));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours === 0) {
|
||||
return { key: 'PORTALS.DETAIL.DURATION_MINUTES', params: { minutes } };
|
||||
}
|
||||
return {
|
||||
key: 'PORTALS.DETAIL.DURATION_HOURS_MINUTES',
|
||||
params: { hours, minutes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds from the duration strings providers send: "45 min", "1h 30min",
|
||||
* "01:52:10", "52:10", a bare number of seconds.
|
||||
*/
|
||||
export function parseDurationSeconds(
|
||||
duration: string | number | null | undefined
|
||||
): number {
|
||||
if (!duration) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof duration === 'number') {
|
||||
return duration;
|
||||
}
|
||||
// "45 min" (Stalker VOD) and "1h 30min" (Xtream): keep the hour part.
|
||||
const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/);
|
||||
if (minutesMatch) {
|
||||
return (
|
||||
parseInt(minutesMatch[1] ?? '0', 10) * 3600 +
|
||||
parseInt(minutesMatch[2], 10) * 60
|
||||
);
|
||||
}
|
||||
const parts = duration.split(':').map(Number);
|
||||
if (parts.length === 3 && parts.every(Number.isFinite)) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
if (parts.length === 2 && parts.every(Number.isFinite)) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
return Number(duration) || 0;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { splitSeasonSuffix } from './season-title';
|
||||
|
||||
describe('splitSeasonSuffix', () => {
|
||||
it.each([
|
||||
['Большая фарма (1 сезон)', 'Большая фарма', 1],
|
||||
['Родители родителей 2 сезон', 'Родители родителей', 2],
|
||||
['Родители родителей (сезон 3)', 'Родители родителей', 3],
|
||||
['Big Pharma Season 2', 'Big Pharma', 2],
|
||||
['Big Pharma - Season 12', 'Big Pharma', 12],
|
||||
['Big Pharma [Season 4]', 'Big Pharma', 4],
|
||||
['Big Pharma S02', 'Big Pharma', 2],
|
||||
['La casa de papel: Temporada 5', 'La casa de papel', 5],
|
||||
['Dark Staffel 3', 'Dark', 3],
|
||||
])('strips %s', (raw, title, season) => {
|
||||
expect(splitSeasonSuffix(raw)).toEqual({ title, season });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'S.W.A.T.',
|
||||
'Area 51',
|
||||
'Season of the Witch',
|
||||
'Fast 9',
|
||||
'24',
|
||||
'The 100',
|
||||
])('leaves %s alone', (raw) => {
|
||||
expect(splitSeasonSuffix(raw)).toEqual({ title: raw, season: null });
|
||||
});
|
||||
|
||||
it('keeps a title that is only a season marker', () => {
|
||||
expect(splitSeasonSuffix('Season 2')).toEqual({
|
||||
title: 'Season 2',
|
||||
season: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('trims and tolerates empty input', () => {
|
||||
expect(splitSeasonSuffix(' ')).toEqual({ title: '', season: null });
|
||||
expect(splitSeasonSuffix(undefined)).toEqual({ title: '', season: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface SeasonTitleParts {
|
||||
/** The provider title without its season marker. */
|
||||
readonly title: string;
|
||||
readonly season: number | null;
|
||||
}
|
||||
|
||||
// "(1 сезон)", "Сезон 2", "Season 3", "S04", "- Season 2", "[Season 2]".
|
||||
// The marker must close the title: a leading season word stays part of it.
|
||||
const SEASON_SUFFIX =
|
||||
/[\s\-–—:·|,]*[([]?\s*(?:(\d{1,2})\s*(?:-?й\s*)?сезон|сезон\s*(\d{1,2})|season\s*(\d{1,2})|temporada\s*(\d{1,2})|staffel\s*(\d{1,2})|saison\s*(\d{1,2})|S(\d{2}))\s*[)\]]?\s*$/i;
|
||||
|
||||
/**
|
||||
* Strips a trailing season marker from a provider series title so the hero
|
||||
* shows "Большая фарма" with a "Season 1" chip instead of "Большая фарма
|
||||
* (1 сезон)". Titles without a marker are returned unchanged.
|
||||
*/
|
||||
export function splitSeasonSuffix(rawTitle: string | null | undefined): SeasonTitleParts {
|
||||
const title = (rawTitle ?? '').trim();
|
||||
const match = title.match(SEASON_SUFFIX);
|
||||
if (!match || match.index === undefined || match.index === 0) {
|
||||
return { title, season: null };
|
||||
}
|
||||
const season = Number(match.slice(1).find((group) => group !== undefined));
|
||||
const stripped = title.slice(0, match.index).trim();
|
||||
if (!stripped || !Number.isInteger(season) || season <= 0) {
|
||||
return { title, season: null };
|
||||
}
|
||||
return { title: stripped, season };
|
||||
}
|
||||
@@ -33,3 +33,16 @@ export * from './lib/window-controls/window-controls.component';
|
||||
|
||||
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
|
||||
export * from './lib/category-lock-menu/category-lock-menu.component';
|
||||
|
||||
export * from './lib/detail-ui/cast-crew.util';
|
||||
export * from './lib/detail-ui/cast-crew-row.component';
|
||||
export * from './lib/detail-ui/detail-credits.component';
|
||||
export * from './lib/detail-ui/detail-icon-button.component';
|
||||
export * from './lib/detail-ui/detail-primary-button.component';
|
||||
export * from './lib/detail-ui/detail-rail.component';
|
||||
export * from './lib/detail-ui/meta-chip.component';
|
||||
export * from './lib/detail-ui/similar-rail.component';
|
||||
export * from './lib/detail-ui/trailer-dialog.component';
|
||||
export * from './lib/detail-ui/trailer-dialog.service';
|
||||
export * from './lib/detail-ui/vod-more-menu.component';
|
||||
export * from './lib/detail-ui/vod-more-menu.model';
|
||||
@@ -0,0 +1,37 @@
|
||||
@if (entries().length) {
|
||||
<app-detail-rail
|
||||
[title]="'PORTALS.DETAIL.CAST_AND_CREW' | translate"
|
||||
[count]="entries().length"
|
||||
[headingId]="headingId()"
|
||||
data-test-id="detail-cast-crew"
|
||||
>
|
||||
@for (entry of entries(); track entry.key) {
|
||||
<div
|
||||
class="person"
|
||||
[class.person--clickable]="isClickable(entry)"
|
||||
[attr.role]="isClickable(entry) ? 'button' : null"
|
||||
[attr.tabindex]="isClickable(entry) ? 0 : null"
|
||||
(click)="select(entry)"
|
||||
(keydown.enter)="select(entry)"
|
||||
(keydown.space)="$event.preventDefault(); select(entry)"
|
||||
>
|
||||
@if (entry.profileUrl; as photo) {
|
||||
<img
|
||||
class="person__avatar"
|
||||
[src]="photo"
|
||||
[alt]="entry.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="person__avatar person__avatar--initials"
|
||||
aria-hidden="true"
|
||||
>{{ entry.initials }}</span
|
||||
>
|
||||
}
|
||||
<p class="person__name">{{ entry.name }}</p>
|
||||
<small class="person__role">{{ entry.role ?? ' ' }}</small>
|
||||
</div>
|
||||
}
|
||||
</app-detail-rail>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.person {
|
||||
flex: 0 0 96px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .person__avatar {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 50%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.person__avatar {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--app-widget-bg, #1d2533);
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&--initials {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--app-body-color, #9aa3b2);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.person__name {
|
||||
margin: 8px 0 2px;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
text-wrap: balance;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.person__role {
|
||||
display: block;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
|
||||
import { buildCastCrewEntries, type CastCrewEntry } from './cast-crew.util';
|
||||
import { DetailRailComponent } from './detail-rail.component';
|
||||
|
||||
/**
|
||||
* "Cast & crew" row under the hero: 72px round avatars with the full name
|
||||
* and the character (or "Director"), the director first, initials when a
|
||||
* person has no photo. A click on a person with a TMDB id opens the actor
|
||||
* page through the host.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-cast-crew-row',
|
||||
imports: [DetailRailComponent, TranslatePipe],
|
||||
templateUrl: './cast-crew-row.component.html',
|
||||
styleUrl: './cast-crew-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CastCrewRowComponent {
|
||||
readonly cast = input<readonly TmdbEnrichedCastMember[]>([]);
|
||||
readonly directors = input<readonly TmdbEnrichedCastMember[]>([]);
|
||||
readonly headingId = input<string | null>('detail-cast-crew');
|
||||
|
||||
readonly personSelected = output<TmdbEnrichedCastMember>();
|
||||
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly entries = computed<CastCrewEntry[]>(() =>
|
||||
buildCastCrewEntries(
|
||||
this.cast(),
|
||||
this.directors(),
|
||||
this.translate.instant('XTREAM.DIRECTOR')
|
||||
)
|
||||
);
|
||||
|
||||
isClickable(entry: CastCrewEntry): boolean {
|
||||
return !!entry.member.tmdbPersonId;
|
||||
}
|
||||
|
||||
select(entry: CastCrewEntry): void {
|
||||
if (this.isClickable(entry)) {
|
||||
this.personSelected.emit(entry.member);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
buildCastCrewEntries,
|
||||
castMembersFromNames,
|
||||
personInitials,
|
||||
splitPeopleNames,
|
||||
} from './cast-crew.util';
|
||||
|
||||
describe('splitPeopleNames', () => {
|
||||
it('splits on commas and semicolons and drops duplicates', () => {
|
||||
expect(splitPeopleNames('Mara Venn, Elias Shore; mara venn, ')).toEqual([
|
||||
'Mara Venn',
|
||||
'Elias Shore',
|
||||
]);
|
||||
expect(splitPeopleNames(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCastCrewEntries', () => {
|
||||
it('lists the director first and dedupes a director who also acts', () => {
|
||||
const entries = buildCastCrewEntries(
|
||||
[
|
||||
{ name: 'Russell Crowe', character: 'Henry Murray', profileUrl: 'a.jpg', tmdbPersonId: 1 },
|
||||
{ name: 'Janus Metz', profileUrl: null },
|
||||
{ name: 'Mark Camacho', profileUrl: null },
|
||||
],
|
||||
[{ name: 'Janus Metz', profileUrl: null, tmdbPersonId: 7 }],
|
||||
'Director'
|
||||
);
|
||||
expect(entries.map((entry) => [entry.name, entry.role])).toEqual([
|
||||
['Janus Metz', 'Director'],
|
||||
['Russell Crowe', 'Henry Murray'],
|
||||
['Mark Camacho', null],
|
||||
]);
|
||||
expect(entries[0].key).toBe('p7');
|
||||
expect(entries[2].initials).toBe('MC');
|
||||
});
|
||||
|
||||
it('wraps provider names without photos', () => {
|
||||
expect(castMembersFromNames(['A B'])).toEqual([
|
||||
{ name: 'A B', profileUrl: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('personInitials', () => {
|
||||
it('takes the first and last initials', () => {
|
||||
expect(personInitials('Aleksandr Samoylenko Jr.')).toBe('AJ');
|
||||
expect(personInitials('Cher')).toBe('C');
|
||||
expect(personInitials('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { TmdbEnrichedCastMember } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** One avatar of the "Cast & crew" row. */
|
||||
export interface CastCrewEntry {
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
/** Character for actors, a translated "Director" for crew, else null. */
|
||||
readonly role: string | null;
|
||||
readonly profileUrl: string | null;
|
||||
readonly initials: string;
|
||||
readonly member: TmdbEnrichedCastMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Mara Venn, Elias Shore; Tomas Bell" → names. Providers join the cast with
|
||||
* commas (Xtream) or semicolons (some Stalker portals); either way a name
|
||||
* never contains the separator.
|
||||
*/
|
||||
export function splitPeopleNames(value: string | null | undefined): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const part of value.split(/[,;]/)) {
|
||||
const name = part.trim();
|
||||
const key = normalizePersonName(name);
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Plain names (no TMDB data) as cast members without photo or character. */
|
||||
export function castMembersFromNames(
|
||||
names: readonly string[]
|
||||
): TmdbEnrichedCastMember[] {
|
||||
return names.map((name) => ({ name, profileUrl: null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Director first, then the cast, one entry per person: a director who also
|
||||
* acts keeps the single "Director" avatar.
|
||||
*/
|
||||
export function buildCastCrewEntries(
|
||||
cast: readonly TmdbEnrichedCastMember[],
|
||||
directors: readonly TmdbEnrichedCastMember[],
|
||||
directorRole: string
|
||||
): CastCrewEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const entries: CastCrewEntry[] = [];
|
||||
const push = (member: TmdbEnrichedCastMember, role: string | null) => {
|
||||
const key = normalizePersonName(member.name);
|
||||
if (!key || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
entries.push({
|
||||
key: member.tmdbPersonId ? `p${member.tmdbPersonId}` : `n:${key}`,
|
||||
name: member.name.trim(),
|
||||
role,
|
||||
profileUrl: member.profileUrl ?? null,
|
||||
initials: personInitials(member.name),
|
||||
member,
|
||||
});
|
||||
};
|
||||
directors.forEach((member) => push(member, directorRole));
|
||||
cast.forEach((member) => push(member, member.character?.trim() || null));
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function personInitials(name: string): string {
|
||||
const parts = name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((part) => part.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const first = parts[0][0] ?? '';
|
||||
const last = parts.length > 1 ? (parts[parts.length - 1][0] ?? '') : '';
|
||||
return `${first}${last}`.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizePersonName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@if (cast().length) {
|
||||
<div class="credits__row" data-test-id="detail-credits-cast">
|
||||
<span class="credits__label">{{
|
||||
'PORTALS.DETAIL.STARRING' | translate
|
||||
}}</span>
|
||||
<span class="credits__names">{{ leadCast() }}</span>
|
||||
@if (hasMore()) {
|
||||
<button
|
||||
type="button"
|
||||
class="credits__more"
|
||||
(click)="moreRequested.emit()"
|
||||
>
|
||||
{{ 'PORTALS.DETAIL.AND_MORE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (directors().length) {
|
||||
<div class="credits__row" data-test-id="detail-credits-director">
|
||||
<span class="credits__label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
<span class="credits__names">{{ directorNames() }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
}
|
||||
|
||||
.credits__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0 8px;
|
||||
}
|
||||
|
||||
.credits__label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 64px;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.credits__names {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credits__more {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--app-body-color, #9aa3b2);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { DetailCreditsComponent } from './detail-credits.component';
|
||||
|
||||
describe('DetailCreditsComponent', () => {
|
||||
let fixture: ComponentFixture<DetailCreditsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DetailCreditsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: { DETAIL: { STARRING: 'Starring', AND_MORE: 'and more' } },
|
||||
XTREAM: { DIRECTOR: 'Director' },
|
||||
});
|
||||
translate.use('en');
|
||||
fixture = TestBed.createComponent(DetailCreditsComponent);
|
||||
});
|
||||
|
||||
it('shows three lead names, an "and more" link and the director', () => {
|
||||
fixture.componentRef.setInput('cast', ['A', 'B', 'C', 'D']);
|
||||
fixture.componentRef.setInput('directors', ['Janus Metz']);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const castLine = host.querySelector(
|
||||
'[data-test-id="detail-credits-cast"]'
|
||||
)?.textContent;
|
||||
expect(castLine).toContain('A, B, C');
|
||||
expect(castLine).not.toContain('A, B, C, D');
|
||||
const more = host.querySelector<HTMLButtonElement>('.credits__more')!;
|
||||
const spy = jest.fn();
|
||||
fixture.componentInstance.moreRequested.subscribe(spy);
|
||||
more.click();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(
|
||||
host.querySelector('[data-test-id="detail-credits-director"]')
|
||||
?.textContent
|
||||
).toContain('Janus Metz');
|
||||
});
|
||||
|
||||
it('hides the link when three or fewer names exist and renders nothing when empty', () => {
|
||||
fixture.componentRef.setInput('cast', ['A', 'B']);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.querySelector('.credits__more')).toBeNull();
|
||||
expect(host.querySelector('[data-test-id="detail-credits-director"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
const LEAD_CAST_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* Two text lines of credits inside the details hero: "Starring" with the
|
||||
* first three names and an "and more" link to the Cast & crew row, then
|
||||
* "Director". Nothing renders for an empty list.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-credits',
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './detail-credits.component.html',
|
||||
styleUrl: './detail-credits.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailCreditsComponent {
|
||||
readonly cast = input<readonly string[]>([]);
|
||||
readonly directors = input<readonly string[]>([]);
|
||||
|
||||
/** "and more" was activated: the host scrolls to the Cast & crew row. */
|
||||
readonly moreRequested = output<void>();
|
||||
|
||||
readonly leadCast = computed(() =>
|
||||
this.cast().slice(0, LEAD_CAST_LIMIT).join(', ')
|
||||
);
|
||||
readonly hasMore = computed(() => this.cast().length > LEAD_CAST_LIMIT);
|
||||
readonly directorNames = computed(() => this.directors().join(', '));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
[class.is-active]="active()"
|
||||
[class.is-pulsing]="pulsing()"
|
||||
[class.icon-btn--favorite]="tone() === 'favorite'"
|
||||
[class.icon-btn--watched]="tone() === 'watched'"
|
||||
[class.icon-btn--done]="tone() === 'done'"
|
||||
[disabled]="disabled()"
|
||||
[matTooltip]="label()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.aria-pressed]="pressedState()"
|
||||
[attr.data-testid]="testId()"
|
||||
(click)="pressed.emit()"
|
||||
>
|
||||
<ng-content select="[glyph]" />
|
||||
@if (icon(); as glyph) {
|
||||
<mat-icon aria-hidden="true">{{ glyph }}</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@@ -0,0 +1,59 @@
|
||||
@use '../../../../styles/detail-view-actions' as actions;
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@include actions.ghost-action-surface;
|
||||
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
// Favorited: the glyph fills and turns red, the surface stays a ghost.
|
||||
&--favorite.is-active {
|
||||
color: var(--app-live-color, #ff5b6e);
|
||||
}
|
||||
|
||||
&--favorite.is-pulsing mat-icon {
|
||||
animation: icon-btn-pulse 180ms ease;
|
||||
}
|
||||
|
||||
&--watched.is-active,
|
||||
&--done {
|
||||
color: #4ade80;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-btn-pulse {
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon-btn mat-icon {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
|
||||
/** Which accent an active icon button takes. */
|
||||
export type DetailIconButtonTone = 'favorite' | 'watched' | 'done' | 'none';
|
||||
|
||||
/**
|
||||
* 44px ghost icon button of the details action row (Favorites, Mark as
|
||||
* watched, Download, More). The label is both the tooltip and the accessible
|
||||
* name. A projected `[glyph]` (the download progress ring) replaces the icon.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-icon-button',
|
||||
imports: [MatIcon, MatTooltip],
|
||||
templateUrl: './detail-icon-button.component.html',
|
||||
styleUrl: './detail-icon-button.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailIconButtonComponent {
|
||||
readonly label = input.required<string>();
|
||||
readonly icon = input<string | null>(null);
|
||||
readonly active = input(false);
|
||||
readonly tone = input<DetailIconButtonTone>('none');
|
||||
/** Set for toggles; `null` leaves `aria-pressed` off a plain action. */
|
||||
readonly pressedState = input<boolean | null>(null);
|
||||
readonly disabled = input(false);
|
||||
readonly pulsing = input(false);
|
||||
readonly testId = input<string | null>(null);
|
||||
|
||||
readonly pressed = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<button
|
||||
type="button"
|
||||
class="play-btn"
|
||||
[class.play-btn--launching]="state() === 'launching'"
|
||||
[class.play-btn--stop]="state() === 'stop'"
|
||||
[class.play-btn--two-line]="!!meta()"
|
||||
[disabled]="disabled()"
|
||||
[attr.data-testid]="testId()"
|
||||
[attr.aria-busy]="state() === 'launching' ? 'true' : null"
|
||||
(click)="pressed.emit()"
|
||||
>
|
||||
<mat-icon class="play-btn__icon" aria-hidden="true">{{ icon() }}</mat-icon>
|
||||
<span class="play-btn__copy">
|
||||
<span class="play-btn__label">{{ label() }}</span>
|
||||
@if (meta(); as metaLine) {
|
||||
<span class="play-btn__meta">{{ metaLine }}</span>
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
@@ -0,0 +1,98 @@
|
||||
@use '../../../../styles/detail-view-actions' as actions;
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
@include actions.light-primary-button;
|
||||
|
||||
height: 44px;
|
||||
max-width: 100%;
|
||||
|
||||
&__copy {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: block;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: block;
|
||||
max-width: min(42vw, 320px);
|
||||
overflow: hidden;
|
||||
margin-top: 1px;
|
||||
color: var(--app-cta-meta-fg, #5a6274);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// External player feedback: a warm tint while the player starts, a red
|
||||
// one while the button stops it. Tints, not glows.
|
||||
&--launching {
|
||||
background: #ffd36f;
|
||||
color: #241103;
|
||||
|
||||
.play-btn__meta {
|
||||
color: rgba(36, 17, 3, 0.7);
|
||||
}
|
||||
|
||||
.play-btn__icon {
|
||||
animation: play-btn-launch 1.15s cubic-bezier(0.55, 0, 0.45, 1)
|
||||
infinite;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #ffdd8a;
|
||||
}
|
||||
}
|
||||
|
||||
&--stop {
|
||||
background: #ff9f8d;
|
||||
color: #321012;
|
||||
|
||||
.play-btn__meta {
|
||||
color: rgba(50, 16, 18, 0.7);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #ffb3a5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes play-btn-launch {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(18deg) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.play-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.play-btn,
|
||||
.play-btn__icon {
|
||||
animation: none !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
/** External-player launch feedback carried by the primary button. */
|
||||
export type DetailPrimaryButtonState = 'idle' | 'launching' | 'stop';
|
||||
|
||||
/**
|
||||
* The light primary call to action of a details page: play icon plus a
|
||||
* two-line label ("Continue" / "S02E03 · 18m left"). The same surface as the
|
||||
* dashboard hero's primary (`light-primary-button` mixin).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-primary-button',
|
||||
imports: [MatIcon],
|
||||
templateUrl: './detail-primary-button.component.html',
|
||||
styleUrl: './detail-primary-button.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailPrimaryButtonComponent {
|
||||
readonly label = input.required<string>();
|
||||
/** Second, smaller line: episode code, time left, duration. */
|
||||
readonly meta = input<string | null>(null);
|
||||
readonly icon = input('play_arrow');
|
||||
readonly state = input<DetailPrimaryButtonState>('idle');
|
||||
readonly disabled = input(false);
|
||||
readonly testId = input<string | null>(null);
|
||||
|
||||
readonly pressed = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<section class="rail">
|
||||
<header class="rail__header">
|
||||
<h3 class="rail__title" [attr.id]="headingId()">{{ title() }}</h3>
|
||||
@if (count() !== null) {
|
||||
<span class="rail__count">{{ count() }}</span>
|
||||
}
|
||||
<ng-content select="[rail-header]" />
|
||||
@if (overflowing()) {
|
||||
<div class="rail__arrows">
|
||||
<button
|
||||
type="button"
|
||||
class="rail__arrow"
|
||||
[attr.aria-label]="'PORTALS.DETAIL.SCROLL_PREVIOUS' | translate"
|
||||
(click)="scrollBy(-1)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_left</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rail__arrow"
|
||||
[attr.aria-label]="'PORTALS.DETAIL.SCROLL_NEXT' | translate"
|
||||
(click)="scrollBy(1)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
<div #scroller class="rail__scroller">
|
||||
<ng-content />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,80 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rail__title {
|
||||
margin: 0;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rail__count {
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rail__arrows {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rail__arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 50%;
|
||||
background: var(--app-widget-bg, #0f141c);
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-card-hover-bg, #1d2533);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__scroller {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rail__scroller {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
const SCROLL_STEP_PX = 450;
|
||||
|
||||
/**
|
||||
* A details-page row: heading, optional count, previous/next buttons and a
|
||||
* horizontal scroller with a hidden scrollbar. Hosts project the cards.
|
||||
* The arrows only render while the content overflows.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-detail-rail',
|
||||
imports: [MatIcon, TranslatePipe],
|
||||
templateUrl: './detail-rail.component.html',
|
||||
styleUrl: './detail-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DetailRailComponent {
|
||||
readonly title = input.required<string>();
|
||||
readonly count = input<number | string | null>(null);
|
||||
readonly headingId = input<string | null>(null);
|
||||
|
||||
readonly overflowing = signal(false);
|
||||
|
||||
private readonly scroller =
|
||||
viewChild.required<ElementRef<HTMLElement>>('scroller');
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => this.observeOverflow());
|
||||
}
|
||||
|
||||
scrollBy(direction: -1 | 1): void {
|
||||
this.scroller().nativeElement.scrollBy({
|
||||
left: direction * SCROLL_STEP_PX,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
private observeOverflow(): void {
|
||||
const element = this.scroller().nativeElement;
|
||||
const measure = () =>
|
||||
this.overflowing.set(element.scrollWidth > element.clientWidth + 1);
|
||||
measure();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
const mutations = new MutationObserver(measure);
|
||||
mutations.observe(element, { childList: true, subtree: true });
|
||||
this.destroyRef.onDestroy(() => {
|
||||
observer.disconnect();
|
||||
mutations.disconnect();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
padding: 3px 9px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 10%, transparent);
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:host(.meta-chip--rating) {
|
||||
color: var(--app-rating-color, #ffcf5c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Production status is a state, not a fact about the title: the soft
|
||||
// selection tint sets it apart from the plain facts beside it.
|
||||
:host(.meta-chip--status) {
|
||||
background: var(--app-selection-surface, rgba(152, 184, 255, 0.16));
|
||||
color: var(--app-selection-color, #98b8ff);
|
||||
}
|
||||
|
||||
// Facets inside one chip ("Thriller · Drama"): each genre stays a button.
|
||||
:host ::ng-deep .meta-chip__facet {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .meta-chip__sep {
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MetaChipComponent } from './meta-chip.component';
|
||||
|
||||
@Component({
|
||||
imports: [MetaChipComponent],
|
||||
template: `
|
||||
<app-meta-chip data-test-id="plain">2026</app-meta-chip>
|
||||
<app-meta-chip variant="rating" data-test-id="rating">★ 7.3</app-meta-chip>
|
||||
<app-meta-chip variant="status" data-test-id="status">
|
||||
<button type="button" class="meta-chip__facet">Returning</button>
|
||||
</app-meta-chip>
|
||||
`,
|
||||
})
|
||||
class HostComponent {}
|
||||
|
||||
describe('MetaChipComponent', () => {
|
||||
it('applies the variant classes on the host element', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const plain = host.querySelector('[data-test-id="plain"]')!;
|
||||
const rating = host.querySelector('[data-test-id="rating"]')!;
|
||||
const status = host.querySelector('[data-test-id="status"]')!;
|
||||
expect(plain.classList.contains('meta-chip')).toBe(true);
|
||||
expect(plain.classList.contains('meta-chip--rating')).toBe(false);
|
||||
expect(rating.classList.contains('meta-chip--rating')).toBe(true);
|
||||
expect(status.classList.contains('meta-chip--status')).toBe(true);
|
||||
expect(status.querySelector('button.meta-chip__facet')?.textContent?.trim()).toBe(
|
||||
'Returning'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
export type MetaChipVariant = 'default' | 'rating' | 'status';
|
||||
|
||||
/**
|
||||
* Pill meta chip shared by the VOD details hero and the dashboard hero
|
||||
* ("2026", "1 h 52 min", "★ 7.3", "Season 2", "Returning").
|
||||
*
|
||||
* A facet that opens the discover page keeps its keyboard behaviour as a
|
||||
* projected `<button class="meta-chip__facet">`; several facets in one chip
|
||||
* ("Thriller · Drama") are separated by `.meta-chip__sep`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-meta-chip',
|
||||
template: '<ng-content />',
|
||||
styleUrl: './meta-chip.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'meta-chip',
|
||||
'[class.meta-chip--rating]': 'variant() === "rating"',
|
||||
'[class.meta-chip--status]': 'variant() === "status"',
|
||||
},
|
||||
})
|
||||
export class MetaChipComponent {
|
||||
readonly variant = input<MetaChipVariant>('default');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@if (items().length) {
|
||||
<app-detail-rail [title]="title()" data-test-id="tmdb-similar">
|
||||
@for (item of items(); track item.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="similar-card"
|
||||
[title]="item.tooltip ?? item.title"
|
||||
(click)="selected.emit(item)"
|
||||
>
|
||||
@if (posterFor(item); as poster) {
|
||||
<img
|
||||
class="similar-card__poster"
|
||||
[src]="poster"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
(error)="markFailed(poster)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="similar-card__poster similar-card__poster--fallback"
|
||||
aria-hidden="true"
|
||||
>{{ item.title.charAt(0) }}</span
|
||||
>
|
||||
}
|
||||
<span class="similar-card__title">{{ item.title }}</span>
|
||||
@if (item.year) {
|
||||
<span class="similar-card__year">{{ item.year }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</app-detail-rail>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.similar-card {
|
||||
display: flex;
|
||||
flex: 0 0 140px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 140px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .similar-card__poster {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.similar-card__poster {
|
||||
display: block;
|
||||
width: 140px;
|
||||
aspect-ratio: 2 / 3;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
background: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.similar-card__title {
|
||||
overflow: hidden;
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.similar-card__year {
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.similar-card__poster {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SimilarRailComponent } from './similar-rail.component';
|
||||
|
||||
describe('SimilarRailComponent', () => {
|
||||
let fixture: ComponentFixture<SimilarRailComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SimilarRailComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(SimilarRailComponent);
|
||||
fixture.componentRef.setInput('title', 'Similar');
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ key: 'a', title: 'Ted K', posterUrl: 'ted.jpg', year: 2021 },
|
||||
{ key: 'b', title: 'Zodiac', posterUrl: null, year: null, tooltip: 'Backup' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders the year, no source line, and a letter fallback', () => {
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const cards = host.querySelectorAll('.similar-card');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0].textContent).toContain('2021');
|
||||
expect(cards[1].querySelector('.similar-card__poster--fallback')?.textContent?.trim()).toBe('Z');
|
||||
expect(cards[1].getAttribute('title')).toBe('Backup');
|
||||
expect(host.textContent).not.toContain('http');
|
||||
});
|
||||
|
||||
it('emits the item on click and swaps a broken poster for the fallback', () => {
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const spy = jest.fn();
|
||||
fixture.componentInstance.selected.subscribe(spy);
|
||||
host.querySelector<HTMLButtonElement>('.similar-card')!.click();
|
||||
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'a' }));
|
||||
|
||||
host.querySelector<HTMLImageElement>('img.similar-card__poster')!.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(host.querySelectorAll('.similar-card__poster--fallback')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { DetailRailComponent } from './detail-rail.component';
|
||||
|
||||
/** One poster of the "Similar" row. */
|
||||
export interface SimilarRailItem {
|
||||
readonly key: string;
|
||||
readonly title: string;
|
||||
readonly posterUrl: string | null;
|
||||
readonly year: number | null;
|
||||
/** Tooltip detail such as the playlist a cross-portal match lives in. */
|
||||
readonly tooltip?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Similar" row: 140px posters with the title and year. Hosts map their
|
||||
* TMDB and cross-portal matches to `SimilarRailItem` and react to clicks.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-similar-rail',
|
||||
imports: [DetailRailComponent],
|
||||
templateUrl: './similar-rail.component.html',
|
||||
styleUrl: './similar-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SimilarRailComponent {
|
||||
readonly title = input.required<string>();
|
||||
readonly items = input.required<readonly SimilarRailItem[]>();
|
||||
|
||||
readonly selected = output<SimilarRailItem>();
|
||||
|
||||
readonly failedPosters = signal<Record<string, true>>({});
|
||||
|
||||
posterFor(item: SimilarRailItem): string | null {
|
||||
const url = item.posterUrl;
|
||||
return url && !this.failedPosters()[url] ? url : null;
|
||||
}
|
||||
|
||||
markFailed(url: string): void {
|
||||
this.failedPosters.update((failed) => ({ ...failed, [url]: true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface TrailerDialogData {
|
||||
/** A `youtube-nocookie.com/embed/...` URL, see `youtubeEmbedUrl`. */
|
||||
readonly embedUrl: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailer, as a 16:9 YouTube embed in a modal over the details column.
|
||||
* Escape and the backdrop close it; the title row keeps the single Close.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-trailer-dialog',
|
||||
imports: [MatDialogModule, MatButtonModule, TranslatePipe],
|
||||
template: `
|
||||
<div class="trailer">
|
||||
<div class="trailer__frame">
|
||||
<iframe
|
||||
class="trailer__iframe"
|
||||
[src]="embedUrl"
|
||||
[title]="data.title"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="trailer__bar">
|
||||
<h2 mat-dialog-title class="trailer__title">
|
||||
{{ 'PORTALS.DETAIL.TRAILER_OF' | translate: { title: data.title } }}
|
||||
</h2>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="trailer__close"
|
||||
mat-dialog-close
|
||||
data-test-id="trailer-dialog-close"
|
||||
>
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trailer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trailer__frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.trailer__iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.trailer__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
// Keeps the directive (the dialog's accessible name) but drops
|
||||
// Material's headline padding and the 40px strut before it.
|
||||
.trailer__title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trailer__close {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TrailerDialogComponent {
|
||||
readonly data = inject<TrailerDialogData>(MAT_DIALOG_DATA);
|
||||
readonly embedUrl: SafeResourceUrl = inject(
|
||||
DomSanitizer
|
||||
).bypassSecurityTrustResourceUrl(withAutoplay(this.data.embedUrl));
|
||||
}
|
||||
|
||||
function withAutoplay(embedUrl: string): string {
|
||||
try {
|
||||
const url = new URL(embedUrl);
|
||||
url.searchParams.set('autoplay', '1');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return embedUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatDialog, type MatDialogRef } from '@angular/material/dialog';
|
||||
import {
|
||||
TrailerDialogComponent,
|
||||
type TrailerDialogData,
|
||||
} from './trailer-dialog.component';
|
||||
|
||||
export const TRAILER_DIALOG_PANEL_CLASS = 'app-trailer-dialog-panel';
|
||||
|
||||
/** Opens the trailer modal at one width for every details page. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TrailerDialogService {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
open(data: TrailerDialogData): MatDialogRef<TrailerDialogComponent> {
|
||||
return this.dialog.open(TrailerDialogComponent, {
|
||||
data,
|
||||
width: 'min(880px, calc(100vw - 48px))',
|
||||
maxWidth: '880px',
|
||||
autoFocus: 'dialog',
|
||||
panelClass: TRAILER_DIALOG_PANEL_CLASS,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<button
|
||||
#triggerButton
|
||||
type="button"
|
||||
class="more-trigger"
|
||||
cdkOverlayOrigin
|
||||
#trigger="cdkOverlayOrigin"
|
||||
aria-haspopup="menu"
|
||||
[class.more-trigger--open]="isOpen()"
|
||||
[attr.aria-expanded]="isOpen()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.data-testid]="testId()"
|
||||
[matTooltip]="label()"
|
||||
[disabled]="!hasRows()"
|
||||
(click)="toggle()"
|
||||
(keydown)="onTriggerKeydown($event)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">more_horiz</mat-icon>
|
||||
</button>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="trigger"
|
||||
[cdkConnectedOverlayOpen]="isOpen()"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
[cdkConnectedOverlayPositions]="overlayPositions"
|
||||
[cdkConnectedOverlayViewportMargin]="12"
|
||||
[cdkConnectedOverlayFlexibleDimensions]="false"
|
||||
(backdropClick)="close()"
|
||||
(detach)="close()"
|
||||
(overlayKeydown)="onOverlayKeydown($event)"
|
||||
>
|
||||
<div
|
||||
#panel
|
||||
class="more-menu"
|
||||
[class.more-menu--sources]="view() === 'sources'"
|
||||
role="menu"
|
||||
[attr.aria-label]="label()"
|
||||
data-test-id="vod-more-menu"
|
||||
(keydown)="onMenuKeydown($event)"
|
||||
>
|
||||
@if (view() === 'menu') {
|
||||
@for (section of sections(); track $index) {
|
||||
@if (section.items.length) {
|
||||
@if ($index > 0) {
|
||||
<hr class="more-menu__divider" aria-hidden="true" />
|
||||
}
|
||||
@if (section.labelKey; as groupKey) {
|
||||
<div class="more-menu__group" role="presentation">
|
||||
{{ groupKey | translate }}
|
||||
</div>
|
||||
}
|
||||
@for (item of section.items; track item.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="more-menu__item"
|
||||
[disabled]="item.disabled"
|
||||
[attr.data-test-id]="item.testId ?? null"
|
||||
[attr.data-menu-item]="item.id"
|
||||
(click)="select(item)"
|
||||
>
|
||||
@if (item.icon; as icon) {
|
||||
<mat-icon
|
||||
class="more-menu__icon"
|
||||
aria-hidden="true"
|
||||
>{{ icon }}</mat-icon
|
||||
>
|
||||
}
|
||||
<span class="more-menu__label">{{
|
||||
item.labelKey | translate: item.labelParams
|
||||
}}</span>
|
||||
@if (item.hint !== null && item.hint !== undefined) {
|
||||
<span class="more-menu__hint">{{
|
||||
item.hint
|
||||
}}</span>
|
||||
}
|
||||
@if (item.kind === 'sources') {
|
||||
<mat-icon
|
||||
class="more-menu__chevron"
|
||||
aria-hidden="true"
|
||||
>chevron_right</mat-icon
|
||||
>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="more-menu__item more-menu__back"
|
||||
role="menuitem"
|
||||
(click)="showMenu()"
|
||||
>
|
||||
<mat-icon class="more-menu__icon" aria-hidden="true"
|
||||
>arrow_back</mat-icon
|
||||
>
|
||||
<span class="more-menu__label">{{ 'BACK' | translate }}</span>
|
||||
</button>
|
||||
<app-vod-sources-menu
|
||||
class="more-menu__sources"
|
||||
[sources]="sources()"
|
||||
[title]="sourcesTitle()"
|
||||
[matchKind]="matchKind()"
|
||||
[autoFailoverEnabled]="autoFailoverEnabled()"
|
||||
[autoFailoverSupported]="autoFailoverSupported()"
|
||||
[resumeLabel]="resumeLabel()"
|
||||
[playbackLive]="playbackLive()"
|
||||
(playRequested)="onSourcePlay($event)"
|
||||
(pinRequested)="onSourcePin($event)"
|
||||
(checkRequested)="sourceCheckRequested.emit($event)"
|
||||
(autoFailoverToggled)="autoFailoverToggled.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,151 @@
|
||||
@use '../../../../styles/detail-view-actions' as actions;
|
||||
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.more-trigger {
|
||||
@include actions.ghost-action-surface;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&--open {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The dropdown. An opaque widget surface: a CDK overlay floats over
|
||||
// arbitrary page content, so a missing background is not cosmetic.
|
||||
.more-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 260px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: min(380px, 60vh);
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 10px;
|
||||
background: var(--app-widget-bg, #151c28);
|
||||
color: var(--app-heading-color, #e7ecf3);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
|
||||
&--sources {
|
||||
width: 660px;
|
||||
max-height: min(560px, 80vh);
|
||||
}
|
||||
|
||||
&__group {
|
||||
padding: 8px 10px 4px;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
margin: 4px 2px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
background: var(--app-card-hover-bg, #1d2533);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--app-eyebrow-color, #9aa3b2);
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
color: var(--app-eyebrow-color, #6b7384);
|
||||
}
|
||||
|
||||
&__back {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { VodMoreMenuComponent } from './vod-more-menu.component';
|
||||
import type { VodMoreMenuSection } from './vod-more-menu.model';
|
||||
|
||||
const SECTIONS: VodMoreMenuSection[] = [
|
||||
{
|
||||
labelKey: 'GROUP_A',
|
||||
items: [
|
||||
{ id: 'copy', labelKey: 'COPY', testId: 'menu-copy' },
|
||||
{ id: 'sources', labelKey: 'SOURCES', kind: 'sources', hint: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [{ id: 'reset', labelKey: 'RESET', disabled: true }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('VodMoreMenuComponent', () => {
|
||||
let fixture: ComponentFixture<VodMoreMenuComponent>;
|
||||
let overlay: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VodMoreMenuComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
GROUP_A: 'Watching',
|
||||
COPY: 'Copy stream URL',
|
||||
SOURCES: 'Other sources',
|
||||
RESET: 'Reset progress',
|
||||
BACK: 'Back',
|
||||
});
|
||||
translate.use('en');
|
||||
overlay = TestBed.inject(OverlayContainer).getContainerElement();
|
||||
fixture = TestBed.createComponent(VodMoreMenuComponent);
|
||||
fixture.componentRef.setInput('sections', SECTIONS);
|
||||
fixture.componentRef.setInput('label', 'More');
|
||||
fixture.componentRef.setInput('testId', 'more');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function trigger(): HTMLButtonElement {
|
||||
return (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-testid="more"]'
|
||||
)!;
|
||||
}
|
||||
|
||||
function rows(): HTMLButtonElement[] {
|
||||
return Array.from(
|
||||
overlay.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')
|
||||
);
|
||||
}
|
||||
|
||||
it('opens on click with groups, hints and disabled rows, and emits the chosen id', () => {
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
expect(trigger().getAttribute('aria-expanded')).toBe('true');
|
||||
expect(overlay.querySelector('[role="menu"]')).toBeTruthy();
|
||||
expect(overlay.textContent).toContain('Watching');
|
||||
expect(overlay.textContent).toContain('Other sources');
|
||||
expect(overlay.querySelector('.more-menu__hint')?.textContent?.trim()).toBe('2');
|
||||
const reset = rows().find((row) => row.textContent?.includes('Reset'))!;
|
||||
expect(reset.disabled).toBe(true);
|
||||
|
||||
const spy = jest.fn();
|
||||
fixture.componentInstance.selected.subscribe(spy);
|
||||
overlay.querySelector<HTMLButtonElement>('[data-test-id="menu-copy"]')!.click();
|
||||
fixture.detectChanges();
|
||||
expect(spy).toHaveBeenCalledWith('copy');
|
||||
expect(overlay.querySelector('[role="menu"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('moves focus with the arrow keys and closes on Escape', () => {
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
const [first, second] = rows();
|
||||
first.focus();
|
||||
const menu = overlay.querySelector<HTMLElement>('[role="menu"]')!;
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||
);
|
||||
expect(document.activeElement).toBe(second);
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
|
||||
);
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
menu.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to the sources panel and back', () => {
|
||||
fixture.componentRef.setInput('sources', []);
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
rows()
|
||||
.find((row) => row.textContent?.includes('Other sources'))!
|
||||
.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.view()).toBe('sources');
|
||||
expect(overlay.querySelector('app-vod-sources-menu')).toBeTruthy();
|
||||
overlay.querySelector<HTMLButtonElement>('.more-menu__back')!.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.view()).toBe('menu');
|
||||
});
|
||||
|
||||
it('disables the trigger when there are no rows', () => {
|
||||
fixture.componentRef.setInput('sections', [{ items: [] }]);
|
||||
fixture.detectChanges();
|
||||
expect(trigger().disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
type ConnectedPosition,
|
||||
} from '@angular/cdk/overlay';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type {
|
||||
VodSourceDescriptor,
|
||||
VodSourceMatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodSourcesMenuComponent } from '../vod-sources/vod-sources-menu.component';
|
||||
import type { VodMoreMenuItem, VodMoreMenuSection } from './vod-more-menu.model';
|
||||
|
||||
type MenuView = 'menu' | 'sources';
|
||||
|
||||
/**
|
||||
* The "…" button of the details action row and its dropdown. Right edges
|
||||
* align with the button so the panel never leaves the details column; when
|
||||
* the space below is short the panel opens upward. Rows are plain buttons
|
||||
* with `role="menuitem"`, reachable with the arrow keys; Escape and an
|
||||
* outside click close the menu. Selecting the `sources` row swaps the panel
|
||||
* to the alternative-sources list.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vod-more-menu',
|
||||
imports: [
|
||||
CdkConnectedOverlay,
|
||||
CdkOverlayOrigin,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
VodSourcesMenuComponent,
|
||||
],
|
||||
templateUrl: './vod-more-menu.component.html',
|
||||
styleUrl: './vod-more-menu.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VodMoreMenuComponent {
|
||||
readonly sections = input.required<readonly VodMoreMenuSection[]>();
|
||||
readonly label = input.required<string>();
|
||||
readonly testId = input<string | null>(null);
|
||||
|
||||
// Alternative sources, shown by the `sources` row.
|
||||
readonly sources = input<readonly VodSourceDescriptor[]>([]);
|
||||
readonly sourcesTitle = input('');
|
||||
readonly matchKind = input<VodSourceMatchKind>('title-year');
|
||||
readonly autoFailoverEnabled = input(false);
|
||||
readonly autoFailoverSupported = input(true);
|
||||
readonly playbackLive = input(false);
|
||||
readonly resumeLabel = input<string | null>(null);
|
||||
|
||||
readonly selected = output<string>();
|
||||
readonly sourcePlayRequested = output<string>();
|
||||
readonly sourcePinRequested = output<string>();
|
||||
readonly sourceCheckRequested = output<string>();
|
||||
readonly autoFailoverToggled = output<boolean>();
|
||||
|
||||
readonly isOpen = signal(false);
|
||||
readonly view = signal<MenuView>('menu');
|
||||
readonly hasRows = computed(() =>
|
||||
this.sections().some((section) => section.items.length > 0)
|
||||
);
|
||||
|
||||
private readonly panel = viewChild<ElementRef<HTMLElement>>('panel');
|
||||
private readonly trigger =
|
||||
viewChild.required<ElementRef<HTMLButtonElement>>('triggerButton');
|
||||
|
||||
/** Below the button, right-aligned; above it when the space below is short. */
|
||||
readonly overlayPositions: ConnectedPosition[] = [
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetY: 6,
|
||||
panelClass: 'vod-more-menu-pane--below',
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -6,
|
||||
panelClass: 'vod-more-menu-pane--above',
|
||||
},
|
||||
];
|
||||
|
||||
toggle(): void {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.view.set('menu');
|
||||
this.isOpen.set(true);
|
||||
this.focusRow(0);
|
||||
}
|
||||
|
||||
close(restoreFocus = false): void {
|
||||
if (!this.isOpen()) {
|
||||
return;
|
||||
}
|
||||
this.isOpen.set(false);
|
||||
this.view.set('menu');
|
||||
if (restoreFocus) {
|
||||
this.trigger().nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
showMenu(): void {
|
||||
this.view.set('menu');
|
||||
this.focusRow(0);
|
||||
}
|
||||
|
||||
select(item: VodMoreMenuItem): void {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
if (item.kind === 'sources') {
|
||||
this.view.set('sources');
|
||||
return;
|
||||
}
|
||||
this.close(true);
|
||||
this.selected.emit(item.id);
|
||||
}
|
||||
|
||||
onOverlayKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close(true);
|
||||
} else if (event.key === 'Tab') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
onTriggerKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowDown' && !this.isOpen()) {
|
||||
event.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
onMenuKeydown(event: KeyboardEvent): void {
|
||||
if (this.view() !== 'menu') {
|
||||
return;
|
||||
}
|
||||
const rows = this.rows();
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const current = rows.indexOf(document.activeElement as HTMLElement);
|
||||
let next: number | null = null;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
next = (current + 1) % rows.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
next = (current - 1 + rows.length) % rows.length;
|
||||
break;
|
||||
case 'Home':
|
||||
next = 0;
|
||||
break;
|
||||
case 'End':
|
||||
next = rows.length - 1;
|
||||
break;
|
||||
}
|
||||
if (next !== null) {
|
||||
event.preventDefault();
|
||||
rows[next].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Playing from a row hands the screen back to the player. */
|
||||
onSourcePlay(sourceId: string): void {
|
||||
this.close();
|
||||
this.sourcePlayRequested.emit(sourceId);
|
||||
}
|
||||
|
||||
onSourcePin(sourceId: string): void {
|
||||
this.close();
|
||||
this.sourcePinRequested.emit(sourceId);
|
||||
}
|
||||
|
||||
private rows(): HTMLElement[] {
|
||||
const panel = this.panel()?.nativeElement;
|
||||
return panel
|
||||
? Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(
|
||||
'[role="menuitem"]:not([disabled])'
|
||||
)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
private focusRow(index: number): void {
|
||||
// The overlay attaches after this change detection pass.
|
||||
setTimeout(() => this.rows()[index]?.focus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** One row of the details "…" menu. Labels are i18n keys; hints are literal. */
|
||||
export interface VodMoreMenuItem {
|
||||
readonly id: string;
|
||||
readonly labelKey: string;
|
||||
readonly labelParams?: Record<string, string | number>;
|
||||
/** Right-aligned literal hint: a count, a category, a player name. */
|
||||
readonly hint?: string | number | null;
|
||||
readonly icon?: string;
|
||||
readonly disabled?: boolean;
|
||||
readonly testId?: string;
|
||||
/** `sources` swaps the panel to the alternative-sources list on select. */
|
||||
readonly kind?: 'action' | 'sources';
|
||||
}
|
||||
|
||||
/** A labelled group of rows; groups are separated by a hairline. */
|
||||
export interface VodMoreMenuSection {
|
||||
readonly labelKey?: string | null;
|
||||
readonly items: readonly VodMoreMenuItem[];
|
||||
}
|
||||
@@ -1,36 +1,13 @@
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
isPortalPlaybackWatched,
|
||||
parseDurationSeconds,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Pure helpers for episode duration parsing and progress-text formatting. */
|
||||
|
||||
export function parseDuration(duration: string | number | undefined): number {
|
||||
if (!duration) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return duration;
|
||||
}
|
||||
|
||||
// Providers report "45 min" (Stalker VOD) and "1h 30min" (Xtream) —
|
||||
// the hour component must not be dropped when both are present.
|
||||
const minutesMatch = duration.match(/(?:(\d+)\s*h\w*)?\s*(\d+)\s*min/);
|
||||
if (minutesMatch) {
|
||||
return (
|
||||
parseInt(minutesMatch[1] ?? '0', 10) * 3600 +
|
||||
parseInt(minutesMatch[2], 10) * 60
|
||||
);
|
||||
}
|
||||
|
||||
const parts = duration.split(':').map(Number);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
return Number(duration) || 0;
|
||||
return parseDurationSeconds(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -306,3 +306,110 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cinematic action row (VOD details hero + dashboard hero) ────────────────
|
||||
// One light primary and one ghost surface, shared by the detail pages and the
|
||||
// dashboard hero so "Continue" means the same thing everywhere. Fixed
|
||||
// geometry: 44px tall in the details, 38px in the dashboard hero; the mixins
|
||||
// only size the type and radius, the caller sets the height.
|
||||
|
||||
@mixin light-primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px 0 16px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--app-cta-bg, #f2f4f8);
|
||||
color: var(--app-cta-fg, #0a0e14);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--app-cta-hover-bg, #fff);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin ghost-action-surface {
|
||||
background: color-mix(in srgb, var(--app-on-surface, #e6e1e5) 12%, transparent);
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 14%, transparent);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Text secondary action ("Trailer", "Details"): the ghost surface at the
|
||||
// primary's height.
|
||||
@mixin ghost-text-button {
|
||||
@include ghost-action-surface;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 18px;
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
|
||||
|
||||
export interface DashboardRemainingLabel {
|
||||
readonly key: string;
|
||||
readonly params: Record<string, number>;
|
||||
}
|
||||
import {
|
||||
formatRemainingLabel,
|
||||
playbackProgressPercent,
|
||||
type RemainingTimeLabel,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
export type DashboardRemainingLabel = RemainingTimeLabel;
|
||||
export { formatRemainingLabel, playbackProgressPercent };
|
||||
|
||||
export function buildPlaybackPositionReloadKey(
|
||||
items: readonly Pick<
|
||||
@@ -29,66 +33,6 @@ export function isContinueWatchingRecentItem(
|
||||
return item.type === 'movie' || item.type === 'series';
|
||||
}
|
||||
|
||||
export function playbackProgressPercent(
|
||||
position: { positionSeconds: number; durationSeconds?: number } | null
|
||||
): number | null {
|
||||
if (
|
||||
!position ||
|
||||
position.durationSeconds == null ||
|
||||
position.durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ratio = position.positionSeconds / position.durationSeconds;
|
||||
if (!Number.isFinite(ratio)) {
|
||||
return null;
|
||||
}
|
||||
// Integer percent keeps "92% watched" out of "92.4% watched" territory,
|
||||
// and matches the resolution of a 3px-tall progress bar on a 280px card.
|
||||
return Math.max(0, Math.min(100, Math.floor(ratio * 100)));
|
||||
}
|
||||
|
||||
export function formatRemainingLabel(
|
||||
position: { positionSeconds: number; durationSeconds?: number } | null
|
||||
): DashboardRemainingLabel | null {
|
||||
if (
|
||||
!position ||
|
||||
position.durationSeconds == null ||
|
||||
position.durationSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.round(position.durationSeconds - position.positionSeconds)
|
||||
);
|
||||
if (remaining < 60) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS',
|
||||
params: { seconds: remaining },
|
||||
};
|
||||
}
|
||||
const totalMinutes = Math.round(remaining / 60);
|
||||
if (totalMinutes < 60) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: totalMinutes },
|
||||
};
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (minutes === 0) {
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS',
|
||||
params: { hours },
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES',
|
||||
params: { hours, minutes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "S1·E5" for an item whose progress is tracked per episode. Keyed on the
|
||||
* WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes as a
|
||||
|
||||
Reference in new issue
Block a user