From 3fd65c935cdcbe6bb61e357b7eb23c18b7cd7028 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 2 Oct 2026 07:58:06 +0200 Subject: [PATCH] feat(ui): shared cinematic detail pieces (chip, light primary, icon button, more menu, cast row, trailer dialog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/assets/i18n/ar.json | 28 +++ apps/web/src/assets/i18n/ary.json | 28 +++ apps/web/src/assets/i18n/by.json | 28 +++ apps/web/src/assets/i18n/de.json | 28 +++ apps/web/src/assets/i18n/el.json | 28 +++ apps/web/src/assets/i18n/en.json | 28 +++ apps/web/src/assets/i18n/es.json | 28 +++ apps/web/src/assets/i18n/fr.json | 28 +++ apps/web/src/assets/i18n/hu.json | 28 +++ apps/web/src/assets/i18n/it.json | 28 +++ apps/web/src/assets/i18n/ja.json | 28 +++ apps/web/src/assets/i18n/ko.json | 28 +++ apps/web/src/assets/i18n/nl.json | 28 +++ apps/web/src/assets/i18n/pl.json | 28 +++ apps/web/src/assets/i18n/pt.json | 28 +++ apps/web/src/assets/i18n/ru.json | 28 +++ apps/web/src/assets/i18n/tr.json | 28 +++ apps/web/src/assets/i18n/zh.json | 28 +++ apps/web/src/assets/i18n/zhtw.json | 28 +++ apps/web/src/m3-theme.scss | 17 ++ libs/portal/shared/util/src/index.ts | 3 + .../src/lib/detail/country-short-name.spec.ts | 28 +++ .../util/src/lib/detail/country-short-name.ts | 79 +++++++ .../src/lib/detail/playback-progress.spec.ts | 85 +++++++ .../util/src/lib/detail/playback-progress.ts | 121 ++++++++++ .../util/src/lib/detail/season-title.spec.ts | 40 ++++ .../util/src/lib/detail/season-title.ts | 29 +++ libs/ui/components/src/index.ts | 13 ++ .../detail-ui/cast-crew-row.component.html | 37 +++ .../detail-ui/cast-crew-row.component.scss | 66 ++++++ .../lib/detail-ui/cast-crew-row.component.ts | 53 +++++ .../src/lib/detail-ui/cast-crew.util.spec.ts | 51 +++++ .../src/lib/detail-ui/cast-crew.util.ts | 90 ++++++++ .../detail-ui/detail-credits.component.html | 23 ++ .../detail-ui/detail-credits.component.scss | 46 ++++ .../detail-credits.component.spec.ts | 49 ++++ .../lib/detail-ui/detail-credits.component.ts | 36 +++ .../detail-icon-button.component.html | 20 ++ .../detail-icon-button.component.scss | 59 +++++ .../detail-ui/detail-icon-button.component.ts | 32 +++ .../detail-primary-button.component.html | 19 ++ .../detail-primary-button.component.scss | 98 ++++++++ .../detail-primary-button.component.ts | 29 +++ .../lib/detail-ui/detail-rail.component.html | 32 +++ .../lib/detail-ui/detail-rail.component.scss | 80 +++++++ .../lib/detail-ui/detail-rail.component.ts | 68 ++++++ .../lib/detail-ui/meta-chip.component.scss | 56 +++++ .../lib/detail-ui/meta-chip.component.spec.ts | 36 +++ .../src/lib/detail-ui/meta-chip.component.ts | 26 +++ .../lib/detail-ui/similar-rail.component.html | 32 +++ .../lib/detail-ui/similar-rail.component.scss | 68 ++++++ .../detail-ui/similar-rail.component.spec.ts | 42 ++++ .../lib/detail-ui/similar-rail.component.ts | 47 ++++ .../lib/detail-ui/trailer-dialog.component.ts | 120 ++++++++++ .../lib/detail-ui/trailer-dialog.service.ts | 24 ++ .../detail-ui/vod-more-menu.component.html | 117 ++++++++++ .../detail-ui/vod-more-menu.component.scss | 151 +++++++++++++ .../detail-ui/vod-more-menu.component.spec.ts | 122 ++++++++++ .../lib/detail-ui/vod-more-menu.component.ts | 210 ++++++++++++++++++ .../src/lib/detail-ui/vod-more-menu.model.ts | 19 ++ .../season-container/episode-progress.util.ts | 33 +-- libs/ui/styles/_detail-view-actions.scss | 107 +++++++++ .../src/lib/rails/dashboard-playback.utils.ts | 72 +----- 63 files changed, 3025 insertions(+), 92 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts create mode 100644 libs/portal/shared/util/src/lib/detail/country-short-name.ts create mode 100644 libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts create mode 100644 libs/portal/shared/util/src/lib/detail/playback-progress.ts create mode 100644 libs/portal/shared/util/src/lib/detail/season-title.spec.ts create mode 100644 libs/portal/shared/util/src/lib/detail/season-title.ts create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew-row.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew.util.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/cast-crew.util.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-credits.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-icon-button.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-icon-button.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-icon-button.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-primary-button.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-primary-button.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-primary-button.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/detail-rail.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/detail-rail.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/detail-rail.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/meta-chip.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/meta-chip.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/meta-chip.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/similar-rail.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.html create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.scss create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts create mode 100644 libs/ui/components/src/lib/detail-ui/vod-more-menu.model.ts diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index f09483744..416f59863 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "معلومات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 7e672bb78..7ef2d6d3e 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "معلومات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index bd06e3b88..113f09218 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Інфармацыя", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 12ca756f3..f509236d9 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index d4065b7b7..a212d36e3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Πληροφορίες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 401da3613..dbb0d6b9f 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0e460f3ae..b61566be4 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 54eb60961..919ba68a6 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index aee8487dd..31fe97ff2 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 886d12389..72a54689f 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index fc86eae05..95bda767b 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "情報", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 9b4f72484..395b78321 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "정보", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 81146e2d8..7548d6c59 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 27e2db41c..323332f12 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index afb27e027..6ed95c5bc 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c8e73edd5..2f01260de 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Инфо", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index a4c3c71b9..c9b512404 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 5b09ac96c..fa2681c1a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "信息", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 8e9cfad4f..6e693de57 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "資訊", diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index e5190fc8a..1506be0c0 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -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; } } diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index f139e536b..942d05976 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts b/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts new file mode 100644 index 000000000..dfe616493 --- /dev/null +++ b/libs/portal/shared/util/src/lib/detail/country-short-name.spec.ts @@ -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([]); + }); +}); diff --git a/libs/portal/shared/util/src/lib/detail/country-short-name.ts b/libs/portal/shared/util/src/lib/detail/country-short-name.ts new file mode 100644 index 000000000..0383326fb --- /dev/null +++ b/libs/portal/shared/util/src/lib/detail/country-short-name.ts @@ -0,0 +1,79 @@ +/** Short display names for the countries providers spell out in full. */ +const SHORT_BY_CODE: Record = { + 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 = { + '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); +} diff --git a/libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts b/libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts new file mode 100644 index 000000000..74c1c4479 --- /dev/null +++ b/libs/portal/shared/util/src/lib/detail/playback-progress.spec.ts @@ -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); + }); +}); diff --git a/libs/portal/shared/util/src/lib/detail/playback-progress.ts b/libs/portal/shared/util/src/lib/detail/playback-progress.ts new file mode 100644 index 000000000..127afad7b --- /dev/null +++ b/libs/portal/shared/util/src/lib/detail/playback-progress.ts @@ -0,0 +1,121 @@ +export interface RemainingTimeLabel { + readonly key: string; + readonly params: Record; +} + +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; +} diff --git a/libs/portal/shared/util/src/lib/detail/season-title.spec.ts b/libs/portal/shared/util/src/lib/detail/season-title.spec.ts new file mode 100644 index 000000000..0c6b5aceb --- /dev/null +++ b/libs/portal/shared/util/src/lib/detail/season-title.spec.ts @@ -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 }); + }); +}); diff --git a/libs/portal/shared/util/src/lib/detail/season-title.ts b/libs/portal/shared/util/src/lib/detail/season-title.ts new file mode 100644 index 000000000..24dcd2283 --- /dev/null +++ b/libs/portal/shared/util/src/lib/detail/season-title.ts @@ -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 }; +} diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 07e6ca0b3..d4fe6949c 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.html b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.html new file mode 100644 index 000000000..148c77fa5 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.html @@ -0,0 +1,37 @@ +@if (entries().length) { + + @for (entry of entries(); track entry.key) { +
+ @if (entry.profileUrl; as photo) { + + } @else { + + } +

{{ entry.name }}

+ {{ entry.role ?? ' ' }} +
+ } +
+} diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss new file mode 100644 index 000000000..0a369721b --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.scss @@ -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; +} diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts new file mode 100644 index 000000000..eaf2c0ab1 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/cast-crew-row.component.ts @@ -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 directors = input([]); + readonly headingId = input('detail-cast-crew'); + + readonly personSelected = output(); + + private readonly translate = inject(TranslateService); + + readonly entries = computed(() => + 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); + } + } +} diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew.util.spec.ts b/libs/ui/components/src/lib/detail-ui/cast-crew.util.spec.ts new file mode 100644 index 000000000..540c6d59e --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/cast-crew.util.spec.ts @@ -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(''); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/cast-crew.util.ts b/libs/ui/components/src/lib/detail-ui/cast-crew.util.ts new file mode 100644 index 000000000..46cbd42bb --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/cast-crew.util.ts @@ -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(); + 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(); + 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, ' '); +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-credits.component.html b/libs/ui/components/src/lib/detail-ui/detail-credits.component.html new file mode 100644 index 000000000..8f84d7c18 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-credits.component.html @@ -0,0 +1,23 @@ +@if (cast().length) { +
+ {{ + 'PORTALS.DETAIL.STARRING' | translate + }} + {{ leadCast() }} + @if (hasMore()) { + + } +
+} +@if (directors().length) { +
+ {{ 'XTREAM.DIRECTOR' | translate }} + {{ directorNames() }} +
+} diff --git a/libs/ui/components/src/lib/detail-ui/detail-credits.component.scss b/libs/ui/components/src/lib/detail-ui/detail-credits.component.scss new file mode 100644 index 000000000..688fb91a1 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-credits.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-credits.component.spec.ts b/libs/ui/components/src/lib/detail-ui/detail-credits.component.spec.ts new file mode 100644 index 000000000..368d3323a --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-credits.component.spec.ts @@ -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; + + 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('.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(); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/detail-credits.component.ts b/libs/ui/components/src/lib/detail-ui/detail-credits.component.ts new file mode 100644 index 000000000..fe21c9694 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-credits.component.ts @@ -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 directors = input([]); + + /** "and more" was activated: the host scrolls to the Cast & crew row. */ + readonly moreRequested = output(); + + 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(', ')); +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.html b/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.html new file mode 100644 index 000000000..973284378 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.html @@ -0,0 +1,20 @@ + diff --git a/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.scss b/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.scss new file mode 100644 index 000000000..bb6515a30 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.ts b/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.ts new file mode 100644 index 000000000..595c73476 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-icon-button.component.ts @@ -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(); + readonly icon = input(null); + readonly active = input(false); + readonly tone = input('none'); + /** Set for toggles; `null` leaves `aria-pressed` off a plain action. */ + readonly pressedState = input(null); + readonly disabled = input(false); + readonly pulsing = input(false); + readonly testId = input(null); + + readonly pressed = output(); +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.html b/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.html new file mode 100644 index 000000000..ee16ccdd9 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.html @@ -0,0 +1,19 @@ + diff --git a/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.scss b/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.scss new file mode 100644 index 000000000..755ac5e88 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.ts b/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.ts new file mode 100644 index 000000000..b6c52198d --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-primary-button.component.ts @@ -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(); + /** Second, smaller line: episode code, time left, duration. */ + readonly meta = input(null); + readonly icon = input('play_arrow'); + readonly state = input('idle'); + readonly disabled = input(false); + readonly testId = input(null); + + readonly pressed = output(); +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-rail.component.html b/libs/ui/components/src/lib/detail-ui/detail-rail.component.html new file mode 100644 index 000000000..ba6490deb --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-rail.component.html @@ -0,0 +1,32 @@ +
+
+

{{ title() }}

+ @if (count() !== null) { + {{ count() }} + } + + @if (overflowing()) { +
+ + +
+ } +
+
+ +
+
diff --git a/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss b/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss new file mode 100644 index 000000000..7c5a22e62 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-rail.component.ts b/libs/ui/components/src/lib/detail-ui/detail-rail.component.ts new file mode 100644 index 000000000..e8c597da4 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-rail.component.ts @@ -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(); + readonly count = input(null); + readonly headingId = input(null); + + readonly overflowing = signal(false); + + private readonly scroller = + viewChild.required>('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(); + }); + } +} diff --git a/libs/ui/components/src/lib/detail-ui/meta-chip.component.scss b/libs/ui/components/src/lib/detail-ui/meta-chip.component.scss new file mode 100644 index 000000000..7a0e4390b --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/meta-chip.component.scss @@ -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; +} diff --git a/libs/ui/components/src/lib/detail-ui/meta-chip.component.spec.ts b/libs/ui/components/src/lib/detail-ui/meta-chip.component.spec.ts new file mode 100644 index 000000000..f54370cbe --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/meta-chip.component.spec.ts @@ -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: ` + 2026 + ★ 7.3 + + + + `, +}) +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' + ); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/meta-chip.component.ts b/libs/ui/components/src/lib/detail-ui/meta-chip.component.ts new file mode 100644 index 000000000..e98faa3c7 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/meta-chip.component.ts @@ -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 ` + } + +} diff --git a/libs/ui/components/src/lib/detail-ui/similar-rail.component.scss b/libs/ui/components/src/lib/detail-ui/similar-rail.component.scss new file mode 100644 index 000000000..ffe90ffa2 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/similar-rail.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/similar-rail.component.spec.ts b/libs/ui/components/src/lib/detail-ui/similar-rail.component.spec.ts new file mode 100644 index 000000000..f3860a139 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/similar-rail.component.spec.ts @@ -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; + + 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('.similar-card')!.click(); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ key: 'a' })); + + host.querySelector('img.similar-card__poster')!.dispatchEvent(new Event('error')); + fixture.detectChanges(); + expect(host.querySelectorAll('.similar-card__poster--fallback')).toHaveLength(2); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/similar-rail.component.ts b/libs/ui/components/src/lib/detail-ui/similar-rail.component.ts new file mode 100644 index 000000000..594ef633e --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/similar-rail.component.ts @@ -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(); + readonly items = input.required(); + + readonly selected = output(); + + readonly failedPosters = signal>({}); + + 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 })); + } +} diff --git a/libs/ui/components/src/lib/detail-ui/trailer-dialog.component.ts b/libs/ui/components/src/lib/detail-ui/trailer-dialog.component.ts new file mode 100644 index 000000000..6bf156885 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/trailer-dialog.component.ts @@ -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: ` +
+
+ +
+
+

+ {{ 'PORTALS.DETAIL.TRAILER_OF' | translate: { title: data.title } }} +

+ +
+
+ `, + 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(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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts b/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts new file mode 100644 index 000000000..26ec5f4e3 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts @@ -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 { + return this.dialog.open(TrailerDialogComponent, { + data, + width: 'min(880px, calc(100vw - 48px))', + maxWidth: '880px', + autoFocus: 'dialog', + panelClass: TRAILER_DIALOG_PANEL_CLASS, + }); + } +} diff --git a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.html b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.html new file mode 100644 index 000000000..318277e7c --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.html @@ -0,0 +1,117 @@ + + + + + diff --git a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.scss b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.scss new file mode 100644 index 000000000..c1081c25f --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts new file mode 100644 index 000000000..28b1620c0 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.spec.ts @@ -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; + 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('[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('[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('[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('.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); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts new file mode 100644 index 000000000..0bb7e313a --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/vod-more-menu.component.ts @@ -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 label = input.required(); + readonly testId = input(null); + + // Alternative sources, shown by the `sources` row. + readonly sources = input([]); + readonly sourcesTitle = input(''); + readonly matchKind = input('title-year'); + readonly autoFailoverEnabled = input(false); + readonly autoFailoverSupported = input(true); + readonly playbackLive = input(false); + readonly resumeLabel = input(null); + + readonly selected = output(); + readonly sourcePlayRequested = output(); + readonly sourcePinRequested = output(); + readonly sourceCheckRequested = output(); + readonly autoFailoverToggled = output(); + + readonly isOpen = signal(false); + readonly view = signal('menu'); + readonly hasRows = computed(() => + this.sections().some((section) => section.items.length > 0) + ); + + private readonly panel = viewChild>('panel'); + private readonly trigger = + viewChild.required>('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( + '[role="menuitem"]:not([disabled])' + ) + ) + : []; + } + + private focusRow(index: number): void { + // The overlay attaches after this change detection pass. + setTimeout(() => this.rows()[index]?.focus()); + } +} diff --git a/libs/ui/components/src/lib/detail-ui/vod-more-menu.model.ts b/libs/ui/components/src/lib/detail-ui/vod-more-menu.model.ts new file mode 100644 index 000000000..603cb61c6 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/vod-more-menu.model.ts @@ -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; + /** 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[]; +} diff --git a/libs/ui/components/src/lib/season-container/episode-progress.util.ts b/libs/ui/components/src/lib/season-container/episode-progress.util.ts index 706e5382a..a966ed6ee 100644 --- a/libs/ui/components/src/lib/season-container/episode-progress.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-progress.util.ts @@ -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); } /** diff --git a/libs/ui/styles/_detail-view-actions.scss b/libs/ui/styles/_detail-view-actions.scss index 3335f83f6..b5d126d84 100644 --- a/libs/ui/styles/_detail-view-actions.scss +++ b/libs/ui/styles/_detail-view-actions.scss @@ -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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts index 6002e34a1..eaeef34c1 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts @@ -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; -} +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