mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(portals): two-state VOD detail pages (browse ↔ watch) with season tabs (#1127)
* feat(ui): slim ContentHero to non-scrolling hero block The page scroll container moves out of the hero into the upcoming PortalDetailShellComponent; the hero no longer hosts a default content slot for player/seasons/extras. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): add PortalDetailShellComponent with browse/watch states Two-state detail layout shell: owns page scroll, hero collapse animation (~300ms, reduced-motion aware), Escape-to-close-player handling, and an About block that re-stamps host-provided *detailTags/*detailMeta templates in watch state. The [detail-player] slot is never wrapped in a shell conditional so the host-owned player subtree survives state changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): restyle inline player header as now-playing bar Adds a back button (emits the existing closed output — in watch state back returns to browse, not route navigation) and replaces the hardcoded header strings with i18n keys (PORTALS.NOW_PLAYING, CLOSE_PLAYER, BACK_TO_BROWSE). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(xtream): migrate VOD and serial detail templates to detail shell Hero chips/meta/actions become *detailTags/*detailMeta/*detailActions templates; the inline player moves to the full-width [detail-player] slot; trailer and similar rail move to [detail-extras]. Escape and the now-playing back button close the inline player back to browse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(stalker): migrate shared VodDetails and series view to detail shell The shared VodDetailsComponent (Stalker VOD, collections, search) and StalkerSeriesViewComponent move their hero content into *detailTags/*detailMeta/*detailActions templates and host the inline player in the full-width [detail-player] slot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): season tabs replace two-level seasons navigation SeasonTabsComponent renders a pill row (dropdown beyond 6 seasons), an optional season description and a back-to-playing chip. The season container auto-selects a season (inline-playing episode's season → most recent in-progress → first), emits seasonSelected for auto-selections so lazy-load/enrichment hooks keep firing, highlights the inline-playing episode, and loses the seasons grid + 'Back to seasons' button. Download/progress helpers move to pure utils; season strings are now translatable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(portals): season descriptions under the season tabs Xtream reads season overviews from get_series_info; Stalker exposes the TMDB season overview through TmdbEnrichmentService.getSeason (same cache rows as the episode enrichment) and keys it per tmdbId so views reused across detail navigations cannot leak descriptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document two-state detail layout and season tabs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): app-prefix detail template directive selectors, build fixes detailTags/detailMeta/detailActions → appDetailTags/appDetailMeta/ appDetailActions per @angular-eslint/directive-selector; type the Escape host listener as Event; drop a redundant ?? in season tabs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(i18n): translate new detail-page keys across all 17 locales 13 PORTALS.* keys (now-playing bar, About block, season tabs, episode empty states) translated via the i18n-fill workflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): keep now-playing bar actions inside the viewport The nowrap title made the header grid track grow to min-content and pushed Copy/Close out of the page; min-width: 0 on the header/title row lets the title truncate instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): expandable season description and episode info dialog Season descriptions get the same show more/less toggle as the hero (new shared ExpandableTextComponent); episode cards and list rows get an info button that opens a dialog with the full plot, duration, air date and a Play action — card heights stay fixed and click-to-play semantics stay intact. Also: drop the per-frame backdrop blur on the player card and make the watch-enter scroll instant so the hero morph stops competing for frame budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): re-run TMDB season enrichment when the show match arrives With season tabs the first seasonSelected fires as soon as seasons load — usually before the async show-level TMDB enrichment has written tmdb_id — so the season fetch silently no-oped and was never retried, leaving episode stills/plots and the season description empty. Both detail hosts now key the fetch on (tmdb_id, selected season) in an effect, so it runs whichever arrives last. Also adds breathing room above the About divider in watch state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): move episode info button into the card body, compact overlay actions Three overlay buttons crowded the thumbnail on hover and the new info button had no styling at all. Info belongs with the content text: it now sits as a quiet ghost button at the end of the title row (revealed on card hover, MDC touch target clamped so it cannot swallow the play-on-click card area). Download + watched-toggle stay on the artwork, shrunk to 28px with a subtle edge border. List view already used the shared action style and is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): single watched indicator-toggle on episode cards Watched state was signalled three times on one card: the green badge, the hover toggle in the top-right cluster, and a checkmark appended to the title. Now one control does both jobs — a toggle at the top-left of the artwork that looks like the old green badge when watched (always visible) and appears as a ghost circle on hover when not. The right cluster keeps only the download action; the title checkmark is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): back arrow in watch mode navigates straight to the list Leaving the theater took two clicks: back to browse, then back to the list. Watch state already shows everything browse offers (episodes, About, extras), so the bar's back arrow is now route-level back (new backClicked output wired to each host's goBack), while Close player and Escape keep exiting to browse without navigating. Replaces PORTALS.BACK_TO_BROWSE with a generic top-level BACK key (all locales). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ui): compact episode stills in the list view When a season has genuinely distinct per-episode art (TMDB stills or provider images), list rows show a 96x54 thumbnail with the episode number riding on it instead of the number square. When every episode repeats the same image (providers often send the series poster), the plain number square stays — a column of identical posters is worse than none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ui): compact list-thumbnail spec under the max-lines limit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): address Greptile review — real fallback asset, untracked overflow measure default-episode.png never existed (pre-existing in the grid card, copied into the list thumbs) — both onerror handlers now fall back to the real default-poster.png. The expandable-text and content-hero measuring effects read the expanded signal via untracked so toggling show more/less no longer rebuilds their ResizeObservers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
865a44c874
commit
ce24e16e32
65 files changed
+3248
-765
No files matched your search
@@ -618,6 +618,14 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
- External players: MPV, VLC (via IPC to Electron backend)
|
||||
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
|
||||
|
||||
**VOD/Series Detail Pages (two-state layout)**:
|
||||
|
||||
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
|
||||
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
|
||||
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker)
|
||||
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
|
||||
|
||||
**Radio Player**:
|
||||
|
||||
- Dedicated audio player for channels with `radio="true"` M3U attribute
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "إلغاء",
|
||||
"CLEAR": "مسح",
|
||||
"CLOSE": "إغلاق",
|
||||
"BACK": "رجوع",
|
||||
"SHOW_MORE": "المزيد",
|
||||
"SHOW_LESS": "أقل",
|
||||
"PLAYLISTS": "قوائم التشغيل",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة",
|
||||
"COPY_STREAM_URL": "نسخ رابط البث",
|
||||
"NOW_PLAYING": "قيد التشغيل",
|
||||
"CLOSE_PLAYER": "إغلاق المشغل",
|
||||
"ABOUT": "نبذة",
|
||||
"SEASONS_AND_EPISODES": "المواسم والحلقات",
|
||||
"SEASON_TAB": "الموسم {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "العودة إلى الحلقة قيد التشغيل",
|
||||
"GRID_VIEW": "عرض شبكي",
|
||||
"LIST_VIEW": "عرض قائمة",
|
||||
"NO_EPISODES_AVAILABLE": "لا توجد حلقات متاحة",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "هذا المسلسل مدرج لدى مزودك، لكن لا توجد مواسم أو حلقات متاحة للتشغيل.",
|
||||
"SEASON_EMPTY": "هذا الموسم فارغ",
|
||||
"SEASON_EMPTY_MESSAGE": "هذا الموسم مدرج لدى مزودك، لكن لا توجد حلقات قابلة للتشغيل متاحة.",
|
||||
"EPISODE_INFO": "تفاصيل الحلقة",
|
||||
"SELECT_CATEGORY": "اختر فئة",
|
||||
"PAGE": "الصفحة",
|
||||
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "إلغاء",
|
||||
"CLEAR": "مسح",
|
||||
"CLOSE": "سد",
|
||||
"BACK": "رجع",
|
||||
"SHOW_MORE": "وري كثر",
|
||||
"SHOW_LESS": "وري قل",
|
||||
"PLAYLISTS": "قوائم التشغيل",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "رابط البث تنقل للحافظة",
|
||||
"COPY_STREAM_URL": "نسخ رابط البث",
|
||||
"NOW_PLAYING": "كيتشغل دابا",
|
||||
"CLOSE_PLAYER": "سد المشغل",
|
||||
"ABOUT": "حول",
|
||||
"SEASONS_AND_EPISODES": "المواسم والحلقات",
|
||||
"SEASON_TAB": "الموسم {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "رجوع للحلقة اللي كتشغل",
|
||||
"GRID_VIEW": "عرض الشبكة",
|
||||
"LIST_VIEW": "عرض اللائحة",
|
||||
"NO_EPISODES_AVAILABLE": "ما كاينش حلقات متوفرة",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "هاد السلسلة مذكورة عند المزود ديالك، ولكن ما كاينش مواسم ولا حلقات متوفرة للتشغيل.",
|
||||
"SEASON_EMPTY": "هاد الموسم خاوي",
|
||||
"SEASON_EMPTY_MESSAGE": "هاد الموسم مذكور عند المزود ديالك، ولكن ما كاينش حلقات متوفرة للتشغيل.",
|
||||
"EPISODE_INFO": "تفاصيل الحلقة",
|
||||
"SELECT_CATEGORY": "اختار فئة",
|
||||
"PAGE": "الصفحة",
|
||||
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Адмена",
|
||||
"CLEAR": "Ачысціць",
|
||||
"CLOSE": "Закрыць",
|
||||
"BACK": "Назад",
|
||||
"SHOW_MORE": "Болей",
|
||||
"SHOW_LESS": "Меней",
|
||||
"PLAYLISTS": "Плэйлісты",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL-адрас патоку быў скапіяваны ў буфер абмену.",
|
||||
"COPY_STREAM_URL": "Капіраваць URL-адрас патоку",
|
||||
"NOW_PLAYING": "Зараз прайграецца",
|
||||
"CLOSE_PLAYER": "Закрыць прайгравальнік",
|
||||
"ABOUT": "Пра фільм",
|
||||
"SEASONS_AND_EPISODES": "Сезоны і эпізоды",
|
||||
"SEASON_TAB": "Сезон {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Назад да бягучага эпізоду",
|
||||
"GRID_VIEW": "Выгляд сеткай",
|
||||
"LIST_VIEW": "Выгляд спісам",
|
||||
"NO_EPISODES_AVAILABLE": "Няма даступных эпізодаў",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Гэты серыял пазначаны вашым правайдэрам, але сезоны або эпізоды для прайгравання недаступныя.",
|
||||
"SEASON_EMPTY": "Гэты сезон пусты",
|
||||
"SEASON_EMPTY_MESSAGE": "Гэты сезон пазначаны вашым правайдэрам, але даступных для прайгравання эпізодаў няма.",
|
||||
"EPISODE_INFO": "Падрабязнасці эпізода",
|
||||
"SELECT_CATEGORY": "Выберыце катэгорыю",
|
||||
"PAGE": "Старонка",
|
||||
"CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Abbrechen",
|
||||
"CLEAR": "Leeren",
|
||||
"CLOSE": "Schließen",
|
||||
"BACK": "Zurück",
|
||||
"SHOW_MORE": "Mehr",
|
||||
"SHOW_LESS": "Weniger",
|
||||
"PLAYLISTS": "Playlisten",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "Stream-URL in die Zwischenablage kopiert",
|
||||
"COPY_STREAM_URL": "Stream-URL kopieren",
|
||||
"NOW_PLAYING": "Wird wiedergegeben",
|
||||
"CLOSE_PLAYER": "Player schließen",
|
||||
"ABOUT": "Info",
|
||||
"SEASONS_AND_EPISODES": "Staffeln und Folgen",
|
||||
"SEASON_TAB": "Staffel {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Zurück zur laufenden Folge",
|
||||
"GRID_VIEW": "Rasteransicht",
|
||||
"LIST_VIEW": "Listenansicht",
|
||||
"NO_EPISODES_AVAILABLE": "Keine Folgen verfügbar",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Diese Serie wird von Ihrem Anbieter gelistet, es sind jedoch keine Staffeln oder Folgen zum Abspielen verfügbar.",
|
||||
"SEASON_EMPTY": "Diese Staffel ist leer",
|
||||
"SEASON_EMPTY_MESSAGE": "Diese Staffel wird von Ihrem Anbieter gelistet, es sind jedoch keine abspielbaren Folgen verfügbar.",
|
||||
"EPISODE_INFO": "Details zur Folge",
|
||||
"SELECT_CATEGORY": "Wählen Sie eine Kategorie aus",
|
||||
"PAGE": "Seite",
|
||||
"CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Ακύρωση",
|
||||
"CLEAR": "Εκκαθάριση",
|
||||
"CLOSE": "Κλείσιμο",
|
||||
"BACK": "Πίσω",
|
||||
"SHOW_MORE": "Περισσότερα",
|
||||
"SHOW_LESS": "Λιγότερα",
|
||||
"PLAYLISTS": "Λίστα Αναπαραγωγής",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο",
|
||||
"COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής",
|
||||
"NOW_PLAYING": "Τώρα παίζει",
|
||||
"CLOSE_PLAYER": "Κλείσιμο προγράμματος αναπαραγωγής",
|
||||
"ABOUT": "Σχετικά",
|
||||
"SEASONS_AND_EPISODES": "Σεζόν και επεισόδια",
|
||||
"SEASON_TAB": "Σεζόν {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Πίσω στο επεισόδιο που παίζει",
|
||||
"GRID_VIEW": "Προβολή πλέγματος",
|
||||
"LIST_VIEW": "Προβολή λίστας",
|
||||
"NO_EPISODES_AVAILABLE": "Δεν υπάρχουν διαθέσιμα επεισόδια",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Αυτή η σειρά είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμες σεζόν ή επεισόδια για αναπαραγωγή.",
|
||||
"SEASON_EMPTY": "Αυτή η σεζόν είναι κενή",
|
||||
"SEASON_EMPTY_MESSAGE": "Αυτή η σεζόν είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμα επεισόδια για αναπαραγωγή.",
|
||||
"EPISODE_INFO": "Λεπτομέρειες επεισοδίου",
|
||||
"SELECT_CATEGORY": "Επιλέξτε μια κατηγορία",
|
||||
"PAGE": "Σελίδα",
|
||||
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Cancel",
|
||||
"CLEAR": "Clear",
|
||||
"CLOSE": "Close",
|
||||
"BACK": "Back",
|
||||
"SHOW_MORE": "More",
|
||||
"SHOW_LESS": "Less",
|
||||
"PLAYLISTS": "Playlists",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "Stream URL copied to clipboard",
|
||||
"COPY_STREAM_URL": "Copy Stream URL",
|
||||
"NOW_PLAYING": "Now playing",
|
||||
"CLOSE_PLAYER": "Close player",
|
||||
"ABOUT": "About",
|
||||
"SEASONS_AND_EPISODES": "Seasons and Episodes",
|
||||
"SEASON_TAB": "Season {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Back to playing episode",
|
||||
"GRID_VIEW": "Grid view",
|
||||
"LIST_VIEW": "List view",
|
||||
"NO_EPISODES_AVAILABLE": "No episodes available",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "This series is listed by your provider, but no seasons or episodes are available to play.",
|
||||
"SEASON_EMPTY": "This season is empty",
|
||||
"SEASON_EMPTY_MESSAGE": "This season is listed by your provider, but no playable episodes are available.",
|
||||
"EPISODE_INFO": "Episode details",
|
||||
"SELECT_CATEGORY": "Select a category",
|
||||
"PAGE": "Page",
|
||||
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Cancelar",
|
||||
"CLEAR": "Borrar",
|
||||
"CLOSE": "Cerrar",
|
||||
"BACK": "Atrás",
|
||||
"SHOW_MORE": "Más",
|
||||
"SHOW_LESS": "Menos",
|
||||
"PLAYLISTS": "Listas de reproducción",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL de transmisión copiada al portapapeles",
|
||||
"COPY_STREAM_URL": "Copiar URL de transmisión",
|
||||
"NOW_PLAYING": "Reproduciendo ahora",
|
||||
"CLOSE_PLAYER": "Cerrar reproductor",
|
||||
"ABOUT": "Acerca de",
|
||||
"SEASONS_AND_EPISODES": "Temporadas y episodios",
|
||||
"SEASON_TAB": "Temporada {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Volver al episodio en reproducción",
|
||||
"GRID_VIEW": "Vista de cuadrícula",
|
||||
"LIST_VIEW": "Vista de lista",
|
||||
"NO_EPISODES_AVAILABLE": "No hay episodios disponibles",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Tu proveedor incluye esta serie en el listado, pero no hay temporadas ni episodios disponibles para reproducir.",
|
||||
"SEASON_EMPTY": "Esta temporada está vacía",
|
||||
"SEASON_EMPTY_MESSAGE": "Tu proveedor incluye esta temporada en el listado, pero no hay episodios reproducibles disponibles.",
|
||||
"EPISODE_INFO": "Detalles del episodio",
|
||||
"SELECT_CATEGORY": "Selecciona una categoría",
|
||||
"PAGE": "Página",
|
||||
"CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Annuler",
|
||||
"CLEAR": "Effacer",
|
||||
"CLOSE": "Fermer",
|
||||
"BACK": "Retour",
|
||||
"SHOW_MORE": "Plus",
|
||||
"SHOW_LESS": "Moins",
|
||||
"PLAYLISTS": "Listes de lecture",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL du flux copiée dans le presse-papiers",
|
||||
"COPY_STREAM_URL": "Copier l'URL du flux",
|
||||
"NOW_PLAYING": "En cours de lecture",
|
||||
"CLOSE_PLAYER": "Fermer le lecteur",
|
||||
"ABOUT": "À propos",
|
||||
"SEASONS_AND_EPISODES": "Saisons et épisodes",
|
||||
"SEASON_TAB": "Saison {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Retour à l'épisode en cours",
|
||||
"GRID_VIEW": "Vue en grille",
|
||||
"LIST_VIEW": "Vue en liste",
|
||||
"NO_EPISODES_AVAILABLE": "Aucun épisode disponible",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Cette série est répertoriée par votre fournisseur, mais aucune saison ni épisode n'est disponible à la lecture.",
|
||||
"SEASON_EMPTY": "Cette saison est vide",
|
||||
"SEASON_EMPTY_MESSAGE": "Cette saison est répertoriée par votre fournisseur, mais aucun épisode lisible n'est disponible.",
|
||||
"EPISODE_INFO": "Détails de l'épisode",
|
||||
"SELECT_CATEGORY": "Sélectionner une catégorie",
|
||||
"PAGE": "Page",
|
||||
"CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Annulla",
|
||||
"CLEAR": "Cancella",
|
||||
"CLOSE": "Chiudi",
|
||||
"BACK": "Indietro",
|
||||
"SHOW_MORE": "Altro",
|
||||
"SHOW_LESS": "Meno",
|
||||
"PLAYLISTS": "Playlist",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL streaming copiato negli appunti",
|
||||
"COPY_STREAM_URL": "Copia URL streaming",
|
||||
"NOW_PLAYING": "In riproduzione",
|
||||
"CLOSE_PLAYER": "Chiudi lettore",
|
||||
"ABOUT": "Info",
|
||||
"SEASONS_AND_EPISODES": "Stagioni ed episodi",
|
||||
"SEASON_TAB": "Stagione {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Torna all'episodio in riproduzione",
|
||||
"GRID_VIEW": "Vista griglia",
|
||||
"LIST_VIEW": "Vista elenco",
|
||||
"NO_EPISODES_AVAILABLE": "Nessun episodio disponibile",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Questa serie è elencata dal tuo fornitore, ma non sono disponibili stagioni o episodi da riprodurre.",
|
||||
"SEASON_EMPTY": "Questa stagione è vuota",
|
||||
"SEASON_EMPTY_MESSAGE": "Questa stagione è elencata dal tuo fornitore, ma non sono disponibili episodi riproducibili.",
|
||||
"EPISODE_INFO": "Dettagli episodio",
|
||||
"SELECT_CATEGORY": "Seleziona una categoria",
|
||||
"PAGE": "Pagina",
|
||||
"CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "キャンセル",
|
||||
"CLEAR": "クリア",
|
||||
"CLOSE": "閉じる",
|
||||
"BACK": "戻る",
|
||||
"SHOW_MORE": "もっと見る",
|
||||
"SHOW_LESS": "折りたたむ",
|
||||
"PLAYLISTS": "プレイリスト",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました",
|
||||
"COPY_STREAM_URL": "ストリームURLをコピー",
|
||||
"NOW_PLAYING": "再生中",
|
||||
"CLOSE_PLAYER": "プレーヤーを閉じる",
|
||||
"ABOUT": "概要",
|
||||
"SEASONS_AND_EPISODES": "シーズンとエピソード",
|
||||
"SEASON_TAB": "シーズン{{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "再生中のエピソードに戻る",
|
||||
"GRID_VIEW": "グリッド表示",
|
||||
"LIST_VIEW": "リスト表示",
|
||||
"NO_EPISODES_AVAILABLE": "利用可能なエピソードはありません",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "このシリーズはプロバイダーに登録されていますが、再生可能なシーズンやエピソードはありません。",
|
||||
"SEASON_EMPTY": "このシーズンにはエピソードがありません",
|
||||
"SEASON_EMPTY_MESSAGE": "このシーズンはプロバイダーに登録されていますが、再生可能なエピソードはありません。",
|
||||
"EPISODE_INFO": "エピソードの詳細",
|
||||
"SELECT_CATEGORY": "カテゴリーを選択",
|
||||
"PAGE": "ページ",
|
||||
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "취소",
|
||||
"CLEAR": "지우기",
|
||||
"CLOSE": "닫기",
|
||||
"BACK": "뒤로",
|
||||
"SHOW_MORE": "더 보기",
|
||||
"SHOW_LESS": "간략히 보기",
|
||||
"PLAYLISTS": "재생목록",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "스트림 URL이 클립보드에 복사되었습니다.",
|
||||
"COPY_STREAM_URL": "스트림 URL 복사",
|
||||
"NOW_PLAYING": "지금 재생 중",
|
||||
"CLOSE_PLAYER": "플레이어 닫기",
|
||||
"ABOUT": "정보",
|
||||
"SEASONS_AND_EPISODES": "시즌 및 에피소드",
|
||||
"SEASON_TAB": "시즌 {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "재생 중인 에피소드로 돌아가기",
|
||||
"GRID_VIEW": "그리드 보기",
|
||||
"LIST_VIEW": "목록 보기",
|
||||
"NO_EPISODES_AVAILABLE": "사용 가능한 에피소드가 없습니다",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "이 시리즈는 공급자 목록에 있지만 재생 가능한 시즌이나 에피소드가 없습니다.",
|
||||
"SEASON_EMPTY": "이 시즌은 비어 있습니다",
|
||||
"SEASON_EMPTY_MESSAGE": "이 시즌은 공급자 목록에 있지만 재생 가능한 에피소드가 없습니다.",
|
||||
"EPISODE_INFO": "에피소드 정보",
|
||||
"SELECT_CATEGORY": "카테고리를 선택하세요",
|
||||
"PAGE": "페이지",
|
||||
"CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Annuleren",
|
||||
"CLEAR": "Wissen",
|
||||
"CLOSE": "Afsluiten",
|
||||
"BACK": "Terug",
|
||||
"SHOW_MORE": "Meer",
|
||||
"SHOW_LESS": "Minder",
|
||||
"PLAYLISTS": "Afspeellijst",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "Stream URL Gekopieërd",
|
||||
"COPY_STREAM_URL": "Kopieër Stream URL",
|
||||
"NOW_PLAYING": "Nu bezig",
|
||||
"CLOSE_PLAYER": "Speler sluiten",
|
||||
"ABOUT": "Over",
|
||||
"SEASONS_AND_EPISODES": "Seizoenen en afleveringen",
|
||||
"SEASON_TAB": "Seizoen {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Terug naar lopende aflevering",
|
||||
"GRID_VIEW": "Rasterweergave",
|
||||
"LIST_VIEW": "Lijstweergave",
|
||||
"NO_EPISODES_AVAILABLE": "Geen afleveringen beschikbaar",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Deze serie staat in de lijst van je provider, maar er zijn geen seizoenen of afleveringen beschikbaar om af te spelen.",
|
||||
"SEASON_EMPTY": "Dit seizoen is leeg",
|
||||
"SEASON_EMPTY_MESSAGE": "Dit seizoen staat in de lijst van je provider, maar er zijn geen afspeelbare afleveringen beschikbaar.",
|
||||
"EPISODE_INFO": "Afleveringsdetails",
|
||||
"SELECT_CATEGORY": "Selecteer een categorie",
|
||||
"PAGE": "Pagina",
|
||||
"CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Anuluj",
|
||||
"CLEAR": "Wyczyść",
|
||||
"CLOSE": "Zamknij",
|
||||
"BACK": "Wstecz",
|
||||
"SHOW_MORE": "Więcej",
|
||||
"SHOW_LESS": "Mniej",
|
||||
"PLAYLISTS": "Listy odtwarzania",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL strumienia skopiowany do schowka",
|
||||
"COPY_STREAM_URL": "Kopiuj URL strumienia",
|
||||
"NOW_PLAYING": "Teraz odtwarzane",
|
||||
"CLOSE_PLAYER": "Zamknij odtwarzacz",
|
||||
"ABOUT": "Informacje",
|
||||
"SEASONS_AND_EPISODES": "Sezony i odcinki",
|
||||
"SEASON_TAB": "Sezon {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Wróć do odtwarzanego odcinka",
|
||||
"GRID_VIEW": "Widok siatki",
|
||||
"LIST_VIEW": "Widok listy",
|
||||
"NO_EPISODES_AVAILABLE": "Brak dostępnych odcinków",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Ten serial jest dostępny u Twojego dostawcy, ale nie ma sezonów ani odcinków do odtworzenia.",
|
||||
"SEASON_EMPTY": "Ten sezon jest pusty",
|
||||
"SEASON_EMPTY_MESSAGE": "Ten sezon jest dostępny u Twojego dostawcy, ale nie ma odcinków do odtworzenia.",
|
||||
"EPISODE_INFO": "Szczegóły odcinka",
|
||||
"SELECT_CATEGORY": "Wybierz kategorię",
|
||||
"PAGE": "Strona",
|
||||
"CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Cancelar",
|
||||
"CLEAR": "Limpar",
|
||||
"CLOSE": "Fechar",
|
||||
"BACK": "Voltar",
|
||||
"SHOW_MORE": "Mais",
|
||||
"SHOW_LESS": "Menos",
|
||||
"PLAYLISTS": "Playlists",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL do stream copiado para a área de transferência",
|
||||
"COPY_STREAM_URL": "Copiar URL do Stream",
|
||||
"NOW_PLAYING": "Reproduzindo agora",
|
||||
"CLOSE_PLAYER": "Fechar reprodutor",
|
||||
"ABOUT": "Sobre",
|
||||
"SEASONS_AND_EPISODES": "Temporadas e episódios",
|
||||
"SEASON_TAB": "Temporada {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Voltar ao episódio em reprodução",
|
||||
"GRID_VIEW": "Visualização em grade",
|
||||
"LIST_VIEW": "Visualização em lista",
|
||||
"NO_EPISODES_AVAILABLE": "Nenhum episódio disponível",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Esta série está listada pelo seu provedor, mas não há temporadas ou episódios disponíveis para reprodução.",
|
||||
"SEASON_EMPTY": "Esta temporada está vazia",
|
||||
"SEASON_EMPTY_MESSAGE": "Esta temporada está listada pelo seu provedor, mas não há episódios reproduzíveis disponíveis.",
|
||||
"EPISODE_INFO": "Detalhes do episódio",
|
||||
"SELECT_CATEGORY": "Selecione uma categoria",
|
||||
"PAGE": "Página",
|
||||
"CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "Отмена",
|
||||
"CLEAR": "Очистить",
|
||||
"CLOSE": "Закрыть",
|
||||
"BACK": "Назад",
|
||||
"SHOW_MORE": "Больше",
|
||||
"SHOW_LESS": "Меньше",
|
||||
"PLAYLISTS": "Плейлисты",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "URL-адрес потока был скопирован в буфер обмена.",
|
||||
"COPY_STREAM_URL": "Копировать URL-адрес потока",
|
||||
"NOW_PLAYING": "Сейчас воспроизводится",
|
||||
"CLOSE_PLAYER": "Закрыть плеер",
|
||||
"ABOUT": "Об этом",
|
||||
"SEASONS_AND_EPISODES": "Сезоны и эпизоды",
|
||||
"SEASON_TAB": "Сезон {{number}}",
|
||||
"BACK_TO_PLAYING_EPISODE": "Вернуться к воспроизводимому эпизоду",
|
||||
"GRID_VIEW": "Вид сеткой",
|
||||
"LIST_VIEW": "Вид списком",
|
||||
"NO_EPISODES_AVAILABLE": "Нет доступных эпизодов",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Этот сериал есть в списке вашего провайдера, но доступных для воспроизведения сезонов или эпизодов нет.",
|
||||
"SEASON_EMPTY": "Этот сезон пуст",
|
||||
"SEASON_EMPTY_MESSAGE": "Этот сезон есть в списке вашего провайдера, но доступных для воспроизведения эпизодов нет.",
|
||||
"EPISODE_INFO": "Детали эпизода",
|
||||
"SELECT_CATEGORY": "Выберите категорию",
|
||||
"PAGE": "Страница",
|
||||
"CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "İptal",
|
||||
"CLEAR": "Temizle",
|
||||
"CLOSE": "Kapat",
|
||||
"BACK": "Geri",
|
||||
"SHOW_MORE": "Daha fazla",
|
||||
"SHOW_LESS": "Daha az",
|
||||
"PLAYLISTS": "Oynatma Listeleri",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "Yayın URL'si panoya kopyalandı",
|
||||
"COPY_STREAM_URL": "Yayın URL'sini kopyala",
|
||||
"NOW_PLAYING": "Şimdi oynatılıyor",
|
||||
"CLOSE_PLAYER": "Oynatıcıyı kapat",
|
||||
"ABOUT": "Hakkında",
|
||||
"SEASONS_AND_EPISODES": "Sezonlar ve Bölümler",
|
||||
"SEASON_TAB": "{{number}}. Sezon",
|
||||
"BACK_TO_PLAYING_EPISODE": "Oynatılan bölüme dön",
|
||||
"GRID_VIEW": "Izgara görünümü",
|
||||
"LIST_VIEW": "Liste görünümü",
|
||||
"NO_EPISODES_AVAILABLE": "Bölüm mevcut değil",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Bu dizi sağlayıcınız tarafından listeleniyor, ancak oynatılabilecek sezon veya bölüm bulunmuyor.",
|
||||
"SEASON_EMPTY": "Bu sezon boş",
|
||||
"SEASON_EMPTY_MESSAGE": "Bu sezon sağlayıcınız tarafından listeleniyor, ancak oynatılabilir bölüm bulunmuyor.",
|
||||
"EPISODE_INFO": "Bölüm ayrıntıları",
|
||||
"SELECT_CATEGORY": "Bir kategori seçin",
|
||||
"PAGE": "Sayfa",
|
||||
"CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "取消",
|
||||
"CLEAR": "清除",
|
||||
"CLOSE": "关闭",
|
||||
"BACK": "返回",
|
||||
"SHOW_MORE": "更多",
|
||||
"SHOW_LESS": "收起",
|
||||
"PLAYLISTS": "播放列表",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "流 URL 已复制到剪贴板",
|
||||
"COPY_STREAM_URL": "复制流 URL",
|
||||
"NOW_PLAYING": "正在播放",
|
||||
"CLOSE_PLAYER": "关闭播放器",
|
||||
"ABOUT": "简介",
|
||||
"SEASONS_AND_EPISODES": "季与集",
|
||||
"SEASON_TAB": "第 {{number}} 季",
|
||||
"BACK_TO_PLAYING_EPISODE": "返回正在播放的剧集",
|
||||
"GRID_VIEW": "网格视图",
|
||||
"LIST_VIEW": "列表视图",
|
||||
"NO_EPISODES_AVAILABLE": "没有可用的剧集",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "您的提供商列出了此剧集,但没有可播放的季或集。",
|
||||
"SEASON_EMPTY": "本季暂无内容",
|
||||
"SEASON_EMPTY_MESSAGE": "您的提供商列出了本季,但没有可播放的剧集。",
|
||||
"EPISODE_INFO": "剧集详情",
|
||||
"SELECT_CATEGORY": "选择一个分类",
|
||||
"PAGE": "页",
|
||||
"CONTENT_NOT_AVAILABLE": "此服务器上没有此内容",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"CANCEL": "取消",
|
||||
"CLEAR": "清除",
|
||||
"CLOSE": "關閉",
|
||||
"BACK": "返回",
|
||||
"SHOW_MORE": "更多",
|
||||
"SHOW_LESS": "收起",
|
||||
"PLAYLISTS": "播放清單",
|
||||
@@ -918,6 +919,19 @@
|
||||
},
|
||||
"STREAM_URL_COPIED": "串流網址已複製到剪貼簿",
|
||||
"COPY_STREAM_URL": "複製串流網址",
|
||||
"NOW_PLAYING": "現正播放",
|
||||
"CLOSE_PLAYER": "關閉播放器",
|
||||
"ABOUT": "簡介",
|
||||
"SEASONS_AND_EPISODES": "季數與集數",
|
||||
"SEASON_TAB": "第 {{number}} 季",
|
||||
"BACK_TO_PLAYING_EPISODE": "返回正在播放的集數",
|
||||
"GRID_VIEW": "格狀檢視",
|
||||
"LIST_VIEW": "清單檢視",
|
||||
"NO_EPISODES_AVAILABLE": "沒有可用的集數",
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "此供應商列出了這部影集,但目前沒有可播放的季數或集數。",
|
||||
"SEASON_EMPTY": "此季暫無內容",
|
||||
"SEASON_EMPTY_MESSAGE": "此供應商列出了這一季,但目前沒有可播放的集數。",
|
||||
"EPISODE_INFO": "集數詳情",
|
||||
"SELECT_CATEGORY": "選擇類別",
|
||||
"PAGE": "頁",
|
||||
"CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容",
|
||||
|
||||
@@ -82,8 +82,63 @@ Current limitation:
|
||||
- because of that, the setting is auto-sanitized back to the default inline player unless support detection reports that the experimental runtime is available
|
||||
- this follows the rollout gate: keep the native work in-tree, but do not leave it user-facing until playback, resize, focus, and packaging are stable
|
||||
|
||||
## Two-State Detail Layout (Browse ↔ Watch)
|
||||
|
||||
Portal VOD/series detail pages are hosted by the shared
|
||||
`PortalDetailShellComponent`
|
||||
(`libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`),
|
||||
which owns the page scroll container and two layout states:
|
||||
|
||||
- **Browse** (`playbackActive=false`): the hero (`ContentHeroComponent`,
|
||||
now a natural-height, non-scrolling block) renders poster, title, chips,
|
||||
description, credits, and actions. Episodes render below.
|
||||
- **Watch** (`playbackActive=true`, bound by hosts to
|
||||
`inlinePlayback() !== null`): the hero collapses (~300ms CSS morph,
|
||||
disabled under `prefers-reduced-motion`), the host-projected
|
||||
`[detail-player]` slot renders the inline player at full content width,
|
||||
and the shell renders an About block (`ContentAboutComponent`) below the
|
||||
`[detail-episodes]` slot so the hero metadata stays reachable.
|
||||
|
||||
Contracts:
|
||||
|
||||
- Hosts provide hero chips/meta/actions as `*appDetailTags` / `*appDetailMeta` /
|
||||
`*appDetailActions` templates; the shell stamps them into the hero and again
|
||||
into the About block. Degradation stays "missing → not rendered".
|
||||
- The shell never wraps the `[detail-player]` slot in a conditional; the
|
||||
host's `@if (inlinePlayback())` is the only creator/destroyer of the
|
||||
player subtree, so shell state changes cannot recreate the `<video>`.
|
||||
- **External MPV/VLC sessions do not flip the layout to watch** — browse
|
||||
layout stays, and the primary CTA keeps its "Stop <player>" behavior.
|
||||
- Escape closes inline playback: the shell emits `closePlayerRequested`
|
||||
when playback is active, the event was not `defaultPrevented`, and no
|
||||
element is in browser fullscreen; hosts wire it to `closeInlinePlayer()`.
|
||||
- The now-playing bar separates two exits: the back arrow emits
|
||||
`backClicked`, which hosts wire to their route-level `goBack()` (straight
|
||||
back to the list — everything browse offers is also visible in watch, so
|
||||
a two-step unwind would be ceremony); the "Close player" button and
|
||||
Escape emit `closed`/`closePlayerRequested` and return to browse without
|
||||
navigating.
|
||||
- Entering watch scrolls the shell to the top; leaving keeps the scroll
|
||||
position.
|
||||
|
||||
Season navigation inside `SeasonContainerComponent` uses season tabs
|
||||
(`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old
|
||||
seasons-grid + "Back to seasons" level. A season is auto-selected
|
||||
(inline-playing episode's season → most recently updated in-progress
|
||||
episode's season → first) and the auto-selection emits `seasonSelected`,
|
||||
so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch,
|
||||
TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open
|
||||
without a click. Switching tabs never stops playback; a "back to playing
|
||||
episode" chip appears when the playing episode is outside the opened
|
||||
season. Season descriptions come from `get_series_info` seasons (Xtream)
|
||||
or `TmdbEnrichmentService.getSeason` (Stalker).
|
||||
|
||||
## Components
|
||||
|
||||
Shared detail layout shell:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`
|
||||
|
||||
Shared inline player shell:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts`
|
||||
|
||||
@@ -10,6 +10,11 @@ Related:
|
||||
|
||||
- Xtream category browsing uses a route-first detail model.
|
||||
- Stalker uses an inline/store-state detail model.
|
||||
- Detail pages themselves are two-state (browse ↔ watch) inside
|
||||
`PortalDetailShellComponent`; entering/leaving watch is a layout state,
|
||||
not a navigation. Route-level back semantics are unchanged; the
|
||||
watch-state back button only closes the inline player. See
|
||||
[Embedded Inline Playback](./embedded-inline-playback.md).
|
||||
- Favorites and recently viewed collections now use collection-owned inline detail
|
||||
for non-live Xtream and Stalker items.
|
||||
- Provider-scoped collection routes fall back to the matching global collection
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
||||
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
buildStalkerStateItem,
|
||||
createLogger,
|
||||
@@ -67,7 +67,7 @@ interface StalkerCollectionDetailMode {
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-collection-detail',
|
||||
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
|
||||
imports: [PortalDetailShellComponent, StalkerInlineDetailComponent],
|
||||
template: `
|
||||
@if (inlineDetail().categoryId) {
|
||||
<app-stalker-inline-detail
|
||||
@@ -91,7 +91,7 @@ interface StalkerCollectionDetailMode {
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-content-hero [isLoading]="true" />
|
||||
<app-portal-detail-shell [isLoading]="true" />
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
+42
-8
@@ -7,8 +7,9 @@ import {
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Component-scoped holder for lazily fetched TMDB episode data in the
|
||||
* Stalker series view (provide it in the component's `providers`).
|
||||
* Component-scoped holder for lazily fetched TMDB season data (episode
|
||||
* lists and season overviews) in the Stalker series view (provide it in
|
||||
* the component's `providers`).
|
||||
*
|
||||
* Entries are keyed by `${tmdbId}|${seasonKey}` so data from a previously
|
||||
* shown series can never leak into the current one.
|
||||
@@ -20,6 +21,9 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
private readonly episodesByKey = signal<
|
||||
ReadonlyMap<string, TmdbEpisode[]>
|
||||
>(new Map());
|
||||
private readonly overviewsByKey = signal<ReadonlyMap<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
/**
|
||||
* Overlays fetched TMDB episode data (real names, overviews, stills)
|
||||
@@ -46,9 +50,29 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily pulls the TMDB episode list for an opened season; a no-op
|
||||
* without a show-level TMDB match, with enrichment disabled, or when
|
||||
* the season was already fetched.
|
||||
* Season overviews for the season tabs, keyed by season key of the
|
||||
* given show. Reads a signal, so callers can use it in a `computed`.
|
||||
*/
|
||||
descriptions(tmdbId: number | null | undefined): Record<string, string> {
|
||||
const overviews = this.overviewsByKey();
|
||||
if (!tmdbId || overviews.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const prefix = `${tmdbId}|`;
|
||||
const descriptions: Record<string, string> = {};
|
||||
for (const [mapKey, overview] of overviews) {
|
||||
if (mapKey.startsWith(prefix)) {
|
||||
descriptions[mapKey.slice(prefix.length)] = overview;
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily pulls the TMDB season (episode list + overview) for an opened
|
||||
* season; a no-op without a show-level TMDB match, with enrichment
|
||||
* disabled, or when the season was already fetched.
|
||||
*/
|
||||
async fetchSeason(
|
||||
tmdbId: number | null | undefined,
|
||||
@@ -69,16 +93,26 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmdbEpisodes = await this.tmdbEnrichment.getSeasonEpisodes(
|
||||
const season = await this.tmdbEnrichment.getSeason(
|
||||
tmdbId,
|
||||
seasonNumber
|
||||
);
|
||||
if (!tmdbEpisodes?.length) {
|
||||
if (!season) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (season.overview) {
|
||||
const overviews = new Map(this.overviewsByKey());
|
||||
overviews.set(mapKey, season.overview);
|
||||
this.overviewsByKey.set(overviews);
|
||||
}
|
||||
|
||||
if (!season.episodes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map(this.episodesByKey());
|
||||
next.set(mapKey, tmdbEpisodes);
|
||||
next.set(mapKey, season.episodes);
|
||||
this.episodesByKey.set(next);
|
||||
}
|
||||
}
|
||||
+34
-23
@@ -1,13 +1,15 @@
|
||||
@if (displayItem(); as serial) {
|
||||
<app-content-hero
|
||||
<app-portal-detail-shell
|
||||
[title]="serial.info.name"
|
||||
[description]="serial.info.description"
|
||||
[posterUrl]="serial.info.movie_image"
|
||||
[backdropUrl]="serial.info.tmdb_backdrop"
|
||||
[playbackActive]="!!inlinePlayback()"
|
||||
(backClicked)="goBack()"
|
||||
(closePlayerRequested)="closeInlinePlayer()"
|
||||
>
|
||||
<!-- Tags: Year, Genre, Rating -->
|
||||
<ng-container hero-tags>
|
||||
<ng-template appDetailTags>
|
||||
@if (serial.info.releasedate) {
|
||||
<span class="details__tag">{{ serial.info.releasedate }}</span>
|
||||
}
|
||||
@@ -24,10 +26,10 @@
|
||||
KP {{ serial.info.rating_kinopoisk }}
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
<ng-template appDetailMeta>
|
||||
@if (serial.info.actors || serial.info.tmdb_cast?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
@@ -84,10 +86,10 @@
|
||||
<span class="value">{{ serial.info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<ng-template appDetailActions>
|
||||
@if (quickStartAction(); as action) {
|
||||
<button
|
||||
class="play-btn"
|
||||
@@ -113,15 +115,17 @@
|
||||
</button>
|
||||
}
|
||||
<app-favorites-button [itemId]="serial.id" [item]="serial" />
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(playbackEnded)="handleInlinePlaybackEnded()"
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
@@ -135,6 +139,7 @@
|
||||
<!-- Seasons & Episodes -->
|
||||
@if (mappedSeasons()) {
|
||||
<app-season-container
|
||||
detail-episodes
|
||||
[seasons]="mappedSeasons()"
|
||||
[seriesId]="toSeriesId(serial.id)"
|
||||
[playlistId]="stalkerStore.currentPlaylist()?._id ?? ''"
|
||||
@@ -142,9 +147,13 @@
|
||||
[playbackPositions]="episodePlaybackPositions()"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
[playingEpisodeId]="
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
[seasonDescriptions]="seasonDescriptions()"
|
||||
[isLoading]="
|
||||
isVodSeriesSeasonsLoading() ||
|
||||
isCurrentSeasonLoading(seasonContainer?.selectedSeason)
|
||||
isCurrentSeasonLoading(seasonContainer?.selectedSeason())
|
||||
"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
@@ -156,20 +165,22 @@
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- YouTube Trailer (TMDB enrichment; Stalker provides none) -->
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="trailerUrl | safe"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<div detail-extras>
|
||||
<!-- YouTube Trailer (TMDB enrichment; Stalker provides none) -->
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="trailerUrl | safe"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</app-content-hero>
|
||||
}
|
||||
</div>
|
||||
</app-portal-detail-shell>
|
||||
}
|
||||
+57
-22
@@ -5,7 +5,7 @@ import { Router } from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { ContentHeroComponent, SeasonContainerComponent } from '@iptvnator/ui/components';
|
||||
import { SeasonContainerComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
@@ -17,24 +17,11 @@ import {
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { of } from 'rxjs';
|
||||
import { DownloadsService, TmdbEnrichmentService } from '@iptvnator/services';
|
||||
import { EMPTY, of } from 'rxjs';
|
||||
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
|
||||
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-hero',
|
||||
standalone: true,
|
||||
template: '<ng-content />',
|
||||
})
|
||||
class StubContentHeroComponent {
|
||||
readonly title = input<string | undefined>(undefined);
|
||||
readonly description = input<string | undefined>(undefined);
|
||||
readonly posterUrl = input<string | undefined>(undefined);
|
||||
readonly backdropUrl = input<string | undefined>(undefined);
|
||||
readonly backClicked = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
standalone: true,
|
||||
@@ -48,12 +35,14 @@ class StubSeasonContainerComponent {
|
||||
readonly playbackPositions = input<unknown>(null);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly isLoading = input(false);
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly episodeClicked = output<unknown>();
|
||||
readonly episodeDownloadRequested = output<unknown>();
|
||||
readonly playbackToggleRequested = output<unknown>();
|
||||
selectedSeason: string | undefined;
|
||||
readonly selectedSeason = signal<string | undefined>(undefined);
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -95,6 +84,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||
const openResolvedPlayback = jest.fn();
|
||||
const isEmbeddedPlayer = jest.fn();
|
||||
const tmdbGetSeason = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedContentType.set('series');
|
||||
@@ -147,6 +137,11 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
openResolvedPlayback.mockClear();
|
||||
isEmbeddedPlayer.mockReset();
|
||||
isEmbeddedPlayer.mockReturnValue(false);
|
||||
tmdbGetSeason.mockReset();
|
||||
tmdbGetSeason.mockResolvedValue({
|
||||
overview: 'Season overview from TMDB',
|
||||
episodes: [],
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [StalkerSeriesViewComponent],
|
||||
@@ -201,6 +196,14 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
startDownload: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: {
|
||||
isEnabled: () => true,
|
||||
getSeason: tmdbGetSeason,
|
||||
getSeasonEpisodes: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
@@ -213,9 +216,11 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
// EMPTY (not of(null)): the real TranslatePipe inside
|
||||
// the detail shell reads `event.lang` from emissions.
|
||||
onLangChange: EMPTY,
|
||||
onTranslationChange: EMPTY,
|
||||
onDefaultLangChange: EMPTY,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -223,7 +228,6 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
.overrideComponent(StalkerSeriesViewComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
FavoritesButtonComponent,
|
||||
PortalInlinePlayerComponent,
|
||||
SeasonContainerComponent,
|
||||
@@ -232,7 +236,6 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubContentHeroComponent,
|
||||
StubFavoritesButtonComponent,
|
||||
StubPortalInlinePlayerComponent,
|
||||
StubSeasonContainerComponent,
|
||||
@@ -692,4 +695,36 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches the TMDB season once the show-level match arrives after auto-select', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Season tabs auto-select immediately — usually before the async
|
||||
// show-level enrichment has written tmdb_id.
|
||||
const seasonContainer = fixture.debugElement.query(
|
||||
By.directive(StubSeasonContainerComponent)
|
||||
).componentInstance as StubSeasonContainerComponent;
|
||||
seasonContainer.seasonSelected.emit('1');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(tmdbGetSeason).not.toHaveBeenCalled();
|
||||
|
||||
// The TMDB match lands afterwards — the fetch must run now.
|
||||
selectedItem.set({
|
||||
id: '30001',
|
||||
cmd: '/media/file_30001.mpg',
|
||||
info: {
|
||||
name: 'Regular Series',
|
||||
description: 'Series description',
|
||||
movie_image: 'poster.jpg',
|
||||
tmdb_id: 777,
|
||||
},
|
||||
} as never);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1);
|
||||
});
|
||||
});
|
||||
+38
-7
@@ -7,6 +7,7 @@ import {
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
@@ -14,7 +15,10 @@ import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
|
||||
import {
|
||||
ContentHeroComponent,
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
SeasonContainerComponent,
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
} from '@iptvnator/ui/components';
|
||||
@@ -80,7 +84,10 @@ import {
|
||||
styleUrls: ['../styles/detail-view.scss'],
|
||||
imports: [
|
||||
FavoritesButtonComponent,
|
||||
ContentHeroComponent,
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
PortalInlinePlayerComponent,
|
||||
SafePipe,
|
||||
TranslatePipe,
|
||||
@@ -121,6 +128,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
readonly selectedItem = this.stalkerStore.selectedItem;
|
||||
|
||||
private readonly tmdbSeasons = inject(StalkerSeriesTmdbSeasonsService);
|
||||
/** Season currently selected in the season container. */
|
||||
private readonly selectedSeasonKey = signal<string | null>(null);
|
||||
|
||||
/** Season descriptions for the season tabs (TMDB overview per season). */
|
||||
readonly seasonDescriptions = computed<Record<string, string>>(() =>
|
||||
this.tmdbSeasons.descriptions(this.displayItem()?.info?.tmdb_id)
|
||||
);
|
||||
|
||||
/**
|
||||
* Track VOD series seasons with their loaded episodes
|
||||
@@ -150,6 +164,25 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
|
||||
|
||||
constructor() {
|
||||
// TMDB season fetch, keyed on (tmdb_id, selected season). With season
|
||||
// tabs the first seasonSelected fires immediately when seasons load —
|
||||
// usually BEFORE the async show-level TMDB enrichment has written
|
||||
// tmdb_id — so the fetch must re-run when the match arrives, not only
|
||||
// on selection. fetchSeason is idempotent per (tmdbId, season).
|
||||
effect(() => {
|
||||
const tmdbId = this.displayItem()?.info?.tmdb_id;
|
||||
const seasonKey = this.selectedSeasonKey();
|
||||
if (tmdbId && seasonKey) {
|
||||
untracked(() =>
|
||||
void this.tmdbSeasons.fetchSeason(
|
||||
tmdbId,
|
||||
seasonKey,
|
||||
this.mappedSeasons()[seasonKey]
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to load VOD series seasons when a VOD series item is selected
|
||||
effect(() => {
|
||||
if (this.isVodSeries()) {
|
||||
@@ -320,11 +353,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
* For VOD Series, triggers lazy loading of episodes.
|
||||
*/
|
||||
onSeasonSelected(seasonKey: string) {
|
||||
void this.tmdbSeasons.fetchSeason(
|
||||
this.displayItem()?.info?.tmdb_id,
|
||||
seasonKey,
|
||||
this.mappedSeasons()[seasonKey]
|
||||
);
|
||||
// The TMDB fetch itself runs from the constructor effect keyed on
|
||||
// (tmdb_id, selectedSeasonKey) — see the race note there.
|
||||
this.selectedSeasonKey.set(seasonKey);
|
||||
|
||||
if (!this.isVodSeries()) return;
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
@let item = selectedItem();
|
||||
@if (item && item.info) {
|
||||
@let info = item.info;
|
||||
<app-content-hero
|
||||
<app-portal-detail-shell
|
||||
[title]="info.name"
|
||||
[description]="info.plot"
|
||||
[posterUrl]="info.cover"
|
||||
[backdropUrl]="getBackdropUrl(info)"
|
||||
[playbackActive]="!!inlinePlayback()"
|
||||
(backClicked)="goBack()"
|
||||
(closePlayerRequested)="closeInlinePlayer()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
<ng-template appDetailTags>
|
||||
@if (info.releaseDate) {
|
||||
<span class="details__tag">{{
|
||||
info.releaseDate | slice: 0 : 4
|
||||
@@ -27,9 +29,9 @@
|
||||
⭐ {{ info.rating }}
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-container hero-meta>
|
||||
<ng-template appDetailMeta>
|
||||
@if (info.cast || info.tmdb_cast?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
|
||||
@@ -83,9 +85,9 @@
|
||||
<span class="value">{{ info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-container hero-actions>
|
||||
<ng-template appDetailActions>
|
||||
@if (quickStartAction(); as action) {
|
||||
<button
|
||||
class="play-btn"
|
||||
@@ -122,15 +124,17 @@
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(playbackEnded)="handleInlinePlaybackEnded()"
|
||||
(previousEpisodeRequested)="playPreviousEpisode()"
|
||||
@@ -142,6 +146,7 @@
|
||||
}
|
||||
|
||||
<app-season-container
|
||||
detail-episodes
|
||||
[seasons]="item.episodes"
|
||||
[seriesId]="item.series_id"
|
||||
[playlistId]="currentPlaylistId()"
|
||||
@@ -150,50 +155,56 @@
|
||||
[xtreamDownloadContext]="xtreamDownloadContext()"
|
||||
[openingEpisodeId]="openingEpisodeId()"
|
||||
[activeEpisodeId]="activeEpisodeId()"
|
||||
[playingEpisodeId]="
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
[seasonDescriptions]="seasonDescriptions()"
|
||||
(episodeClicked)="playEpisode($event)"
|
||||
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
/>
|
||||
|
||||
@if (similarItems().length) {
|
||||
<section class="details__similar" data-test-id="tmdb-similar">
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@for (similar of similarItems(); track similar.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="similar.title"
|
||||
(click)="openSimilar(similar)"
|
||||
>
|
||||
@if (similar.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="similar.posterUrl"
|
||||
[alt]="similar.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ similar.title.charAt(0) }}
|
||||
<div detail-extras>
|
||||
@if (similarItems().length) {
|
||||
<section class="details__similar" data-test-id="tmdb-similar">
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@for (similar of similarItems(); track similar.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="similar.title"
|
||||
(click)="openSimilar(similar)"
|
||||
>
|
||||
@if (similar.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="similar.posterUrl"
|
||||
[alt]="similar.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ similar.title.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ similar.title }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ similar.title }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</app-content-hero>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</app-portal-detail-shell>
|
||||
} @else if (detailsError()) {
|
||||
<app-content-hero
|
||||
<app-portal-detail-shell
|
||||
[errorMessage]="detailsError() ?? undefined"
|
||||
(backClicked)="goBack()"
|
||||
/>
|
||||
} @else {
|
||||
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
|
||||
<app-portal-detail-shell [isLoading]="true" (backClicked)="goBack()" />
|
||||
}
|
||||
@@ -21,21 +21,6 @@ import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-hero',
|
||||
standalone: true,
|
||||
template: '<ng-content />',
|
||||
})
|
||||
class StubContentHeroComponent {
|
||||
readonly title = input<string | undefined>(undefined);
|
||||
readonly description = input<string | undefined>(undefined);
|
||||
readonly posterUrl = input<string | undefined>(undefined);
|
||||
readonly backdropUrl = input<string | undefined>(undefined);
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
readonly backClicked = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
standalone: true,
|
||||
@@ -50,6 +35,8 @@ class StubSeasonContainerComponent {
|
||||
readonly xtreamDownloadContext = input<unknown>(null);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly episodeClicked = output<unknown>();
|
||||
readonly playbackToggleRequested = output<unknown>();
|
||||
}
|
||||
@@ -239,7 +226,6 @@ describe('SerialDetailsComponent', () => {
|
||||
.overrideComponent(SerialDetailsComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
MatIcon,
|
||||
PortalInlinePlayerComponent,
|
||||
SeasonContainerComponent,
|
||||
@@ -248,7 +234,6 @@ describe('SerialDetailsComponent', () => {
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubContentHeroComponent,
|
||||
StubMatIconComponent,
|
||||
StubPortalInlinePlayerComponent,
|
||||
StubSeasonContainerComponent,
|
||||
@@ -334,8 +319,8 @@ describe('SerialDetailsComponent', () => {
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
|
||||
const hero = fixture.debugElement.query(
|
||||
By.directive(StubContentHeroComponent)
|
||||
).componentInstance as StubContentHeroComponent;
|
||||
By.directive(ContentHeroComponent)
|
||||
).componentInstance as ContentHeroComponent;
|
||||
expect(hero.backdropUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -14,7 +15,10 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
ContentHeroComponent,
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
SeasonContainerComponent,
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerXtreamDownloadContext,
|
||||
@@ -57,8 +61,11 @@ import {
|
||||
],
|
||||
providers: [SerialDetailsPlaybackService],
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
MatIcon,
|
||||
PortalDetailShellComponent,
|
||||
PortalInlinePlayerComponent,
|
||||
SeasonContainerComponent,
|
||||
SlicePipe,
|
||||
@@ -103,6 +110,20 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata;
|
||||
readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation;
|
||||
|
||||
/** Season currently selected in the season container. */
|
||||
private readonly selectedSeasonKey = signal<string | null>(null);
|
||||
|
||||
/** Season overviews from get_series_info, keyed by season key. */
|
||||
readonly seasonDescriptions = computed<Record<string, string>>(() => {
|
||||
const descriptions: Record<string, string> = {};
|
||||
for (const season of this.selectedItem()?.seasons ?? []) {
|
||||
if (season?.overview && season.season_number !== undefined) {
|
||||
descriptions[String(season.season_number)] = season.overview;
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
});
|
||||
|
||||
/** TMDB recommendations matched against the loaded series catalog */
|
||||
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
|
||||
const item = this.selectedItem();
|
||||
@@ -120,6 +141,22 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
constructor() {
|
||||
this.playback.bind({ selectedItem: this.selectedItem });
|
||||
|
||||
// TMDB season enrichment, keyed on (tmdb_id, selected season). With
|
||||
// season tabs the first seasonSelected fires as soon as seasons load —
|
||||
// usually BEFORE the async show-level TMDB match has written
|
||||
// info.tmdb_id, and enrichSelectedSerialSeason no-ops without it. So
|
||||
// the call must re-run when the match arrives, not only on selection.
|
||||
// The store-side enrichment is idempotent per (serial, season).
|
||||
effect(() => {
|
||||
const tmdbId = this.selectedItem()?.info?.tmdb_id;
|
||||
const seasonKey = this.selectedSeasonKey();
|
||||
if (tmdbId && seasonKey) {
|
||||
untracked(() =>
|
||||
this.xtreamStore.enrichSelectedSerialSeason(seasonKey)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const item = this.xtreamStore.selectedItem() as unknown as
|
||||
| (XtreamSerieDetails & {
|
||||
@@ -229,7 +266,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onSeasonSelected(seasonKey: string): void {
|
||||
this.xtreamStore.enrichSelectedSerialSeason(seasonKey);
|
||||
// The enrichment call itself runs from the constructor effect keyed
|
||||
// on (tmdb_id, selectedSeasonKey) — see the race note there.
|
||||
this.selectedSeasonKey.set(seasonKey);
|
||||
}
|
||||
|
||||
playEpisode(episode: XtreamSerieEpisode): void {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
@let item = selectedItem();
|
||||
@if (selectedVodInfo(); as info) {
|
||||
<app-content-hero
|
||||
<app-portal-detail-shell
|
||||
[title]="info.name || item?.movie_data?.name"
|
||||
[description]="info.description || info.plot"
|
||||
[posterUrl]="info.movie_image || info.cover_big"
|
||||
[backdropUrl]="getBackdropUrl(info)"
|
||||
[playbackActive]="!!inlinePlayback()"
|
||||
(backClicked)="goBack()"
|
||||
(closePlayerRequested)="closeInlinePlayer()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
<ng-template appDetailTags>
|
||||
@if (info.releasedate) {
|
||||
<span class="details__tag">{{
|
||||
info.releasedate | slice: 0 : 4
|
||||
@@ -27,9 +29,9 @@
|
||||
@if (info.country) {
|
||||
<span class="details__tag">{{ info.country }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-container hero-meta>
|
||||
<ng-template appDetailMeta>
|
||||
@if (info.actors || info.cast || info.tmdb_cast?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
@@ -93,9 +95,9 @@
|
||||
<span class="value">{{ info.rating_kinopoisk }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-container hero-actions>
|
||||
<ng-template appDetailActions>
|
||||
@if (hasPlaybackPosition()) {
|
||||
<button
|
||||
class="play-btn play-btn--resume"
|
||||
@@ -195,13 +197,15 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playback]="playback"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
@@ -209,70 +213,72 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="trailerUrl | safe"
|
||||
title="YouTube video player"
|
||||
allow="
|
||||
accelerometer;
|
||||
autoplay;
|
||||
clipboard-write;
|
||||
encrypted-media;
|
||||
gyroscope;
|
||||
picture-in-picture;
|
||||
web-share;
|
||||
"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<div detail-extras>
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="trailerUrl | safe"
|
||||
title="YouTube video player"
|
||||
allow="
|
||||
accelerometer;
|
||||
autoplay;
|
||||
clipboard-write;
|
||||
encrypted-media;
|
||||
gyroscope;
|
||||
picture-in-picture;
|
||||
web-share;
|
||||
"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (similarItems().length) {
|
||||
<section class="details__similar" data-test-id="tmdb-similar">
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@for (item of similarItems(); track item.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="item.title"
|
||||
(click)="openSimilar(item)"
|
||||
>
|
||||
@if (item.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ item.title.charAt(0) }}
|
||||
@if (similarItems().length) {
|
||||
<section class="details__similar" data-test-id="tmdb-similar">
|
||||
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
|
||||
<div class="details__similar-strip">
|
||||
@for (item of similarItems(); track item.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__similar-card"
|
||||
[title]="item.title"
|
||||
(click)="openSimilar(item)"
|
||||
>
|
||||
@if (item.posterUrl) {
|
||||
<img
|
||||
class="details__similar-poster"
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__similar-poster details__similar-poster--fallback"
|
||||
>
|
||||
{{ item.title.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__similar-title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</app-content-hero>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</app-portal-detail-shell>
|
||||
} @else if (fallbackView(); as fallback) {
|
||||
<app-content-hero
|
||||
<app-portal-detail-shell
|
||||
[title]="fallback.title"
|
||||
[posterUrl]="fallback.posterUrl"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
<ng-template appDetailTags>
|
||||
<span
|
||||
class="details__tag details__tag--status"
|
||||
data-testid="xtream-vod-fallback-status"
|
||||
@@ -293,9 +299,9 @@
|
||||
@if (fallback.format) {
|
||||
<span class="details__tag">{{ fallback.format }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-container hero-meta>
|
||||
<ng-template appDetailMeta>
|
||||
<div
|
||||
class="details__fallback-note"
|
||||
data-testid="xtream-vod-fallback"
|
||||
@@ -311,13 +317,13 @@
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</app-content-hero>
|
||||
</ng-template>
|
||||
</app-portal-detail-shell>
|
||||
} @else if (detailsError()) {
|
||||
<app-content-hero
|
||||
<app-portal-detail-shell
|
||||
[errorMessage]="detailsError() ?? undefined"
|
||||
(backClicked)="goBack()"
|
||||
/>
|
||||
} @else {
|
||||
<app-content-hero [isLoading]="true" (backClicked)="goBack()" />
|
||||
<app-portal-detail-shell [isLoading]="true" (backClicked)="goBack()" />
|
||||
}
|
||||
@@ -14,7 +14,12 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
@@ -51,8 +56,11 @@ import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [VodDetailsPlaybackService],
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
MatIcon,
|
||||
PortalDetailShellComponent,
|
||||
SafePipe,
|
||||
SlicePipe,
|
||||
TranslateModule,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
||||
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
XtreamPlaylistData,
|
||||
@@ -35,7 +35,7 @@ interface XtreamCollectionStateSnapshot {
|
||||
|
||||
@Component({
|
||||
selector: 'app-xtream-collection-detail',
|
||||
imports: [ContentHeroComponent, NgComponentOutlet],
|
||||
imports: [PortalDetailShellComponent, NgComponentOutlet],
|
||||
template: `
|
||||
@if (detailComponent() && detailInjector()) {
|
||||
<ng-container
|
||||
@@ -45,7 +45,7 @@ interface XtreamCollectionStateSnapshot {
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-content-hero [isLoading]="true" />
|
||||
<app-portal-detail-shell [isLoading]="true" />
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TmdbEpisode,
|
||||
TmdbMovieDetails,
|
||||
TmdbPersonDetails,
|
||||
TmdbSeasonDetails,
|
||||
TmdbTvDetails,
|
||||
} from './tmdb.types';
|
||||
|
||||
@@ -67,6 +68,18 @@ export class TmdbEnrichmentService {
|
||||
return this.season.getSeasonEpisodes(tmdbId, seasonNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full season payload (overview + episodes) in the app language, using
|
||||
* the same cache rows as `getSeasonEpisodes`. Returns `null` when
|
||||
* enrichment is off or the request fails.
|
||||
*/
|
||||
async getSeason(
|
||||
tmdbId: number,
|
||||
seasonNumber: number
|
||||
): Promise<TmdbSeasonDetails | null> {
|
||||
return this.season.getSeason(tmdbId, seasonNumber);
|
||||
}
|
||||
|
||||
async getPersonDetails(
|
||||
personId: number
|
||||
): Promise<TmdbPersonDetails | null> {
|
||||
|
||||
@@ -6,10 +6,10 @@ import { TmdbRuntimeService } from './tmdb-runtime.service';
|
||||
import { TmdbEpisode, TmdbSeasonDetails } from './tmdb.types';
|
||||
|
||||
/**
|
||||
* Episode list of one season (names, overviews, stills, air dates) in the
|
||||
* app language. Fetched lazily when a season is opened and cached like
|
||||
* details payloads. Returns `null` when enrichment is off or the request
|
||||
* fails — episode lists then stay provider-only.
|
||||
* Season payloads (overview + episode list with names, overviews, stills,
|
||||
* air dates) in the app language. Fetched lazily when a season is opened
|
||||
* and cached like details payloads. Returns `null` when enrichment is off
|
||||
* or the request fails — season data then stays provider-only.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TmdbSeasonService {
|
||||
@@ -21,6 +21,15 @@ export class TmdbSeasonService {
|
||||
tmdbId: number,
|
||||
seasonNumber: number
|
||||
): Promise<TmdbEpisode[] | null> {
|
||||
const season = await this.getSeason(tmdbId, seasonNumber);
|
||||
return season ? (season.episodes ?? []) : null;
|
||||
}
|
||||
|
||||
/** Full season payload (overview + episodes), same cache rows. */
|
||||
async getSeason(
|
||||
tmdbId: number,
|
||||
seasonNumber: number
|
||||
): Promise<TmdbSeasonDetails | null> {
|
||||
if (!this.runtime.isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
@@ -35,10 +44,7 @@ export class TmdbSeasonService {
|
||||
cached?.payload
|
||||
) {
|
||||
try {
|
||||
const season = JSON.parse(
|
||||
cached.payload
|
||||
) as TmdbSeasonDetails;
|
||||
return season.episodes ?? [];
|
||||
return JSON.parse(cached.payload) as TmdbSeasonDetails;
|
||||
} catch {
|
||||
// Corrupt cache row — fall through to a fresh fetch
|
||||
}
|
||||
@@ -59,7 +65,7 @@ export class TmdbSeasonService {
|
||||
payload: JSON.stringify(season),
|
||||
});
|
||||
|
||||
return season.episodes ?? [];
|
||||
return season;
|
||||
} catch (error) {
|
||||
console.warn('TMDB season enrichment failed:', error);
|
||||
return null;
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface TmdbEpisode {
|
||||
|
||||
export interface TmdbSeasonDetails {
|
||||
season_number?: number;
|
||||
overview?: string;
|
||||
episodes?: TmdbEpisode[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ export * from './lib/channel-list-loading-state/channel-list-loading-state.compo
|
||||
export * from './lib/confirm-dialog/confirm-dialog.component';
|
||||
export * from './lib/confirm-dialog/dialog.service';
|
||||
export * from './lib/content-hero/content-hero.component';
|
||||
export * from './lib/expandable-text/expandable-text.component';
|
||||
export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/portal-detail-shell/content-about.component';
|
||||
export * from './lib/portal-detail-shell/detail-template.directives';
|
||||
export * from './lib/portal-detail-shell/portal-detail-shell.component';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
|
||||
@@ -184,9 +184,6 @@
|
||||
<div class="action-buttons">
|
||||
<ng-content select="[hero-actions]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Additional Content (Seasons, Trailers, etc.) -->
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -51,7 +49,9 @@
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// Natural height: the page scroll container lives outside the hero
|
||||
// (PortalDetailShellComponent). `overflow: hidden` clips the scaled
|
||||
// blurred backdrop fallback.
|
||||
overflow: hidden;
|
||||
background: var(
|
||||
--surface-bg
|
||||
@@ -161,10 +161,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable content area
|
||||
// Content area (page scroll lives in the surrounding detail shell)
|
||||
&__content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
@@ -172,16 +171,7 @@
|
||||
padding: 140px 40px 60px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
// Hide scrollbar but keep functionality
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
@@ -191,6 +181,7 @@
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
min-height: 50vh;
|
||||
|
||||
.error-icon {
|
||||
font-size: 64px;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
} from '@angular/core';
|
||||
@@ -53,8 +54,13 @@ export class ContentHeroComponent {
|
||||
const el = this.descriptionEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
this.measureOverflow(el);
|
||||
this.observeOverflow(el);
|
||||
// untracked: measureOverflow reads isDescriptionExpanded();
|
||||
// tracking it would re-run this effect (and rebuild the
|
||||
// ResizeObserver) on every expand/collapse click.
|
||||
untracked(() => {
|
||||
this.measureOverflow(el);
|
||||
this.observeOverflow(el);
|
||||
});
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => this.resizeObserver?.disconnect());
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ExpandableTextComponent } from './expandable-text.component';
|
||||
|
||||
describe('ExpandableTextComponent', () => {
|
||||
let fixture: ComponentFixture<ExpandableTextComponent>;
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalResizeObserver,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ExpandableTextComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ExpandableTextComponent);
|
||||
fixture.componentRef.setInput('text', 'Some long description');
|
||||
});
|
||||
|
||||
it('renders the text without a toggle when it does not overflow', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.textContent).toContain('Some long description');
|
||||
// JSDOM reports zero heights, so no overflow is detected
|
||||
expect(host.querySelector('.expandable-text__toggle')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the toggle when the text overflows and expands on click', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Simulate clamped overflow (JSDOM reports zero heights by default)
|
||||
// and retrigger the measuring effect via a text change.
|
||||
const paragraph = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector('.expandable-text') as HTMLElement;
|
||||
Object.defineProperty(paragraph, 'scrollHeight', {
|
||||
value: 120,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(paragraph, 'clientHeight', {
|
||||
value: 48,
|
||||
configurable: true,
|
||||
});
|
||||
fixture.componentRef.setInput('text', 'Some even longer description');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toggle = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.expandable-text__toggle'
|
||||
) as HTMLButtonElement;
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle.textContent).toContain('SHOW_MORE');
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.isExpanded()).toBe(true);
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).textContent
|
||||
).toContain('SHOW_LESS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Line-clamped text with a "Show more / Show less" toggle that appears only
|
||||
* when the text actually overflows the clamp (same pattern as the hero
|
||||
* description in ContentHeroComponent).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-expandable-text',
|
||||
standalone: true,
|
||||
imports: [MatIconModule, TranslateModule],
|
||||
template: `
|
||||
<p
|
||||
#textEl
|
||||
class="expandable-text"
|
||||
[class.expandable-text--expanded]="isExpanded()"
|
||||
[style.-webkit-line-clamp]="isExpanded() ? 'unset' : clampLines()"
|
||||
>
|
||||
{{ text() }}
|
||||
</p>
|
||||
@if (hasOverflow() || isExpanded()) {
|
||||
<button
|
||||
type="button"
|
||||
class="expandable-text__toggle"
|
||||
(click)="toggle()"
|
||||
[attr.aria-expanded]="isExpanded()"
|
||||
>
|
||||
<span>{{
|
||||
(isExpanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate
|
||||
}}</span>
|
||||
<mat-icon aria-hidden="true">
|
||||
{{ isExpanded() ? 'expand_less' : 'expand_more' }}
|
||||
</mat-icon>
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expandable-text {
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
&--expanded {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.expandable-text__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ExpandableTextComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly text = input.required<string>();
|
||||
readonly clampLines = input(3);
|
||||
|
||||
readonly isExpanded = signal(false);
|
||||
readonly hasOverflow = signal(false);
|
||||
|
||||
private readonly textEl = viewChild<ElementRef<HTMLElement>>('textEl');
|
||||
private resizeObserver?: ResizeObserver;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.text();
|
||||
const el = this.textEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
// untracked: measureOverflow reads isExpanded(); tracking it
|
||||
// would re-run this effect (and rebuild the ResizeObserver) on
|
||||
// every toggle click.
|
||||
untracked(() => {
|
||||
this.measureOverflow(el);
|
||||
this.observeOverflow(el);
|
||||
});
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => this.resizeObserver?.disconnect());
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this.isExpanded.update((value) => !value);
|
||||
}
|
||||
|
||||
private measureOverflow(el: HTMLElement): void {
|
||||
if (this.isExpanded()) return;
|
||||
this.hasOverflow.set(el.scrollHeight > el.clientHeight + 1);
|
||||
}
|
||||
|
||||
private observeOverflow(el: HTMLElement): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
this.measureOverflow(el);
|
||||
return;
|
||||
}
|
||||
this.resizeObserver = new ResizeObserver(() =>
|
||||
this.measureOverflow(el)
|
||||
);
|
||||
this.resizeObserver.observe(el);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.about {
|
||||
max-width: var(--content-max-width, 1400px);
|
||||
margin: 0 auto;
|
||||
padding: 24px 40px 40px;
|
||||
border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12));
|
||||
|
||||
&__heading {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
gap: 22px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__poster {
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--text-primary, #fff);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
margin: 0 0 14px 0;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
// The stamped host templates reuse the hero markup classes; style them here
|
||||
// because component styles do not cross the template-outlet boundary.
|
||||
.about ::ng-deep .details__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.1));
|
||||
border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 4px;
|
||||
|
||||
&--rating {
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.about ::ng-deep .details__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
.label {
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
||||
font-weight: 400;
|
||||
margin-bottom: 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.about {
|
||||
padding: 20px 20px 32px;
|
||||
|
||||
&__poster {
|
||||
width: 92px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { Component, TemplateRef, input } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Watch-state metadata block ("About the show / movie"). Rendered by
|
||||
* PortalDetailShellComponent below the episodes slot so the hero metadata
|
||||
* stays reachable while the player occupies the hero position.
|
||||
*
|
||||
* Degradation rule: anything missing simply is not rendered — no "N/A"
|
||||
* placeholders. Chips and credits are stamped from the host-provided
|
||||
* *appDetailTags / *appDetailMeta templates, so the host's own @if guards apply.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-content-about',
|
||||
standalone: true,
|
||||
imports: [NgTemplateOutlet, TranslateModule],
|
||||
template: `
|
||||
<section class="about">
|
||||
<h3 class="about__heading">{{ 'PORTALS.ABOUT' | translate }}</h3>
|
||||
<div class="about__body">
|
||||
@if (posterUrl()) {
|
||||
<img
|
||||
class="about__poster"
|
||||
[src]="posterUrl()"
|
||||
[alt]="title() ?? ''"
|
||||
loading="lazy"
|
||||
/>
|
||||
}
|
||||
<div class="about__info">
|
||||
@if (title()) {
|
||||
<h4 class="about__title">{{ title() }}</h4>
|
||||
}
|
||||
@if (tagsTemplate(); as tags) {
|
||||
<div class="about__tags details__tags">
|
||||
<ng-container [ngTemplateOutlet]="tags" />
|
||||
</div>
|
||||
}
|
||||
@if (description()) {
|
||||
<p class="about__description">{{ description() }}</p>
|
||||
}
|
||||
@if (metaTemplate(); as meta) {
|
||||
<div class="about__meta details__meta">
|
||||
<ng-container [ngTemplateOutlet]="meta" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styleUrls: ['./content-about.component.scss'],
|
||||
})
|
||||
export class ContentAboutComponent {
|
||||
readonly title = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
readonly description = input<string>();
|
||||
readonly tagsTemplate = input<TemplateRef<unknown> | null>(null);
|
||||
readonly metaTemplate = input<TemplateRef<unknown> | null>(null);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Directive, TemplateRef, inject } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Structural template markers used by PortalDetailShellComponent.
|
||||
*
|
||||
* Hosts wrap their hero chips/meta/actions markup in these templates so the
|
||||
* shell can stamp the same content twice: once into the hero (browse state)
|
||||
* and once into the About block (watch state). A projected node can only
|
||||
* appear once, a TemplateRef can be stamped any number of times.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appDetailTags]',
|
||||
standalone: true,
|
||||
})
|
||||
export class DetailTagsTemplateDirective {
|
||||
readonly templateRef = inject(TemplateRef);
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appDetailMeta]',
|
||||
standalone: true,
|
||||
})
|
||||
export class DetailMetaTemplateDirective {
|
||||
readonly templateRef = inject(TemplateRef);
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appDetailActions]',
|
||||
standalone: true,
|
||||
})
|
||||
export class DetailActionsTemplateDirective {
|
||||
readonly templateRef = inject(TemplateRef);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<!-- Hero (browse state) — collapses when playback is active -->
|
||||
<div class="shell__hero" [class.shell__hero--collapsed]="isWatch()">
|
||||
<div class="shell__hero-inner">
|
||||
<app-content-hero
|
||||
[title]="title()"
|
||||
[description]="description()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[backdropUrl]="backdropUrl()"
|
||||
[isLoading]="isLoading()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(backClicked)="backClicked.emit()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
@if (tagsTemplate(); as tags) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="tags.templateRef"
|
||||
></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-container hero-meta>
|
||||
@if (metaTemplate(); as meta) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="meta.templateRef"
|
||||
></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-container hero-actions>
|
||||
@if (actionsTemplate(); as actions) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="actions.templateRef"
|
||||
></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
<ng-container hero-error-actions>
|
||||
<ng-content select="[hero-error-actions]"></ng-content>
|
||||
</ng-container>
|
||||
</app-content-hero>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player region (watch state) — the slot itself is never wrapped in @if,
|
||||
so the host-owned player subtree survives every shell state change -->
|
||||
<div class="shell__player" [class.shell__player--active]="isWatch()">
|
||||
<ng-content select="[detail-player]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Episodes / seasons — both states -->
|
||||
<div class="shell__episodes">
|
||||
<ng-content select="[detail-episodes]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- About block — watch state only, metadata moves here -->
|
||||
@if (isWatch() && !isLoading() && !errorMessage()) {
|
||||
<app-content-about
|
||||
class="shell__about"
|
||||
[title]="title()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[description]="description()"
|
||||
[tagsTemplate]="tagsTemplate()?.templateRef ?? null"
|
||||
[metaTemplate]="metaTemplate()?.templateRef ?? null"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Extras (trailer, similar rail) — both states -->
|
||||
<div class="shell__extras">
|
||||
<ng-content select="[detail-extras]"></ng-content>
|
||||
</div>
|
||||
@@ -0,0 +1,140 @@
|
||||
// ============================================================================
|
||||
// Portal detail shell — two-state (browse ↔ watch) layout + page scroll owner
|
||||
// ============================================================================
|
||||
|
||||
:host {
|
||||
// Shared surface palette (mirrors content-hero) so the area below the
|
||||
// natural-height hero and the About block match the page background.
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
|
||||
// Hide scrollbar but keep functionality (parity with the old hero scroll)
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) {
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hero collapse (browse ↔ watch morph, ~300ms, no height measuring)
|
||||
// ============================================================================
|
||||
|
||||
.shell__hero {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
|
||||
&--collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
// Keep the hero visible while it collapses, hide it afterwards so it
|
||||
// cannot receive focus in watch state.
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease,
|
||||
visibility 0s linear 300ms;
|
||||
}
|
||||
}
|
||||
|
||||
.shell__hero-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Player region (full content width in watch state)
|
||||
// ============================================================================
|
||||
|
||||
.shell__player {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
|
||||
&--active {
|
||||
padding: 16px 40px 0;
|
||||
animation: shell-fade-in 300ms ease 50ms both;
|
||||
}
|
||||
}
|
||||
|
||||
.shell__episodes {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.shell__about {
|
||||
display: block;
|
||||
// Air between the last episode row and the section divider.
|
||||
margin-top: 32px;
|
||||
animation: shell-fade-in 250ms ease 100ms both;
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px 40px;
|
||||
}
|
||||
|
||||
@keyframes shell-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.shell__player--active {
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.shell__episodes {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
padding: 0 20px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shell__hero,
|
||||
.shell__hero--collapsed {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.shell__player--active,
|
||||
.shell__about {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
} from './detail-template.directives';
|
||||
import { PortalDetailShellComponent } from './portal-detail-shell.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
PortalDetailShellComponent,
|
||||
DetailTagsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailActionsTemplateDirective,
|
||||
],
|
||||
template: `
|
||||
<app-portal-detail-shell
|
||||
[title]="'Show Title'"
|
||||
[description]="'Show description'"
|
||||
[posterUrl]="'poster.jpg'"
|
||||
[playbackActive]="playbackActive()"
|
||||
(closePlayerRequested)="closeRequests = closeRequests + 1"
|
||||
>
|
||||
<ng-template appDetailTags>
|
||||
<span class="details__tag">2026</span>
|
||||
</ng-template>
|
||||
<ng-template appDetailMeta>
|
||||
<div class="details__meta-item">Cast entry</div>
|
||||
</ng-template>
|
||||
<ng-template appDetailActions>
|
||||
<button class="play-btn">Play</button>
|
||||
</ng-template>
|
||||
@if (playbackActive()) {
|
||||
<div detail-player class="fake-player">player</div>
|
||||
}
|
||||
<div detail-episodes class="fake-episodes">episodes</div>
|
||||
<div detail-extras class="fake-extras">extras</div>
|
||||
</app-portal-detail-shell>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
readonly playbackActive = signal(false);
|
||||
closeRequests = 0;
|
||||
}
|
||||
|
||||
describe('PortalDetailShellComponent', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let host: HostComponent;
|
||||
|
||||
const query = (selector: string): HTMLElement | null =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(selector);
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders hero with stamped tags/meta/actions in browse state', () => {
|
||||
expect(query('.shell__hero--collapsed')).toBeNull();
|
||||
expect(query('app-content-hero')).toBeTruthy();
|
||||
expect(query('.details__tags .details__tag')?.textContent).toContain(
|
||||
'2026'
|
||||
);
|
||||
expect(query('.details__meta .details__meta-item')).toBeTruthy();
|
||||
expect(query('.action-buttons .play-btn')).toBeTruthy();
|
||||
expect(query('app-content-about')).toBeNull();
|
||||
});
|
||||
|
||||
it('collapses hero and shows About with re-stamped templates in watch state', () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('.shell__hero--collapsed')).toBeTruthy();
|
||||
const about = query('app-content-about');
|
||||
expect(about).toBeTruthy();
|
||||
expect(about?.querySelector('.details__tag')?.textContent).toContain(
|
||||
'2026'
|
||||
);
|
||||
expect(about?.querySelector('.details__meta-item')).toBeTruthy();
|
||||
// Actions are intentionally NOT repeated in About
|
||||
expect(about?.querySelector('.play-btn')).toBeNull();
|
||||
expect(about?.textContent).toContain('Show description');
|
||||
});
|
||||
|
||||
it('keeps the player slot outside any shell conditional', () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
const player = query('.fake-player');
|
||||
expect(player).toBeTruthy();
|
||||
|
||||
// Toggling unrelated shell state must not recreate the projected node
|
||||
host.playbackActive.set(false);
|
||||
fixture.detectChanges();
|
||||
// player disappears only because the HOST @if removed it
|
||||
expect(query('.fake-player')).toBeNull();
|
||||
});
|
||||
|
||||
it('emits closePlayerRequested on Escape only during playback', () => {
|
||||
const escape = () =>
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
|
||||
escape();
|
||||
expect(host.closeRequests).toBe(0);
|
||||
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
escape();
|
||||
expect(host.closeRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores Escape when the event was already handled', () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
document.dispatchEvent(event);
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
contentChild,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { ContentHeroComponent } from '../content-hero/content-hero.component';
|
||||
import { ContentAboutComponent } from './content-about.component';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
} from './detail-template.directives';
|
||||
|
||||
/**
|
||||
* Two-state layout shell for portal VOD/series detail pages.
|
||||
*
|
||||
* Browse state: hero (poster + metadata + actions) on top, episodes below.
|
||||
* Watch state (`playbackActive`): the hero collapses, the host-projected
|
||||
* player takes the full content width, and the metadata reappears in an
|
||||
* About block below the episodes slot.
|
||||
*
|
||||
* The shell owns the page scroll, the browse↔watch animation, Escape
|
||||
* handling, and never conditionally wraps the `[detail-player]` slot — the
|
||||
* host's own `@if (inlinePlayback())` is the only thing that creates or
|
||||
* destroys the player, so shell state changes cannot recreate it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-portal-detail-shell',
|
||||
standalone: true,
|
||||
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
|
||||
templateUrl: './portal-detail-shell.component.html',
|
||||
styleUrls: ['./portal-detail-shell.component.scss'],
|
||||
host: {
|
||||
'[class.shell-host--watch]': 'isWatch()',
|
||||
'(document:keydown.escape)': 'onEscape($event)',
|
||||
},
|
||||
})
|
||||
export class PortalDetailShellComponent {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
readonly title = input<string>();
|
||||
readonly description = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
readonly backdropUrl = input<string>();
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
/** True while inline playback is active — flips the layout to watch state. */
|
||||
readonly playbackActive = input(false);
|
||||
|
||||
readonly backClicked = output<void>();
|
||||
/** Emitted when Escape is pressed during inline playback. */
|
||||
readonly closePlayerRequested = output<void>();
|
||||
|
||||
protected readonly tagsTemplate = contentChild(DetailTagsTemplateDirective);
|
||||
protected readonly metaTemplate = contentChild(DetailMetaTemplateDirective);
|
||||
protected readonly actionsTemplate = contentChild(
|
||||
DetailActionsTemplateDirective
|
||||
);
|
||||
|
||||
readonly isWatch = computed(() => this.playbackActive());
|
||||
|
||||
constructor() {
|
||||
let wasWatch = false;
|
||||
effect(() => {
|
||||
const watch = this.isWatch();
|
||||
if (watch && !wasWatch) {
|
||||
this.scrollToTop();
|
||||
}
|
||||
wasWatch = watch;
|
||||
});
|
||||
}
|
||||
|
||||
onEscape(event: Event): void {
|
||||
if (!this.playbackActive()) return;
|
||||
if (event.defaultPrevented) return;
|
||||
// Browser fullscreen owns Escape (exits fullscreen first).
|
||||
if (document.fullscreenElement) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.closePlayerRequested.emit();
|
||||
}
|
||||
|
||||
private scrollToTop(): void {
|
||||
const element = this.host.nativeElement;
|
||||
// Instant jump: a smooth scroll would run concurrently with the
|
||||
// 300ms hero-collapse morph (a layout animation) and the two
|
||||
// animations fight for frame budget. The morph visually covers the
|
||||
// jump anyway.
|
||||
if (typeof element.scrollTo !== 'function') {
|
||||
element.scrollTop = 0;
|
||||
return;
|
||||
}
|
||||
element.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Pure helpers for mapping episodes (Xtream and Stalker-mapped) to the
|
||||
* download-manager content ids and download requests used by the season
|
||||
* container.
|
||||
*/
|
||||
|
||||
export interface XtreamEpisodeDownloadContext {
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface XtreamEpisodeDownloadRequest {
|
||||
playlistId: string;
|
||||
xtreamId: number;
|
||||
contentType: 'episode';
|
||||
title: string;
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
seriesXtreamId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
}
|
||||
|
||||
export function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < str.length; index++) {
|
||||
const char = str.charCodeAt(index);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash &= hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
return customSid === 'vod-series' || customSid === 'regular-series';
|
||||
}
|
||||
|
||||
export function getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
|
||||
if (customSid === 'regular-series') {
|
||||
const cmd = (episode as { originalCmd?: string }).originalCmd;
|
||||
if (cmd) {
|
||||
const match = cmd.match(/file_(\d+)/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
return hashString(cmd);
|
||||
}
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
if (customSid === 'vod-series') {
|
||||
const originalId = (episode as { originalId?: string | number })
|
||||
.originalId;
|
||||
const numericId = Number(originalId);
|
||||
return Number.isNaN(numericId)
|
||||
? hashString(String(originalId))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
const numericId = Number(episode.id);
|
||||
return Number.isNaN(numericId) ? hashString(String(episode.id)) : numericId;
|
||||
}
|
||||
|
||||
export function buildXtreamEpisodeDownloadRequest(options: {
|
||||
episode: XtreamSerieEpisode;
|
||||
context: XtreamEpisodeDownloadContext;
|
||||
playlistId: string;
|
||||
seriesId: number;
|
||||
seriesTitle: string;
|
||||
fallbackSeasonKey: string | undefined;
|
||||
posterUrl?: string;
|
||||
}): XtreamEpisodeDownloadRequest {
|
||||
const { episode, context, playlistId, seriesId, seriesTitle } = options;
|
||||
const serverUrl = context.serverUrl?.replace(/\/$/, '') || '';
|
||||
const username = context.username || '';
|
||||
const password = context.password || '';
|
||||
const extension = episode.container_extension || 'mp4';
|
||||
const seasonNumber =
|
||||
episode.season || Number(options.fallbackSeasonKey) || 1;
|
||||
const episodeNumber = episode.episode_num || 1;
|
||||
const title = `${seriesTitle || 'Series'} - S${String(seasonNumber).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`;
|
||||
|
||||
return {
|
||||
playlistId,
|
||||
xtreamId: Number(episode.id),
|
||||
contentType: 'episode',
|
||||
title,
|
||||
url: `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`,
|
||||
posterUrl: options.posterUrl,
|
||||
seriesXtreamId: seriesId,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface EpisodeInfoDialogData {
|
||||
/** e.g. "S01E05" */
|
||||
episodeLabel: string;
|
||||
title: string;
|
||||
plot?: string;
|
||||
duration?: string;
|
||||
airDate?: string;
|
||||
thumbnailUrl?: string;
|
||||
rating?: string | number;
|
||||
}
|
||||
|
||||
export function buildEpisodeInfoDialogData(
|
||||
episode: XtreamSerieEpisode,
|
||||
fallbackSeasonKey: string | undefined
|
||||
): EpisodeInfoDialogData {
|
||||
const info =
|
||||
!episode.info || Array.isArray(episode.info)
|
||||
? undefined
|
||||
: episode.info;
|
||||
const seasonNumber = Number(episode.season || fallbackSeasonKey || 1);
|
||||
const episodeNumber = Number(episode.episode_num || 1);
|
||||
return {
|
||||
episodeLabel: `S${String(seasonNumber).padStart(2, '0')}E${String(
|
||||
episodeNumber
|
||||
).padStart(2, '0')}`,
|
||||
title: episode.title,
|
||||
plot: info?.plot || undefined,
|
||||
duration: info?.duration || undefined,
|
||||
airDate: info?.releasedate || undefined,
|
||||
thumbnailUrl: info?.movie_image || undefined,
|
||||
rating: info?.rating || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Result returned when the user chooses to play from the dialog. */
|
||||
export const EPISODE_INFO_PLAY = 'play' as const;
|
||||
|
||||
/**
|
||||
* Lightweight episode details dialog. Cards and list rows clamp the plot to
|
||||
* keep fixed heights; the ℹ button opens this dialog with the full text.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-episode-info-dialog',
|
||||
standalone: true,
|
||||
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule],
|
||||
template: `
|
||||
<div class="episode-info">
|
||||
@if (data.thumbnailUrl) {
|
||||
<img
|
||||
class="episode-info__thumb"
|
||||
[src]="data.thumbnailUrl"
|
||||
[alt]="data.title"
|
||||
(error)="thumbError = true"
|
||||
[style.display]="thumbError ? 'none' : ''"
|
||||
/>
|
||||
}
|
||||
<div class="episode-info__body">
|
||||
<div class="episode-info__eyebrow">
|
||||
{{ data.episodeLabel }}
|
||||
@if (data.duration) {
|
||||
<span class="episode-info__chip">{{
|
||||
data.duration
|
||||
}}</span>
|
||||
}
|
||||
@if (data.airDate) {
|
||||
<span class="episode-info__chip">{{
|
||||
data.airDate
|
||||
}}</span>
|
||||
}
|
||||
@if (data.rating) {
|
||||
<span class="episode-info__chip"
|
||||
>⭐ {{ data.rating }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<h2 mat-dialog-title class="episode-info__title">
|
||||
{{ data.title }}
|
||||
</h2>
|
||||
@if (data.plot) {
|
||||
<p class="episode-info__plot">{{ data.plot }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
data-testid="episode-info-play"
|
||||
(click)="play()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.episode-info__thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.episode-info__body {
|
||||
padding: 16px 24px 0;
|
||||
}
|
||||
|
||||
.episode-info__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.episode-info__chip {
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.episode-info__title {
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-info__plot {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.85;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class EpisodeInfoDialogComponent {
|
||||
readonly data: EpisodeInfoDialogData = inject(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<EpisodeInfoDialogComponent>
|
||||
);
|
||||
thumbError = false;
|
||||
|
||||
play(): void {
|
||||
this.dialogRef.close(EPISODE_INFO_PLAY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { isPortalPlaybackWatched } 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable in-progress text ("41:12 left" / elapsed time when the
|
||||
* duration is unknown). Returns null for watched or position-less episodes.
|
||||
*/
|
||||
export function formatEpisodePositionText(
|
||||
position: PlaybackPositionData | undefined
|
||||
): string | null {
|
||||
if (isPortalPlaybackWatched(position)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!position || !position.positionSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let seconds = position.positionSeconds;
|
||||
let suffix = '';
|
||||
|
||||
if (position.durationSeconds && position.durationSeconds > 0) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
position.durationSeconds - position.positionSeconds
|
||||
);
|
||||
|
||||
if (remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seconds = remaining;
|
||||
suffix = ' left';
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
const formatted = [hours, minutes, secs]
|
||||
.map((value) => String(value).padStart(2, '0'))
|
||||
.filter((value, index) => (index === 0 ? value !== '00' : true))
|
||||
.join(':');
|
||||
return `${formatted}${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildXtreamEpisodeDownloadRequest,
|
||||
getEpisodeDownloadId,
|
||||
hashString,
|
||||
isStalkerEpisode,
|
||||
} from './episode-download.util';
|
||||
import {
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
|
||||
const episode = (overrides: Record<string, unknown>): XtreamSerieEpisode =>
|
||||
({ id: '10', episode_num: 1, title: 'Ep', ...overrides }) as never;
|
||||
|
||||
describe('episode-download.util', () => {
|
||||
it('detects stalker-mapped episodes', () => {
|
||||
expect(isStalkerEpisode(episode({ custom_sid: 'vod-series' }))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isStalkerEpisode(episode({ custom_sid: 'regular-series' }))
|
||||
).toBe(true);
|
||||
expect(isStalkerEpisode(episode({}))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves download ids per episode source', () => {
|
||||
expect(getEpisodeDownloadId(episode({}))).toBe(10);
|
||||
expect(
|
||||
getEpisodeDownloadId(
|
||||
episode({
|
||||
custom_sid: 'regular-series',
|
||||
originalCmd: '/media/file_777.mpg',
|
||||
})
|
||||
)
|
||||
).toBe(777);
|
||||
expect(
|
||||
getEpisodeDownloadId(
|
||||
episode({ custom_sid: 'vod-series', originalId: '42' })
|
||||
)
|
||||
).toBe(42);
|
||||
expect(
|
||||
getEpisodeDownloadId(
|
||||
episode({ custom_sid: 'vod-series', originalId: 'abc' })
|
||||
)
|
||||
).toBe(hashString('abc'));
|
||||
});
|
||||
|
||||
it('builds an Xtream episode download request', () => {
|
||||
const request = buildXtreamEpisodeDownloadRequest({
|
||||
episode: episode({
|
||||
id: '55',
|
||||
episode_num: 3,
|
||||
season: 2,
|
||||
container_extension: 'mkv',
|
||||
title: 'The One',
|
||||
}),
|
||||
context: {
|
||||
serverUrl: 'http://host/',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
},
|
||||
playlistId: 'pl-1',
|
||||
seriesId: 900,
|
||||
seriesTitle: 'Show',
|
||||
fallbackSeasonKey: '1',
|
||||
});
|
||||
|
||||
expect(request.url).toBe('http://host/series/u/p/55.mkv');
|
||||
expect(request.title).toBe('Show - S02E03 - The One');
|
||||
expect(request.seasonNumber).toBe(2);
|
||||
expect(request.seriesXtreamId).toBe(900);
|
||||
});
|
||||
});
|
||||
|
||||
describe('episode-progress.util', () => {
|
||||
it('parses duration strings', () => {
|
||||
expect(parseDuration('01:00:30')).toBe(3630);
|
||||
expect(parseDuration('45:12')).toBe(2712);
|
||||
expect(parseDuration(120)).toBe(120);
|
||||
expect(parseDuration(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('formats remaining time when duration is known', () => {
|
||||
expect(
|
||||
formatEpisodePositionText({
|
||||
contentXtreamId: 1,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 60,
|
||||
durationSeconds: 360,
|
||||
})
|
||||
).toBe('05:00 left');
|
||||
});
|
||||
|
||||
it('returns null for watched or missing positions', () => {
|
||||
expect(formatEpisodePositionText(undefined)).toBeNull();
|
||||
expect(
|
||||
formatEpisodePositionText({
|
||||
contentXtreamId: 1,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 350,
|
||||
durationSeconds: 360,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,27 @@
|
||||
<section class="seasons-section">
|
||||
<div class="section-header">
|
||||
<div class="section-header__left">
|
||||
<h3 class="section-title">Seasons and Episodes</h3>
|
||||
@if (selectedSeason) {
|
||||
<button class="back-btn" (click)="selectedSeason = undefined">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to seasons</span>
|
||||
</button>
|
||||
}
|
||||
<h3 class="section-title">
|
||||
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
@if (selectedSeason && !showSeasonEmptyState()) {
|
||||
@if (hasSeasons() && !showSeasonEmptyState()) {
|
||||
<div class="view-toggle">
|
||||
<mat-button-toggle-group
|
||||
[value]="viewMode()"
|
||||
(change)="setViewMode($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-button-toggle value="grid" [matTooltip]="'Grid view'">
|
||||
<mat-button-toggle
|
||||
value="grid"
|
||||
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="list" [matTooltip]="'List view'">
|
||||
<mat-button-toggle
|
||||
value="list"
|
||||
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
|
||||
>
|
||||
<mat-icon>view_list</mat-icon>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
@@ -27,6 +29,19 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (hasSeasons()) {
|
||||
<app-season-tabs
|
||||
[seasonKeys]="sortedSeasonKeys()"
|
||||
[selectedSeason]="selectedSeason()"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
/>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
@@ -37,42 +52,13 @@
|
||||
<div class="empty-state-panel__icon" aria-hidden="true">
|
||||
<mat-icon>video_library</mat-icon>
|
||||
</div>
|
||||
<h4 class="empty-state-panel__title">No episodes available</h4>
|
||||
<h4 class="empty-state-panel__title">
|
||||
{{ 'PORTALS.NO_EPISODES_AVAILABLE' | translate }}
|
||||
</h4>
|
||||
<p class="empty-state-panel__message">
|
||||
This series is listed by your provider, but no seasons or
|
||||
episodes are available to play.
|
||||
{{ 'PORTALS.NO_EPISODES_AVAILABLE_MESSAGE' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else if (!selectedSeason) {
|
||||
<!-- Seasons Grid -->
|
||||
<div class="seasons-grid">
|
||||
@for (
|
||||
season of seasons() | keyvalue: compareSeasons;
|
||||
track $index
|
||||
) {
|
||||
<button
|
||||
class="season-card"
|
||||
(click)="selectSeason(season.key)"
|
||||
[class.season-card--has-episodes]="season.value.length"
|
||||
>
|
||||
<div class="season-card__number">{{ season.key }}</div>
|
||||
<div class="season-card__label">Season</div>
|
||||
@if (season.value.length) {
|
||||
<div class="season-card__episodes">
|
||||
{{ season.value.length }}
|
||||
{{
|
||||
season.value.length === 1
|
||||
? 'Episode'
|
||||
: 'Episodes'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
<div class="season-card__arrow">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else if (showSeasonEmptyState()) {
|
||||
<div
|
||||
class="empty-state-panel empty-state-panel--season"
|
||||
@@ -81,20 +67,24 @@
|
||||
<div class="empty-state-panel__icon" aria-hidden="true">
|
||||
<mat-icon>video_library</mat-icon>
|
||||
</div>
|
||||
<h4 class="empty-state-panel__title">This season is empty</h4>
|
||||
<h4 class="empty-state-panel__title">
|
||||
{{ 'PORTALS.SEASON_EMPTY' | translate }}
|
||||
</h4>
|
||||
<p class="empty-state-panel__message">
|
||||
This season is listed by your provider, but no playable
|
||||
episodes are available.
|
||||
{{ 'PORTALS.SEASON_EMPTY_MESSAGE' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (viewMode() === 'grid') {
|
||||
<!-- Episodes Grid View -->
|
||||
<div class="episodes-grid">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@for (episode of selectedSeasonEpisodes(); track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-card"
|
||||
[attr.data-episode-id]="
|
||||
getEpisodeContentId(episode)
|
||||
"
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-card--watched]="
|
||||
isEpisodeWatched(episode)
|
||||
@@ -108,6 +98,9 @@
|
||||
[class.episode-card--active-external]="
|
||||
isEpisodeActiveExternal(episode)
|
||||
"
|
||||
[class.episode-card--playing]="
|
||||
isEpisodePlayingInline(episode)
|
||||
"
|
||||
>
|
||||
<div class="episode-card__thumbnail">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
@@ -115,7 +108,7 @@
|
||||
[src]="episodeInfo?.movie_image ?? ''"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-episode.png'
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
alt="Episode thumbnail"
|
||||
loading="lazy"
|
||||
@@ -140,9 +133,27 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<app-watched-badge
|
||||
[isWatched]="isEpisodeWatched(episode)"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="episode-card__watched-toggle"
|
||||
[class.episode-card__watched-toggle--watched]="
|
||||
isEpisodeWatched(episode)
|
||||
"
|
||||
data-testid="episode-watched-toggle"
|
||||
(click)="toggleWatched($event, episode)"
|
||||
[matTooltip]="
|
||||
(isEpisodeWatched(episode)
|
||||
? 'XTREAM.MARK_UNWATCHED'
|
||||
: 'XTREAM.MARK_WATCHED'
|
||||
) | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isEpisodeWatched(episode)
|
||||
? 'check_circle'
|
||||
: 'radio_button_unchecked'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
<div class="episode-card__actions">
|
||||
@if (isElectron()) {
|
||||
@if (isEpisodeDownloaded(episode)) {
|
||||
@@ -195,23 +206,6 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="toggle-watched-btn"
|
||||
(click)="toggleWatched($event, episode)"
|
||||
[matTooltip]="
|
||||
(isEpisodeWatched(episode)
|
||||
? 'XTREAM.MARK_UNWATCHED'
|
||||
: 'XTREAM.MARK_WATCHED'
|
||||
) | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>{{
|
||||
isEpisodeWatched(episode)
|
||||
? 'check_circle'
|
||||
: 'radio_button_unchecked'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="episode-card__overlay">
|
||||
<div class="episode-card__play">
|
||||
@@ -221,7 +215,10 @@
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
isEpisodeActiveExternal(episode)
|
||||
isEpisodeActiveExternal(
|
||||
episode
|
||||
) ||
|
||||
isEpisodePlayingInline(episode)
|
||||
? 'equalizer'
|
||||
: 'play_arrow'
|
||||
}}</mat-icon>
|
||||
@@ -233,10 +230,31 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__info">
|
||||
<h4 class="episode-card__title">
|
||||
{{ episode.episode_num }}.
|
||||
{{ episode.title }}
|
||||
</h4>
|
||||
<div class="episode-card__title-row">
|
||||
<h4 class="episode-card__title">
|
||||
{{ episode.episode_num }}.
|
||||
{{ episode.title }}
|
||||
</h4>
|
||||
@if (episodeInfo?.plot) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="episode-card__info-btn"
|
||||
data-testid="episode-info-button"
|
||||
(click)="
|
||||
openEpisodeInfo(
|
||||
$event,
|
||||
episode
|
||||
)
|
||||
"
|
||||
[matTooltip]="
|
||||
'PORTALS.EPISODE_INFO'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (episodeInfo?.plot) {
|
||||
<p class="episode-card__plot">
|
||||
{{ episodeInfo?.plot }}
|
||||
@@ -254,10 +272,13 @@
|
||||
} @else {
|
||||
<!-- Episodes List View -->
|
||||
<div class="episodes-list">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@for (episode of selectedSeasonEpisodes(); track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-list-item"
|
||||
[attr.data-episode-id]="
|
||||
getEpisodeContentId(episode)
|
||||
"
|
||||
(click)="selectEpisode(episode)"
|
||||
[class.episode-list-item--watched]="
|
||||
isEpisodeWatched(episode)
|
||||
@@ -271,10 +292,40 @@
|
||||
[class.episode-list-item--active-external]="
|
||||
isEpisodeActiveExternal(episode)
|
||||
"
|
||||
[class.episode-list-item--playing]="
|
||||
isEpisodePlayingInline(episode)
|
||||
"
|
||||
>
|
||||
<div class="episode-list-item__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
@if (listThumbnailsEnabled()) {
|
||||
<div class="episode-list-item__thumb">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
<img
|
||||
[src]="
|
||||
episodeInfo?.movie_image ?? ''
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
alt="Episode thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon
|
||||
class="episode-list-item__thumb-placeholder"
|
||||
>play_circle_outline</mat-icon
|
||||
>
|
||||
}
|
||||
<span
|
||||
class="episode-list-item__thumb-number"
|
||||
>{{ episode.episode_num }}</span
|
||||
>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="episode-list-item__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
}
|
||||
<div class="episode-list-item__content">
|
||||
<div class="episode-list-item__header">
|
||||
<h4 class="episode-list-item__title">
|
||||
@@ -340,7 +391,8 @@
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
isEpisodeActiveExternal(episode)
|
||||
isEpisodeActiveExternal(episode) ||
|
||||
isEpisodePlayingInline(episode)
|
||||
? 'equalizer'
|
||||
: 'play_arrow'
|
||||
}}</mat-icon>
|
||||
@@ -388,6 +440,21 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (episodeInfo?.plot) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-btn"
|
||||
data-testid="episode-info-button"
|
||||
(click)="
|
||||
openEpisodeInfo($event, episode)
|
||||
"
|
||||
[matTooltip]="
|
||||
'PORTALS.EPISODE_INFO' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="action-btn"
|
||||
|
||||
@@ -26,33 +26,6 @@
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
mat-button-toggle-group {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -170,140 +143,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Grid Layouts
|
||||
.seasons-grid,
|
||||
// Grid Layout
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.seasons-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.episodes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
// Season Card
|
||||
.season-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
|
||||
.season-card__arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-ring {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring__bg {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.1);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.progress-ring__fill {
|
||||
fill: none;
|
||||
stroke: var(--accent-color);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.5s ease-out;
|
||||
}
|
||||
|
||||
.season-card__number {
|
||||
position: absolute;
|
||||
font-size: 1rem !important;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
opacity: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.9375rem !important;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&__progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__episodes {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-muted);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&--has-episodes {
|
||||
/* background: rgba(var(--accent-color-rgb), 0.1);
|
||||
border-color: rgba(var(--accent-color-rgb), 0.3); */
|
||||
|
||||
.season-card__number {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* .season-card__label {
|
||||
color: var(--accent-color);
|
||||
} */
|
||||
}
|
||||
app-season-tabs {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// Episode Card
|
||||
@@ -352,20 +202,77 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
|
||||
}
|
||||
|
||||
// Watched state
|
||||
// Playing in the inline player
|
||||
&--playing {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 70%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
// Watched state — the sole indicator is the watched toggle on the
|
||||
// thumbnail (plus the dimmed card); no extra checkmarks elsewhere.
|
||||
&--watched {
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-card__title::after {
|
||||
content: ' ✓';
|
||||
color: var(--mat-sys-tertiary);
|
||||
font-weight: bold;
|
||||
margin-left: 4px;
|
||||
// Watched indicator + toggle in one control, top-left on the artwork.
|
||||
// Watched: always visible, green (the old badge look). Unwatched: ghost
|
||||
// circle revealed on card hover.
|
||||
&__watched-toggle {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 2;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--watched {
|
||||
opacity: 1;
|
||||
color: white;
|
||||
background: rgba(70, 211, 105, 0.9);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: rgba(70, 211, 105, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .episode-card__watched-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -447,23 +354,27 @@
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.download-btn,
|
||||
.toggle-watched-btn {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
.download-btn {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,10 +437,19 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
&__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
@@ -537,6 +457,50 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// "About the episode" lives with the text, not on the artwork: a quiet
|
||||
// ghost button at the end of the title row, revealed on card hover.
|
||||
&__info-btn {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
margin: -1px -2px 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// The default 48px MDC touch target would swallow clicks on the
|
||||
// title around this tiny button (card click = play).
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .episode-card__info-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__plot {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -618,6 +582,48 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Compact 16:9 episode still (only when the season has distinct
|
||||
// per-episode art); the episode number rides on the image.
|
||||
&__thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
height: 54px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
&__thumb-number {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -757,6 +763,11 @@
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
|
||||
}
|
||||
|
||||
&--playing {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
// Always show actions on touch devices
|
||||
@media (hover: none) {
|
||||
.episode-list-item__actions {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { of } from 'rxjs';
|
||||
import { EPISODE_INFO_PLAY } from './episode-info-dialog.component';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
const downloadsServiceStub = {
|
||||
@@ -37,9 +40,12 @@ function createEpisode(
|
||||
};
|
||||
}
|
||||
|
||||
const dialogOpen = jest.fn();
|
||||
|
||||
describe('SeasonContainerComponent', () => {
|
||||
let fixture: ComponentFixture<SeasonContainerComponent>;
|
||||
let component: SeasonContainerComponent;
|
||||
let emittedSeasons: string[];
|
||||
|
||||
const setRequiredInputs = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
@@ -63,11 +69,21 @@ describe('SeasonContainerComponent', () => {
|
||||
provide: DownloadsService,
|
||||
useValue: downloadsServiceStub,
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: { open: dialogOpen },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SeasonContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
emittedSeasons = [];
|
||||
dialogOpen.mockReset();
|
||||
dialogOpen.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
component.seasonSelected.subscribe((seasonKey) =>
|
||||
emittedSeasons.push(seasonKey)
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the series-level placeholder when no seasons are available', () => {
|
||||
@@ -78,12 +94,11 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'No episodes available'
|
||||
'PORTALS.NO_EPISODES_AVAILABLE'
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This series is listed by your provider, but no seasons or episodes are available to play.'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.season-card')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-season-tabs')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the loading state instead of the placeholder while seasons are loading', () => {
|
||||
@@ -91,72 +106,271 @@ describe('SeasonContainerComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.loading-container mat-spinner')
|
||||
fixture.nativeElement.querySelector(
|
||||
'.loading-container mat-spinner'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the season-level placeholder when the selected season has no episodes', () => {
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This season is empty'
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'This season is listed by your provider, but no playable episodes are available.'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders season cards and episode cards when episodes exist', () => {
|
||||
const episode = createEpisode();
|
||||
|
||||
it('auto-selects the first season, emits seasonSelected, and renders episodes without a click', () => {
|
||||
setRequiredInputs({
|
||||
'1': [episode],
|
||||
'1': [createEpisode()],
|
||||
'2': [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-card').length
|
||||
).toBe(2);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
).toBeNull();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(emittedSeasons).toEqual(['1']);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.episode-card').length
|
||||
).toBe(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.empty-state-panel')
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill').length
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('renders the season-level placeholder when the selected season has no episodes', () => {
|
||||
setRequiredInputs({ '1': [] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'PORTALS.SEASON_EMPTY'
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches season via tab click and emits the lazy-load hook once per season', () => {
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const pills = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
);
|
||||
(pills[1] as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['1', '2']);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-episode-id="201"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the inline-playing episode season on auto-select', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 201);
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('prefers the most recently updated in-progress episode season over the first season', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'playbackPositions',
|
||||
new Map([
|
||||
[
|
||||
201,
|
||||
{
|
||||
contentXtreamId: 201,
|
||||
contentType: 'episode' as const,
|
||||
positionSeconds: 500,
|
||||
durationSeconds: 2700,
|
||||
updatedAt: '2026-07-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
});
|
||||
|
||||
it('re-resolves a stale selection when the season set changes', () => {
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
|
||||
fixture.componentRef.setInput('seasons', {
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('keeps the user selection when episode data mutates without key changes', () => {
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const pills = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
);
|
||||
(pills[1] as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
|
||||
// Same keys, new object (e.g. TMDB overlay rebuild) — selection sticks
|
||||
fixture.componentRef.setInput('seasons', {
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(emittedSeasons).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('uses a dropdown selector when there are more than six seasons', () => {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let index = 1; index <= 7; index++) {
|
||||
seasons[String(index)] = [
|
||||
createEpisode({ id: String(100 + index), season: index }),
|
||||
];
|
||||
}
|
||||
setRequiredInputs(seasons);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill').length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('shows the back-to-playing chip only when the playing episode is in another season', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode()],
|
||||
'2': [createEpisode({ id: '201', season: 2 })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="back-to-playing"]'
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
const pills = fixture.nativeElement.querySelectorAll(
|
||||
'.season-tabs__pill'
|
||||
);
|
||||
(pills[1] as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="back-to-playing"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('marks the inline-playing episode card', () => {
|
||||
fixture.componentRef.setInput('playingEpisodeId', 101);
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.episode-card--playing')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens the episode info dialog and plays on the dialog play action', () => {
|
||||
dialogOpen.mockReturnValue({
|
||||
afterClosed: () => of(EPISODE_INFO_PLAY),
|
||||
});
|
||||
const played: XtreamSerieEpisode[] = [];
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
component.episodeClicked.subscribe((episode) => played.push(episode));
|
||||
fixture.detectChanges();
|
||||
|
||||
const infoButton = fixture.nativeElement.querySelector(
|
||||
'[data-testid="episode-info-button"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(infoButton).toBeTruthy();
|
||||
infoButton.click();
|
||||
|
||||
expect(dialogOpen).toHaveBeenCalledTimes(1);
|
||||
const data = dialogOpen.mock.calls[0][1].data;
|
||||
expect(data.episodeLabel).toBe('S01E01');
|
||||
expect(data.plot).toBe('Pilot episode');
|
||||
expect(played.length).toBe(1);
|
||||
});
|
||||
|
||||
it('hides the episode info button when the episode has no plot', () => {
|
||||
setRequiredInputs({
|
||||
'1': [createEpisode({ info: { duration: '45 min' } as never })],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="episode-info-button"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stale selected season when the input seasons change', () => {
|
||||
const episode = createEpisode();
|
||||
it('shows list thumbnails only for distinct episode images', () => {
|
||||
const withImages = (a: string, b: string) => ({
|
||||
'1': [
|
||||
createEpisode({ info: { movie_image: a } as never }),
|
||||
createEpisode({
|
||||
id: '102',
|
||||
episode_num: 2,
|
||||
info: { movie_image: b } as never,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const query = (selector: string) =>
|
||||
fixture.nativeElement.querySelectorAll(selector).length;
|
||||
|
||||
setRequiredInputs({ '1': [episode] });
|
||||
// Distinct stills → thumbnails replace the number square
|
||||
setRequiredInputs(withImages('still-1.jpg', 'still-2.jpg'));
|
||||
component.setViewMode('list');
|
||||
fixture.detectChanges();
|
||||
expect(component.listThumbnailsEnabled()).toBe(true);
|
||||
expect(query('.episode-list-item__thumb')).toBe(2);
|
||||
expect(query('.episode-list-item__number')).toBe(0);
|
||||
|
||||
// Same poster on every episode → number squares stay
|
||||
setRequiredInputs(withImages('poster.jpg', 'poster.jpg'));
|
||||
fixture.detectChanges();
|
||||
expect(component.listThumbnailsEnabled()).toBe(false);
|
||||
expect(query('.episode-list-item__thumb')).toBe(0);
|
||||
expect(query('.episode-list-item__number')).toBe(2);
|
||||
});
|
||||
|
||||
it('renders the season description for the selected season', () => {
|
||||
fixture.componentRef.setInput('seasonDescriptions', {
|
||||
'1': 'Season one overview',
|
||||
});
|
||||
setRequiredInputs({ '1': [createEpisode()] });
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.nativeElement.querySelector('.season-card').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentRef.setInput('seasons', { '2': [episode] });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason).toBeUndefined();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.season-card__number')
|
||||
?.textContent
|
||||
).toContain('2');
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-description"]'
|
||||
)?.textContent
|
||||
).toContain('Season one overview');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,19 @@
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DoCheck,
|
||||
ElementRef,
|
||||
OnInit,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
@@ -28,7 +31,21 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
|
||||
import { WatchedBadgeComponent } from '../watched-badge/watched-badge.component';
|
||||
import {
|
||||
buildXtreamEpisodeDownloadRequest,
|
||||
getEpisodeDownloadId,
|
||||
isStalkerEpisode,
|
||||
} from './episode-download.util';
|
||||
import {
|
||||
EPISODE_INFO_PLAY,
|
||||
EpisodeInfoDialogComponent,
|
||||
buildEpisodeInfoDialogData,
|
||||
} from './episode-info-dialog.component';
|
||||
import {
|
||||
formatEpisodePositionText,
|
||||
parseDuration,
|
||||
} from './episode-progress.util';
|
||||
import { SeasonTabsComponent } from './season-tabs.component';
|
||||
|
||||
type EpisodeViewMode = 'grid' | 'list';
|
||||
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
|
||||
@@ -44,47 +61,28 @@ export interface SeasonContainerPlaybackToggleRequest {
|
||||
nextPosition: PlaybackPositionData | null;
|
||||
}
|
||||
|
||||
function parseDuration(duration: string | number | undefined): number {
|
||||
if (!duration) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return duration;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
KeyValuePipe,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
ProgressCapsuleComponent,
|
||||
SeasonTabsComponent,
|
||||
TranslateModule,
|
||||
WatchedBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
export class SeasonContainerComponent implements OnInit {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly logger = createLogger('SeasonContainer');
|
||||
private previousSeasonKeysSignature = '';
|
||||
private lastEmittedSeason: string | undefined;
|
||||
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly seriesId = input.required<number>();
|
||||
@@ -97,7 +95,12 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
readonly xtreamDownloadContext =
|
||||
input<SeasonContainerXtreamDownloadContext | null>(null);
|
||||
readonly openingEpisodeId = input<number | null>(null);
|
||||
/** Episode currently playing in an EXTERNAL player session. */
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
/** Episode currently playing in the inline player. */
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
/** Per-season descriptions (TMDB/provider), keyed by season key. */
|
||||
readonly seasonDescriptions = input<Record<string, string> | null>(null);
|
||||
|
||||
readonly episodeClicked = output<XtreamSerieEpisode>();
|
||||
readonly episodeDownloadRequested = output<XtreamSerieEpisode>();
|
||||
@@ -107,7 +110,98 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
readonly isElectron = this.downloadsService.isAvailable;
|
||||
readonly viewMode = signal<EpisodeViewMode>('grid');
|
||||
|
||||
selectedSeason: string | undefined;
|
||||
readonly sortedSeasonKeys = computed(() =>
|
||||
Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b))
|
||||
);
|
||||
|
||||
readonly episodeCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
counts[key] = episodes?.length ?? 0;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
readonly watchedCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
counts[key] = (episodes ?? []).filter((episode) =>
|
||||
this.isEpisodeWatched(episode)
|
||||
).length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
/** Season key of the inline-playing episode, if it is in the loaded set. */
|
||||
readonly playingSeasonKey = computed(() =>
|
||||
this.findSeasonOfEpisode(this.playingEpisodeId())
|
||||
);
|
||||
|
||||
/**
|
||||
* Selected season. Auto-resolves when the season key set changes or when
|
||||
* playback positions first arrive (priority: inline-playing episode's
|
||||
* season → most recently updated in-progress episode's season → first
|
||||
* season); user tab clicks write to it and stick until the auto-select
|
||||
* key changes. Ongoing position saves do not reset the selection — only
|
||||
* the empty→loaded transition of the positions map does.
|
||||
*/
|
||||
readonly selectedSeason = signal<string | undefined>(undefined);
|
||||
|
||||
private readonly autoSelectKey = computed(
|
||||
() =>
|
||||
`${this.sortedSeasonKeys().join('|')}::${
|
||||
this.playbackPositions().size > 0 ? '1' : '0'
|
||||
}`
|
||||
);
|
||||
private lastAutoSelectKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Show thumbnails in the list view only when episodes have genuinely
|
||||
* distinct stills (TMDB or per-episode provider art). When every episode
|
||||
* carries the same image (providers often repeat the series poster) a
|
||||
* column of identical pictures is worse than the plain number square.
|
||||
*/
|
||||
readonly listThumbnailsEnabled = computed(() => {
|
||||
const episodes = this.selectedSeasonEpisodes();
|
||||
const images = episodes
|
||||
.map((episode) => this.getEpisodeInfo(episode)?.movie_image)
|
||||
.filter((image): image is string => !!image);
|
||||
if (images.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return episodes.length === 1 || new Set(images).size > 1;
|
||||
});
|
||||
|
||||
readonly selectedSeasonDescription = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (!selected) {
|
||||
return null;
|
||||
}
|
||||
return this.seasonDescriptions()?.[selected] ?? null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const key = this.autoSelectKey();
|
||||
if (key === this.lastAutoSelectKey) {
|
||||
return;
|
||||
}
|
||||
this.lastAutoSelectKey = key;
|
||||
this.selectedSeason.set(
|
||||
untracked(() => this.resolveAutoSeason())
|
||||
);
|
||||
});
|
||||
|
||||
// Fire the lazy-load/enrichment hooks for auto-selected seasons too —
|
||||
// with tabs there is no initial "pick a season" click anymore.
|
||||
effect(() => {
|
||||
const selected = this.selectedSeason();
|
||||
if (selected && selected !== this.lastEmittedSeason) {
|
||||
this.lastEmittedSeason = selected;
|
||||
this.seasonSelected.emit(selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const savedMode = localStorage.getItem(
|
||||
@@ -116,11 +210,6 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
if (savedMode === 'grid' || savedMode === 'list') {
|
||||
this.viewMode.set(savedMode);
|
||||
}
|
||||
this.previousSeasonKeysSignature = this.getSeasonKeys().join('|');
|
||||
}
|
||||
|
||||
ngDoCheck(): void {
|
||||
this.syncSelectedSeason();
|
||||
}
|
||||
|
||||
setViewMode(mode: EpisodeViewMode) {
|
||||
@@ -128,31 +217,68 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
compareSeasons(a: { key: string }, b: { key: string }): number {
|
||||
return Number(a.key) - Number(b.key);
|
||||
}
|
||||
|
||||
hasSeasons(): boolean {
|
||||
return this.getSeasonKeys().length > 0;
|
||||
return this.sortedSeasonKeys().length > 0;
|
||||
}
|
||||
|
||||
showSeriesEmptyState(): boolean {
|
||||
return !this.selectedSeason && !this.hasSeasons();
|
||||
return !this.hasSeasons();
|
||||
}
|
||||
|
||||
showSeasonEmptyState(): boolean {
|
||||
return Boolean(this.selectedSeason) && !this.selectedSeasonHasEpisodes();
|
||||
const selected = this.selectedSeason();
|
||||
return (
|
||||
Boolean(selected) &&
|
||||
(this.seasons()[selected as string]?.length ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
selectedSeasonEpisodes(): XtreamSerieEpisode[] {
|
||||
const selected = this.selectedSeason();
|
||||
return selected ? (this.seasons()[selected] ?? []) : [];
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason = seasonKey;
|
||||
this.seasonSelected.emit(seasonKey);
|
||||
this.selectedSeason.set(seasonKey);
|
||||
}
|
||||
|
||||
scrollToPlayingEpisode(): void {
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (!playingSeason) {
|
||||
return;
|
||||
}
|
||||
this.selectedSeason.set(playingSeason);
|
||||
// Wait a tick so the episode list re-renders for the new season.
|
||||
setTimeout(() => {
|
||||
const target = this.host.nativeElement.querySelector(
|
||||
`[data-episode-id="${this.playingEpisodeId()}"]`
|
||||
);
|
||||
target?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
selectEpisode(episode: XtreamSerieEpisode) {
|
||||
this.episodeClicked.emit(episode);
|
||||
}
|
||||
|
||||
openEpisodeInfo(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
this.dialog
|
||||
.open(EpisodeInfoDialogComponent, {
|
||||
data: buildEpisodeInfoDialogData(
|
||||
episode,
|
||||
this.selectedSeason()
|
||||
),
|
||||
autoFocus: false,
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result) => {
|
||||
if (result === EPISODE_INFO_PLAY) {
|
||||
this.selectEpisode(episode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
if (!this.playlistId()) {
|
||||
@@ -181,7 +307,9 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: this.seriesId(),
|
||||
seasonNumber: Number(episode.season || this.selectedSeason || 1),
|
||||
seasonNumber: Number(
|
||||
episode.season || this.selectedSeason() || 1
|
||||
),
|
||||
episodeNumber: Number(episode.episode_num || 1),
|
||||
positionSeconds: duration,
|
||||
durationSeconds: duration,
|
||||
@@ -216,70 +344,28 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
return this.activeEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
|
||||
isEpisodePlayingInline(episode: XtreamSerieEpisode): boolean {
|
||||
return this.playingEpisodeId() === this.getEpisodeContentId(episode);
|
||||
}
|
||||
|
||||
getEpisodeProgress(episode: XtreamSerieEpisode): number {
|
||||
return getPortalPlaybackProgressPercent(this.getEpisodePosition(episode));
|
||||
return getPortalPlaybackProgressPercent(
|
||||
this.getEpisodePosition(episode)
|
||||
);
|
||||
}
|
||||
|
||||
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
|
||||
if (this.isEpisodeWatched(episode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = this.getEpisodePosition(episode);
|
||||
if (!position || !position.positionSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let seconds = position.positionSeconds;
|
||||
let suffix = '';
|
||||
|
||||
if (position.durationSeconds && position.durationSeconds > 0) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
position.durationSeconds - position.positionSeconds
|
||||
);
|
||||
|
||||
if (remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seconds = remaining;
|
||||
suffix = ' left';
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
const formatted = [hours, minutes, secs]
|
||||
.map((value) => String(value).padStart(2, '0'))
|
||||
.filter((value, index) => (index === 0 ? value !== '00' : true))
|
||||
.join(':');
|
||||
return `${formatted}${suffix}`;
|
||||
return formatEpisodePositionText(this.getEpisodePosition(episode));
|
||||
}
|
||||
|
||||
getSeasonWatchedCount(seasonKey: string): number {
|
||||
const episodes = this.seasons()[seasonKey];
|
||||
if (!episodes) {
|
||||
return 0;
|
||||
}
|
||||
return episodes.filter((episode) => this.isEpisodeWatched(episode)).length;
|
||||
}
|
||||
|
||||
getSeasonProgressDash(seasonKey: string): string {
|
||||
const episodes = this.seasons()[seasonKey];
|
||||
if (!episodes || episodes.length === 0) {
|
||||
return '0, 100';
|
||||
}
|
||||
|
||||
const watched = this.getSeasonWatchedCount(seasonKey);
|
||||
const percent = (watched / episodes.length) * 100;
|
||||
return `${percent}, 100`;
|
||||
getEpisodeContentId(episode: XtreamSerieEpisode): number {
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.isStalkerEpisode(episode)) {
|
||||
if (isStalkerEpisode(episode)) {
|
||||
this.episodeDownloadRequested.emit(episode);
|
||||
return;
|
||||
}
|
||||
@@ -289,30 +375,17 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUrl = xtreamDownload.serverUrl?.replace(/\/$/, '') || '';
|
||||
const username = xtreamDownload.username || '';
|
||||
const password = xtreamDownload.password || '';
|
||||
const extension = episode.container_extension || 'mp4';
|
||||
const url = `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`;
|
||||
const episodeInfo = this.getEpisodeInfo(episode);
|
||||
const posterUrl = episodeInfo?.movie_image;
|
||||
const seasonNum = episode.season || Number(this.selectedSeason) || 1;
|
||||
const episodeNum = episode.episode_num || 1;
|
||||
const episodeTitle = `${this.seriesTitle() || 'Series'} - S${String(
|
||||
seasonNum
|
||||
).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
|
||||
|
||||
await this.downloadsService.startDownload({
|
||||
playlistId: this.playlistId(),
|
||||
xtreamId: Number(episode.id),
|
||||
contentType: 'episode',
|
||||
title: episodeTitle,
|
||||
url,
|
||||
posterUrl,
|
||||
seriesXtreamId: this.seriesId(),
|
||||
seasonNumber: seasonNum,
|
||||
episodeNumber: episodeNum,
|
||||
});
|
||||
await this.downloadsService.startDownload(
|
||||
buildXtreamEpisodeDownloadRequest({
|
||||
episode,
|
||||
context: xtreamDownload,
|
||||
playlistId: this.playlistId(),
|
||||
seriesId: this.seriesId(),
|
||||
seriesTitle: this.seriesTitle(),
|
||||
fallbackSeasonKey: this.selectedSeason(),
|
||||
posterUrl: this.getEpisodeInfo(episode)?.movie_image,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
|
||||
@@ -322,7 +395,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloaded(
|
||||
this.getEpisodeDownloadId(episode),
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
@@ -335,7 +408,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
|
||||
this.downloadsService.downloads();
|
||||
return this.downloadsService.isDownloading(
|
||||
this.getEpisodeDownloadId(episode),
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
@@ -348,7 +421,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
}
|
||||
|
||||
const filePath = this.downloadsService.getDownloadedFilePath(
|
||||
this.getEpisodeDownloadId(episode),
|
||||
getEpisodeDownloadId(episode),
|
||||
this.playlistId(),
|
||||
'episode'
|
||||
);
|
||||
@@ -358,89 +431,59 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
|
||||
}
|
||||
}
|
||||
|
||||
private isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
|
||||
return (
|
||||
(episode as { custom_sid?: string }).custom_sid === 'vod-series' ||
|
||||
(episode as { custom_sid?: string }).custom_sid === 'regular-series'
|
||||
);
|
||||
}
|
||||
|
||||
private getEpisodeContentId(episode: XtreamSerieEpisode): number {
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
|
||||
const customSid = (episode as { custom_sid?: string }).custom_sid;
|
||||
|
||||
if (customSid === 'regular-series') {
|
||||
const cmd = (episode as { originalCmd?: string }).originalCmd;
|
||||
if (cmd) {
|
||||
const match = cmd.match(/file_(\d+)/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
return this.hashString(cmd);
|
||||
}
|
||||
return Number(episode.id);
|
||||
}
|
||||
|
||||
if (customSid === 'vod-series') {
|
||||
const originalId = (episode as { originalId?: string | number })
|
||||
.originalId;
|
||||
const numericId = Number(originalId);
|
||||
return Number.isNaN(numericId)
|
||||
? this.hashString(String(originalId))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
const numericId = Number(episode.id);
|
||||
return Number.isNaN(numericId)
|
||||
? this.hashString(String(episode.id))
|
||||
: numericId;
|
||||
}
|
||||
|
||||
private getEpisodePosition(
|
||||
episode: XtreamSerieEpisode
|
||||
): PlaybackPositionData | undefined {
|
||||
return this.playbackPositions().get(this.getEpisodeContentId(episode));
|
||||
}
|
||||
|
||||
private selectedSeasonHasEpisodes(): boolean {
|
||||
if (!this.selectedSeason) {
|
||||
return false;
|
||||
private findSeasonOfEpisode(episodeId: number | null): string | null {
|
||||
if (episodeId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (this.seasons()[this.selectedSeason]?.length ?? 0) > 0;
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
if (
|
||||
episodes?.some(
|
||||
(episode) => this.getEpisodeContentId(episode) === episodeId
|
||||
)
|
||||
) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getSeasonKeys(): string[] {
|
||||
return Object.keys(this.seasons());
|
||||
}
|
||||
|
||||
private hasSeasonKey(seasonKey: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(this.seasons(), seasonKey);
|
||||
}
|
||||
|
||||
private syncSelectedSeason(): void {
|
||||
const seasonKeysSignature = this.getSeasonKeys().join('|');
|
||||
if (seasonKeysSignature === this.previousSeasonKeysSignature) {
|
||||
return;
|
||||
private resolveAutoSeason(): string | undefined {
|
||||
const keys = this.sortedSeasonKeys();
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.previousSeasonKeysSignature = seasonKeysSignature;
|
||||
|
||||
if (this.selectedSeason && !this.hasSeasonKey(this.selectedSeason)) {
|
||||
this.selectedSeason = undefined;
|
||||
const playingSeason = this.playingSeasonKey();
|
||||
if (playingSeason) {
|
||||
return playingSeason;
|
||||
}
|
||||
|
||||
const resumeSeason = this.findMostRecentInProgressSeason();
|
||||
return resumeSeason ?? keys[0];
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < str.length; index++) {
|
||||
const char = str.charCodeAt(index);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash &= hash;
|
||||
private findMostRecentInProgressSeason(): string | null {
|
||||
let bestSeason: string | null = null;
|
||||
let bestUpdatedAt = '';
|
||||
for (const [key, episodes] of Object.entries(this.seasons())) {
|
||||
for (const episode of episodes ?? []) {
|
||||
const position = this.getEpisodePosition(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
continue;
|
||||
}
|
||||
const updatedAt = position?.updatedAt ?? '';
|
||||
if (updatedAt >= bestUpdatedAt) {
|
||||
bestUpdatedAt = updatedAt;
|
||||
bestSeason = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.abs(hash);
|
||||
return bestSeason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<div class="season-tabs" role="tablist">
|
||||
@if (useDropdown()) {
|
||||
<button
|
||||
class="season-tabs__dropdown"
|
||||
data-testid="season-dropdown"
|
||||
[matMenuTriggerFor]="seasonMenu"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: selectedSeason() ?? '—' }
|
||||
}}
|
||||
</span>
|
||||
<mat-icon>arrow_drop_down</mat-icon>
|
||||
</button>
|
||||
<mat-menu #seasonMenu="matMenu">
|
||||
@for (seasonKey of seasonKeys(); track seasonKey) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[class.season-tabs__menu-item--selected]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
| translate: { number: seasonKey }
|
||||
}}
|
||||
</span>
|
||||
<span class="season-tabs__menu-count">
|
||||
{{ episodeCounts()[seasonKey] }}
|
||||
</span>
|
||||
@if (isSeasonCompleted(seasonKey)) {
|
||||
<mat-icon class="season-tabs__done">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
} @else {
|
||||
@for (seasonKey of seasonKeys(); track seasonKey) {
|
||||
<button
|
||||
class="season-tabs__pill"
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="seasonKey === selectedSeason()"
|
||||
[class.season-tabs__pill--active]="
|
||||
seasonKey === selectedSeason()
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
{{ 'PORTALS.SEASON_TAB' | translate: { number: seasonKey } }}
|
||||
@if (isSeasonCompleted(seasonKey)) {
|
||||
<mat-icon class="season-tabs__done">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showBackToPlaying()) {
|
||||
<button
|
||||
class="season-tabs__back-to-playing"
|
||||
data-testid="back-to-playing"
|
||||
type="button"
|
||||
(click)="backToPlayingRequested.emit()"
|
||||
>
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
{{ 'PORTALS.BACK_TO_PLAYING_EPISODE' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (seasonDescription(); as description) {
|
||||
<app-expandable-text
|
||||
class="season-tabs__description"
|
||||
data-testid="season-description"
|
||||
[text]="description"
|
||||
[clampLines]="3"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.season-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-primary, #fff);
|
||||
font-weight: 600;
|
||||
outline: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px 8px 16px;
|
||||
border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__menu-count {
|
||||
margin-left: 12px;
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__done {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--accent-success, #4ade80);
|
||||
}
|
||||
|
||||
&__back-to-playing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2));
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #fff);
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-item--selected {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.season-tabs__description {
|
||||
margin: 10px 4px 0;
|
||||
max-width: 760px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||
// Clamping + the show more/less toggle live in app-expandable-text.
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
|
||||
|
||||
/** Above this count the pill row becomes a dropdown selector. */
|
||||
const MAX_SEASON_PILLS = 6;
|
||||
|
||||
/**
|
||||
* Season selector for the season container: a pill row ("Season 1 · 2 · 3")
|
||||
* for up to 6 seasons, a dropdown beyond that. Shows an optional season
|
||||
* description under the tabs and a "back to playing episode" chip when the
|
||||
* currently playing episode belongs to a different season.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-season-tabs',
|
||||
templateUrl: './season-tabs.component.html',
|
||||
styleUrls: ['./season-tabs.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ExpandableTextComponent, MatIcon, MatMenuModule, TranslateModule],
|
||||
})
|
||||
export class SeasonTabsComponent {
|
||||
/** Season keys, already sorted in display order. */
|
||||
readonly seasonKeys = input.required<string[]>();
|
||||
readonly selectedSeason = input<string | undefined>(undefined);
|
||||
readonly episodeCounts = input<Record<string, number>>({});
|
||||
readonly watchedCounts = input<Record<string, number>>({});
|
||||
/** Description of the selected season (TMDB/provider), if available. */
|
||||
readonly seasonDescription = input<string | null>(null);
|
||||
/** Season key of the episode currently playing inline, if any. */
|
||||
readonly playingSeasonKey = input<string | null>(null);
|
||||
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly backToPlayingRequested = output<void>();
|
||||
|
||||
readonly useDropdown = computed(
|
||||
() => this.seasonKeys().length > MAX_SEASON_PILLS
|
||||
);
|
||||
|
||||
readonly showBackToPlaying = computed(() => {
|
||||
const playing = this.playingSeasonKey();
|
||||
return playing !== null && playing !== this.selectedSeason();
|
||||
});
|
||||
|
||||
isSeasonCompleted(seasonKey: string): boolean {
|
||||
const total = this.episodeCounts()[seasonKey] ?? 0;
|
||||
return total > 0 && (this.watchedCounts()[seasonKey] ?? 0) >= total;
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string): void {
|
||||
if (seasonKey !== this.selectedSeason()) {
|
||||
this.seasonSelected.emit(seasonKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,30 @@
|
||||
@if (hasPlayback()) {
|
||||
<section class="player-shell" aria-label="Inline player">
|
||||
<header class="player-shell__header">
|
||||
<div class="player-shell__eyebrow">Now Playing</div>
|
||||
<div class="player-shell__title-row">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-shell__back"
|
||||
data-testid="inline-player-back"
|
||||
[attr.aria-label]="'BACK' | translate"
|
||||
[matTooltip]="'BACK' | translate"
|
||||
(click)="onBack()"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<div class="player-shell__heading">
|
||||
<div class="player-shell__eyebrow">
|
||||
{{ 'PORTALS.NOW_PLAYING' | translate }}
|
||||
</div>
|
||||
<h3 class="player-shell__title" [matTooltip]="title()">
|
||||
{{ title() }}
|
||||
@if (episodeMetadataText(); as episodeText) {
|
||||
<span class="player-shell__episode-meta">
|
||||
· {{ episodeText }}
|
||||
</span>
|
||||
}
|
||||
</h3>
|
||||
@if (episodeMetadataText(); as episodeText) {
|
||||
<div class="player-shell__episode-meta">
|
||||
{{ episodeText }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="player-shell__actions">
|
||||
<button
|
||||
@@ -22,7 +35,7 @@
|
||||
(click)="onCopied()"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
Copy Stream URL
|
||||
{{ 'PORTALS.COPY_STREAM_URL' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
@@ -30,7 +43,7 @@
|
||||
(click)="onClose()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
Close Player
|
||||
{{ 'PORTALS.CLOSE_PLAYER' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
box-shadow:
|
||||
0 20px 56px rgba(25, 20, 20, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(18px);
|
||||
// No backdrop-filter here: blurring over a live video viewport forces an
|
||||
// expensive re-blur every frame and makes the browse↔watch morph stutter.
|
||||
}
|
||||
|
||||
.player-shell__header {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
// Allow the nowrap title to shrink instead of widening the grid track
|
||||
// and pushing the actions out of the viewport.
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-shell__eyebrow {
|
||||
@@ -35,11 +39,17 @@
|
||||
.player-shell__title-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-shell__back {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.player-shell__heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -49,17 +59,15 @@
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #161616;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-shell__episode-meta {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
color: rgba(26, 26, 26, 0.64);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-shell__actions {
|
||||
|
||||
+31
-1
@@ -1,6 +1,7 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
@@ -46,7 +47,7 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PortalInlinePlayerComponent],
|
||||
imports: [PortalInlinePlayerComponent, TranslateModule.forRoot()],
|
||||
})
|
||||
.overrideComponent(PortalInlinePlayerComponent, {
|
||||
remove: {
|
||||
@@ -120,4 +121,33 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
|
||||
expect(events).toEqual(['ended', 'previous', 'next']);
|
||||
});
|
||||
|
||||
it('emits backClicked (not closed) from the back button in the now-playing bar', () => {
|
||||
let backCount = 0;
|
||||
let closedCount = 0;
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl: 'https://example.test/vod/1.mp4',
|
||||
title: 'Movie',
|
||||
});
|
||||
(
|
||||
component as unknown as {
|
||||
backClicked: { subscribe: (fn: () => void) => void };
|
||||
}
|
||||
).backClicked.subscribe(() => backCount++);
|
||||
(
|
||||
component as unknown as {
|
||||
closed: { subscribe: (fn: () => void) => void };
|
||||
}
|
||||
).closed.subscribe(() => closedCount++);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const backButton = fixture.nativeElement.querySelector(
|
||||
'[data-testid="inline-player-back"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(backButton).toBeTruthy();
|
||||
backButton.click();
|
||||
expect(backCount).toBe(1);
|
||||
expect(closedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
PlayerContentInfo,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -60,6 +62,8 @@ export class PortalInlinePlayerComponent {
|
||||
});
|
||||
|
||||
readonly closed = output<void>();
|
||||
/** Back arrow in the now-playing bar: route-level back, not just close. */
|
||||
readonly backClicked = output<void>();
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -74,6 +78,10 @@ export class PortalInlinePlayerComponent {
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
onBack(): void {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
onTimeUpdate(event: { currentTime: number; duration: number }): void {
|
||||
this.timeUpdate.emit(event);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
@let meta = normalizedMeta();
|
||||
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<app-content-hero
|
||||
<app-portal-detail-shell
|
||||
[title]="meta.title"
|
||||
[description]="meta.description"
|
||||
[posterUrl]="meta.posterUrl"
|
||||
[backdropUrl]="meta.backdropUrl"
|
||||
[playbackActive]="!!inlinePlayback()"
|
||||
(backClicked)="goBack()"
|
||||
(closePlayerRequested)="closeInlinePlayback()"
|
||||
>
|
||||
<!-- Tags: Year, Genre, Duration, Rating -->
|
||||
<ng-container hero-tags>
|
||||
<ng-template appDetailTags>
|
||||
@if (meta.year) {
|
||||
<span class="details__tag">{{ meta.year }}</span>
|
||||
}
|
||||
@@ -27,10 +28,10 @@
|
||||
@if (meta.country) {
|
||||
<span class="details__tag">{{ meta.country }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
<ng-template appDetailMeta>
|
||||
@if (meta.actors || meta.tmdbCast?.length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
@@ -86,10 +87,10 @@
|
||||
<span class="value">{{ meta.ratingKinopoisk }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<ng-template appDetailActions>
|
||||
<!-- Resume/Play buttons -->
|
||||
@if (hasPlaybackPosition()) {
|
||||
<button
|
||||
@@ -172,13 +173,15 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playback]="playback"
|
||||
(timeUpdate)="onInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayback()"
|
||||
(backClicked)="goBack()"
|
||||
(streamUrlCopied)="onStreamUrlCopied()"
|
||||
(externalFallbackRequested)="
|
||||
onInlineExternalFallbackRequested($event)
|
||||
@@ -186,19 +189,21 @@
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- YouTube Trailer (provider or TMDB enrichment) -->
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="trailerUrl | safe"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<div detail-extras>
|
||||
<!-- YouTube Trailer (provider or TMDB enrichment) -->
|
||||
@if (trailerEmbedUrl(); as trailerUrl) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="trailerUrl | safe"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</app-content-hero>
|
||||
}
|
||||
</div>
|
||||
</app-portal-detail-shell>
|
||||
@@ -9,7 +9,12 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import { ContentHeroComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
@@ -48,8 +53,11 @@ import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inli
|
||||
templateUrl: './vod-details.component.html',
|
||||
styleUrls: ['../styles/detail-view.scss'],
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
MatIcon,
|
||||
PortalDetailShellComponent,
|
||||
PortalInlinePlayerComponent,
|
||||
SafePipe,
|
||||
TranslatePipe,
|
||||
|
||||
Reference in new issue
Block a user