From 2d7811eb5f7efb045e756683829f1a038a68c636 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:30:57 +0200 Subject: [PATCH] feat(portals): add "View in portal" action to inline collection details (#1422) --- .changes/portals-collection-view-in-portal.md | 9 + CLAUDE.md | 39 ++- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/portal-detail-navigation.md | 15 ++ libs/portal/shared/util/src/index.ts | 1 + ...ollection-detail-portal-navigation.spec.ts | 238 ++++++++++++++++++ .../collection-detail-portal-navigation.ts | 136 ++++++++++ ...talker-collection-detail.component.spec.ts | 66 ++++- .../stalker-collection-detail.component.ts | 40 ++- .../stalker-series-view.component.html | 1 + .../stalker-series-view.component.ts | 2 + .../serial-details.component.html | 1 + .../serial-details.component.ts | 2 + .../vod-details-route.component.html | 1 + .../vod-details-route.component.ts | 2 + ...xtream-collection-detail.component.spec.ts | 64 ++++- .../lib/xtream-collection-detail.component.ts | 37 ++- libs/ui/components/src/index.ts | 2 + .../view-in-portal-action.component.spec.ts | 115 +++++++++ .../view-in-portal-action.component.ts | 75 ++++++ .../view-in-portal-handoff.token.ts | 21 ++ .../vod-details/vod-details.component.html | 1 + .../lib/vod-details/vod-details.component.ts | 2 + 41 files changed, 900 insertions(+), 8 deletions(-) create mode 100644 .changes/portals-collection-view-in-portal.md create mode 100644 libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts create mode 100644 libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts create mode 100644 libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.spec.ts create mode 100644 libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.ts create mode 100644 libs/ui/components/src/lib/view-in-portal-action/view-in-portal-handoff.token.ts diff --git a/.changes/portals-collection-view-in-portal.md b/.changes/portals-collection-view-in-portal.md new file mode 100644 index 000000000..1bd7ea5d7 --- /dev/null +++ b/.changes/portals-collection-view-in-portal.md @@ -0,0 +1,9 @@ +--- +type: feature +area: portals +--- + +Movie and series details opened from the dashboard, global favorites, history, +or a portal's favorites/recent tabs now offer a "View in portal" button below +the main actions. It jumps straight to the title inside its own portal, with +the matching category selected in the sidebar. diff --git a/CLAUDE.md b/CLAUDE.md index 2494b0d73..5569f14f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1198,7 +1198,9 @@ engine` (restart required) or form a metadata-only target; unproven episode and legacy-movie handoffs stay unavailable. The normal detail uses one-shot `provider-only` presentation: it exposes provider content/playback it can resolve while hiding - Offline/local/download actions. + Offline/local/download actions. A second, independent `View in portal` + bridge exists for inline collection details — see **Collection Detail + Portal Handoff** below; it deliberately does NOT use `provider-only`. - Download rows and local files survive source deletion. The global offline library remains visible with no playlists; only provider handoff is disabled until the source exists again. @@ -1208,6 +1210,41 @@ engine` (restart required) or - Canonical contract: `docs/architecture/download-manager.md`; provider handoff: `docs/architecture/portal-detail-navigation.md`. +**Collection Detail Portal Handoff** (`View in portal` for inline details): + +- Details opened outside portal category context — `/workspace/global-favorites`, + `/workspace/global-recent` (which also receive the dashboard hero, Continue + Watching and favorites-rail handoffs), and a portal's own `favorites`/`recent` + tabs — render full-width with no category sidebar. They expose a separate-row + hero action that jumps to the item inside its owning portal. +- Visibility is DI-gated, never URL-sniffed: `app-view-in-portal-action` + (`libs/ui/components/src/lib/view-in-portal-action/`) renders only when a host + provides `VIEW_IN_PORTAL_HANDOFF`. The sole providers are + `XtreamCollectionDetailComponent` (through its dynamic detail injector) and + `StalkerCollectionDetailComponent` (component providers), which exist only in + collection contexts — so router-mounted category details need no opt-out. When + hidden the host must stay `display: none`, or its `flex: 0 0 100%` would claim + a phantom row in the hero action container. +- Targets come from `getUnifiedCollectionDetailNavigation()` + (`libs/portal/shared/util/.../collection-detail-portal-navigation.ts`). Unlike + `getUnifiedCollectionNavigation` it NEVER degrades to a category- or + section-only route: an Xtream item without a resolvable category and positive + item id keeps the action hidden rather than promising a jump to the title and + landing in a list. +- Stalker section resolution mirrors + `StalkerCollectionDetailComponent.resolveDetailMode()` and must not be + simplified to `item.contentType`: `extractStalkerItemType()` reports `series` + for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but + both belong in the VOD catalog — the lazy season/episode fetch in + `StalkerCatalogFacadeService.selectItem()` is gated on the VOD content type, so + a `/series` route leaves the detail unable to load episodes. The virtual + `series` category is normalized to `vod` the same way + `resolveSelectedCategory()` does. Stalker also carries `stalkerReturnTo`. +- Unlike the download handoff this bridge does NOT pass + `detailPresentation: 'provider-only'` — the item exists in the provider + catalog, so the full normal detail (downloads included) is wanted. +- Contract: `docs/architecture/portal-detail-navigation.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) diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 889665632..b5201dcaf 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "تصفح جميع الأفلام", "BROWSE_ALL_SERIES": "تصفح جميع المسلسلات", "BROWSE_ALL_LIVE": "تصفح جميع قنوات البث المباشر", + "VIEW_IN_PORTAL": "عرض في البوابة", + "VIEW_IN_PORTAL_TOOLTIP": "فتح في {{name}}", "SIDEBAR": { "MAIN": "الرئيسية", "MOVIES": "الأفلام", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index fd78da686..c4f5b4881 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "تصفح جميع الأفلام", "BROWSE_ALL_SERIES": "تصفح جميع السلاسل", "BROWSE_ALL_LIVE": "تصفح جميع البث المباشر", + "VIEW_IN_PORTAL": "شوف فالبوابة", + "VIEW_IN_PORTAL_TOOLTIP": "افتح ف {{name}}", "SIDEBAR": { "MAIN": "الرئيسي", "MOVIES": "الأفلام", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 39d59a8dc..9df491d9e 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Прагледзець усе фільмы", "BROWSE_ALL_SERIES": "Прагледзець усе серыялы", "BROWSE_ALL_LIVE": "Прагледзець увесь прамы эфір", + "VIEW_IN_PORTAL": "Паказаць у партале", + "VIEW_IN_PORTAL_TOOLTIP": "Адкрыць у {{name}}", "SIDEBAR": { "MAIN": "Галоўная", "MOVIES": "Фільмы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 62e78dada..96485d45d 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Alle Filme durchsuchen", "BROWSE_ALL_SERIES": "Alle Serien durchsuchen", "BROWSE_ALL_LIVE": "Gesamtes Live-TV durchsuchen", + "VIEW_IN_PORTAL": "Im Portal anzeigen", + "VIEW_IN_PORTAL_TOOLTIP": "In {{name}} öffnen", "SIDEBAR": { "MAIN": "Allgemein", "MOVIES": "Filme", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index d4c85f84e..d8e278435 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Περιήγηση σε όλες τις ταινίες", "BROWSE_ALL_SERIES": "Περιήγηση σε όλες τις σειρές", "BROWSE_ALL_LIVE": "Περιήγηση σε όλη τη ζωντανή TV", + "VIEW_IN_PORTAL": "Προβολή στην πύλη", + "VIEW_IN_PORTAL_TOOLTIP": "Άνοιγμα σε {{name}}", "SIDEBAR": { "MAIN": "Κύριο", "MOVIES": "Ταινίες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 253449105..22e1751e1 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Browse all movies", "BROWSE_ALL_SERIES": "Browse all series", "BROWSE_ALL_LIVE": "Browse all live TV", + "VIEW_IN_PORTAL": "View in portal", + "VIEW_IN_PORTAL_TOOLTIP": "Open in {{name}}", "SIDEBAR": { "MAIN": "Main", "MOVIES": "Movies", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 010cb3106..4c46088fb 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Ver todas las películas", "BROWSE_ALL_SERIES": "Ver todas las series", "BROWSE_ALL_LIVE": "Ver toda la TV en vivo", + "VIEW_IN_PORTAL": "Ver en el portal", + "VIEW_IN_PORTAL_TOOLTIP": "Abrir en {{name}}", "SIDEBAR": { "MAIN": "Principal", "MOVIES": "Películas", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index c2f2daadd..633c17d41 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Parcourir tous les films", "BROWSE_ALL_SERIES": "Parcourir toutes les séries", "BROWSE_ALL_LIVE": "Parcourir toute la TV en direct", + "VIEW_IN_PORTAL": "Voir dans le portail", + "VIEW_IN_PORTAL_TOOLTIP": "Ouvrir dans {{name}}", "SIDEBAR": { "MAIN": "Principal", "MOVIES": "Films", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 6e798194b..4dea2eae4 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Összes film böngészése", "BROWSE_ALL_SERIES": "Összes sorozat böngészése", "BROWSE_ALL_LIVE": "Összes élő csatorna böngészése", + "VIEW_IN_PORTAL": "Megtekintés a portálon", + "VIEW_IN_PORTAL_TOOLTIP": "Megnyitás: {{name}}", "SIDEBAR": { "MAIN": "Főmenü", "MOVIES": "Filmek", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index f2b6818db..6341b95f6 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Sfoglia tutti i film", "BROWSE_ALL_SERIES": "Sfoglia tutte le serie", "BROWSE_ALL_LIVE": "Sfoglia tutta la TV in diretta", + "VIEW_IN_PORTAL": "Mostra nel portale", + "VIEW_IN_PORTAL_TOOLTIP": "Apri in {{name}}", "SIDEBAR": { "MAIN": "Principale", "MOVIES": "Film", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 7428894db..6fae6f715 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "すべての映画を表示", "BROWSE_ALL_SERIES": "すべてのシリーズを表示", "BROWSE_ALL_LIVE": "すべてのライブTVを表示", + "VIEW_IN_PORTAL": "ポータルで表示", + "VIEW_IN_PORTAL_TOOLTIP": "{{name}}で開く", "SIDEBAR": { "MAIN": "メイン", "MOVIES": "映画", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 1812618d7..29225b983 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "모든 영화 둘러보기", "BROWSE_ALL_SERIES": "모든 시리즈 둘러보기", "BROWSE_ALL_LIVE": "모든 라이브 TV 둘러보기", + "VIEW_IN_PORTAL": "포털에서 보기", + "VIEW_IN_PORTAL_TOOLTIP": "{{name}}에서 열기", "SIDEBAR": { "MAIN": "메인", "MOVIES": "영화", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 4b42f3444..f153a26fb 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Bekijk alle films", "BROWSE_ALL_SERIES": "Bekijk alle series", "BROWSE_ALL_LIVE": "Bekijk alle live tv", + "VIEW_IN_PORTAL": "In portal bekijken", + "VIEW_IN_PORTAL_TOOLTIP": "Openen in {{name}}", "SIDEBAR": { "MAIN": "Hoofd", "MOVIES": "Films", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ac76bd2b0..8033b3e9b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Przeglądaj wszystkie filmy", "BROWSE_ALL_SERIES": "Przeglądaj wszystkie seriale", "BROWSE_ALL_LIVE": "Przeglądaj całą telewizję na żywo", + "VIEW_IN_PORTAL": "Pokaż w portalu", + "VIEW_IN_PORTAL_TOOLTIP": "Otwórz w {{name}}", "SIDEBAR": { "MAIN": "Główne", "MOVIES": "Filmy", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 808ddb1b7..bc56b2be6 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Ver todos os filmes", "BROWSE_ALL_SERIES": "Ver todas as séries", "BROWSE_ALL_LIVE": "Ver toda a TV ao vivo", + "VIEW_IN_PORTAL": "Ver no portal", + "VIEW_IN_PORTAL_TOOLTIP": "Abrir em {{name}}", "SIDEBAR": { "MAIN": "Principal", "MOVIES": "Filmes", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 6d1c506f1..984ffed32 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Все фильмы", "BROWSE_ALL_SERIES": "Все сериалы", "BROWSE_ALL_LIVE": "Всё Live TV", + "VIEW_IN_PORTAL": "Открыть в портале", + "VIEW_IN_PORTAL_TOOLTIP": "Открыть в {{name}}", "SIDEBAR": { "MAIN": "Главная", "MOVIES": "Фильмы", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index b042eaeeb..f108a3845 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "Tüm filmlere göz at", "BROWSE_ALL_SERIES": "Tüm dizilere göz at", "BROWSE_ALL_LIVE": "Tüm canlı TV'ye göz at", + "VIEW_IN_PORTAL": "Portalda görüntüle", + "VIEW_IN_PORTAL_TOOLTIP": "{{name}} içinde aç", "SIDEBAR": { "MAIN": "Ana Menü", "MOVIES": "Filmler", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index f7428fb46..6bbafacca 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "浏览所有电影", "BROWSE_ALL_SERIES": "浏览所有剧集", "BROWSE_ALL_LIVE": "浏览所有直播电视", + "VIEW_IN_PORTAL": "在门户中查看", + "VIEW_IN_PORTAL_TOOLTIP": "在{{name}}中打开", "SIDEBAR": { "MAIN": "主页", "MOVIES": "电影", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 5dead155d..d9e1ff643 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -958,6 +958,8 @@ "BROWSE_ALL_MOVIES": "瀏覽所有電影", "BROWSE_ALL_SERIES": "瀏覽所有影集", "BROWSE_ALL_LIVE": "瀏覽所有直播電視", + "VIEW_IN_PORTAL": "在 Portal 中檢視", + "VIEW_IN_PORTAL_TOOLTIP": "在 {{name}} 中開啟", "SIDEBAR": { "MAIN": "主頁", "MOVIES": "電影", diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index dde0ed4be..894e66989 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -39,6 +39,21 @@ Related: the one-shot `detailPresentation: 'provider-only'` navigation state. The destination keeps provider playback and whatever catalog the normal provider host can resolve, but hides Offline/local/download presentation. +- Inline collection details (global favorites/recent — which also receive the + dashboard hero and Continue Watching handoffs — plus a portal's own + favorites/recent tabs) expose the same bridge as a separate-row hero action. + The shared `app-view-in-portal-action` (`libs/ui/components`) renders only + when a host provides `VIEW_IN_PORTAL_HANDOFF`; the two collection-detail + wrappers (`xtream-collection-detail.component.ts`, + `stalker-collection-detail.component.ts`) are the only providers, so + router-mounted category details never show the button. Targets come from + `getUnifiedCollectionDetailNavigation()` (`libs/portal/shared/util`), which + never degrades to a category- or section-only route: an Xtream item without a + resolvable category and positive item id keeps the button hidden. Unlike the + download handoff it does NOT pass `detailPresentation: 'provider-only'` — the + item exists in the provider catalog and the full normal detail is desired. + The Stalker handoff carries `stalkerReturnTo` so the portal detail's back + affordance returns to the originating collection. - Do not force both portals into the same browse/detail behavior unless the full portal detail architecture is being changed. diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 54f6692e3..e4be18af0 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -21,6 +21,7 @@ export * from './lib/remote-channel-navigation'; export * from './lib/workspace-header-context.service'; export * from './lib/workspace-view-command.types'; export * from './lib/workspace-view-command.service'; +export * from './lib/navigation/collection-detail-portal-navigation'; export * from './lib/navigation/portal-rail-links'; export * from './lib/navigation/provider-detail-mode'; export * from './lib/navigation/portal-route.utils'; diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts new file mode 100644 index 000000000..c46dc19a4 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts @@ -0,0 +1,238 @@ +import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface'; +import { getUnifiedCollectionDetailNavigation } from './collection-detail-portal-navigation'; + +describe('getUnifiedCollectionDetailNavigation', () => { + const xtreamMovie: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::movie:99', + name: 'Movie One', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream Playlist', + xtreamId: 99, + categoryId: 42, + }; + + it('builds the full Xtream detail route for a movie', () => { + expect(getUnifiedCollectionDetailNavigation(xtreamMovie)).toEqual({ + link: ['/workspace', 'xtreams', 'xtream-1', 'vod', '42', '99'], + }); + }); + + it('builds the full Xtream detail route for a series', () => { + expect( + getUnifiedCollectionDetailNavigation({ + ...xtreamMovie, + uid: 'xtream::xtream-1::series:103', + contentType: 'series', + xtreamId: 103, + categoryId: 7, + }) + ).toEqual({ + link: ['/workspace', 'xtreams', 'xtream-1', 'series', '7', '103'], + }); + }); + + it('returns null instead of a degraded link when the category is missing', () => { + expect( + getUnifiedCollectionDetailNavigation({ + ...xtreamMovie, + categoryId: undefined, + }) + ).toBeNull(); + expect( + getUnifiedCollectionDetailNavigation({ + ...xtreamMovie, + categoryId: ' ', + }) + ).toBeNull(); + }); + + it('returns null when no positive Xtream item id is resolvable', () => { + expect( + getUnifiedCollectionDetailNavigation({ + ...xtreamMovie, + uid: 'xtream::xtream-1::movie:abc', + xtreamId: undefined, + }) + ).toBeNull(); + expect( + getUnifiedCollectionDetailNavigation({ + ...xtreamMovie, + xtreamId: -5, + uid: 'xtream::xtream-1::movie:-5', + }) + ).toBeNull(); + }); + + it('falls back to the numeric uid tail when xtreamId is absent', () => { + expect( + getUnifiedCollectionDetailNavigation({ + ...xtreamMovie, + xtreamId: undefined, + uid: 'xtream::xtream-1::77', + }) + ).toEqual({ + link: ['/workspace', 'xtreams', 'xtream-1', 'vod', '42', '77'], + }); + }); + + it('builds a Stalker category route with openStalkerItem state', () => { + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::series-9', + name: 'Series Nine', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'series-9', + categoryId: '44', + posterUrl: 'https://example.com/poster.png', + }) + ).toEqual({ + link: ['/workspace', 'stalker', 'stalker-1', 'series', '44'], + state: { + openStalkerItem: expect.objectContaining({ + id: 'series-9', + category_id: '44', + title: 'Series Nine', + }), + }, + }); + }); + + it('keeps a lazy Ministra VOD is_series item on the VOD route', () => { + // extractStalkerItemType() reports `series` for the is_series flag, but + // the lazy season/episode fetch only runs in the VOD catalog. + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::vod-77', + name: 'Lazy VOD Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'vod-77', + categoryId: '12', + stalkerItem: { + id: 'vod-77', + title: 'Lazy VOD Series', + category_id: '12', + is_series: '1', + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'vod', '12']); + }); + + it('keeps an embedded series[] snapshot on the VOD route', () => { + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::vod-88', + name: 'Embedded Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'vod-88', + categoryId: '12', + stalkerItem: { + id: 'vod-88', + title: 'Embedded Series', + category_id: '12', + series: [1, 2, 3], + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'vod', '12']); + }); + + it('normalizes the virtual series category for a VOD-catalog item', () => { + // Persisted from the series view, so it carries category_id 'series' + // while still belonging to the VOD catalog. + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::vod-99', + name: 'Lazy VOD Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'vod-99', + categoryId: 'series', + stalkerItem: { + id: 'vod-99', + title: 'Lazy VOD Series', + category_id: 'series', + is_series: true, + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'vod', 'vod']); + }); + + it('still routes a regular Stalker series to the series catalog', () => { + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::series-9', + name: 'Regular Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'series-9', + categoryId: '44', + stalkerItem: { + id: 'series-9', + title: 'Regular Series', + category_id: '44', + } as never, + })?.link + ).toEqual(['/workspace', 'stalker', 'stalker-1', 'series', '44']); + }); + + it('carries stalkerReturnTo when a returnTo option is passed', () => { + const navigation = getUnifiedCollectionDetailNavigation( + { + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + }, + { returnTo: '/workspace/global-recent' } + ); + + expect(navigation?.link).toEqual([ + '/workspace', + 'stalker', + 'stalker-1', + 'vod', + 'vod', + ]); + expect(navigation?.state).toEqual( + expect.objectContaining({ + stalkerReturnTo: '/workspace/global-recent', + }) + ); + }); + + it('returns null for live and m3u items', () => { + expect( + getUnifiedCollectionDetailNavigation({ + ...xtreamMovie, + contentType: 'live', + }) + ).toBeNull(); + expect( + getUnifiedCollectionDetailNavigation({ + uid: 'm3u::m3u-1::https://example.com/live.m3u8', + name: 'Channel One', + contentType: 'movie', + sourceType: 'm3u', + playlistId: 'm3u-1', + playlistName: 'M3U Playlist', + }) + ).toBeNull(); + }); +}); diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts new file mode 100644 index 000000000..4eb39f8b9 --- /dev/null +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts @@ -0,0 +1,136 @@ +import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface'; +import { + buildStalkerDetailNavigationTarget, + buildStalkerStateItem, + buildXtreamNavigationTarget, + WorkspaceNavigationTarget, +} from './workspace-portal-navigation'; +import { + isStalkerSeriesFlag, + StalkerPortalItem, +} from '@iptvnator/shared/interfaces'; + +/** + * Builds the portal-detail target for a collection item, or `null` when no + * exact detail route can be formed. Unlike `getUnifiedCollectionNavigation` + * this never degrades to a category- or section-only route: the caller shows + * a "View in portal" affordance only when the jump lands on the item itself. + */ +export function getUnifiedCollectionDetailNavigation( + item: UnifiedCollectionItem, + options?: { returnTo?: string | null } +): WorkspaceNavigationTarget | null { + if (item.contentType === 'live') { + return null; + } + + if (item.sourceType === 'xtream') { + const categoryId = toTrimmedSegment(item.categoryId); + const itemId = + toPositiveIntegerSegment(item.xtreamId) ?? + toPositiveIntegerSegment(lastUidSegment(item.uid)); + if (!categoryId || !itemId) { + return null; + } + + return buildXtreamNavigationTarget({ + playlistId: item.playlistId, + type: item.contentType, + categoryId, + itemId, + title: item.name, + imageUrl: item.posterUrl ?? item.logo ?? null, + }); + } + + if (item.sourceType === 'stalker') { + const stalkerItem = item.stalkerItem as StalkerPortalItem | undefined; + const type = resolveStalkerDetailType(item, stalkerItem); + const categoryId = resolveStalkerDetailCategoryId( + item, + stalkerItem, + type + ); + + return buildStalkerDetailNavigationTarget({ + playlistId: item.playlistId, + type, + categoryId, + item: buildStalkerStateItem(stalkerItem, { + id: item.stalkerId ?? lastUidSegment(item.uid) ?? '', + title: item.name, + type, + category_id: categoryId, + poster_url: item.posterUrl ?? item.logo ?? undefined, + }), + returnTo: options?.returnTo ?? null, + }); + } + + return null; +} + +/** + * Mirrors `StalkerCollectionDetailComponent.resolveDetailMode()`: only a + * regular `/series` item belongs in the series catalog. Embedded `series[]` + * snapshots and lazy Ministra VOD `is_series` items normalize to `series` in + * `extractStalkerItemType()` but must stay in the VOD catalog — the lazy + * season/episode fetch in `StalkerCatalogFacadeService.selectItem()` is gated + * on the VOD content type, so routing them to `/series` would leave the detail + * unable to load its episodes. + */ +function resolveStalkerDetailType( + item: UnifiedCollectionItem, + stalkerItem: StalkerPortalItem | undefined +): 'movie' | 'series' { + const embeddedSeries = (stalkerItem as { series?: unknown[] } | undefined) + ?.series; + const hasEmbeddedSeries = + Array.isArray(embeddedSeries) && embeddedSeries.length > 0; + const isVodSeries = isStalkerSeriesFlag( + (stalkerItem as { is_series?: unknown } | undefined)?.is_series + ); + + return item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries + ? 'series' + : 'movie'; +} + +/** + * Mirrors `StalkerCollectionDetailComponent.resolveSelectedCategory()`: a + * VOD-catalog item persisted from the series view carries the virtual + * `series` category, which would otherwise form a `/vod/series` route. + */ +function resolveStalkerDetailCategoryId( + item: UnifiedCollectionItem, + stalkerItem: StalkerPortalItem | undefined, + type: 'movie' | 'series' +): string | number | undefined { + const categoryId = + item.categoryId ?? + (stalkerItem as { category_id?: string | number } | undefined) + ?.category_id; + + if ( + type === 'movie' && + String(categoryId ?? '').toLowerCase() === 'series' + ) { + return 'vod'; + } + + return categoryId; +} + +function toTrimmedSegment(value: unknown): string { + return String(value ?? '').trim(); +} + +function toPositiveIntegerSegment(value: unknown): string | null { + const parsed = Number(toTrimmedSegment(value)); + return Number.isSafeInteger(parsed) && parsed > 0 ? String(parsed) : null; +} + +function lastUidSegment(uid: string): string | undefined { + const segments = uid.split('::'); + return segments.length > 0 ? segments[segments.length - 1] : undefined; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index 8a450154d..d5d0788df 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -2,8 +2,12 @@ import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { ContentHeroComponent } from '@iptvnator/ui/components'; +import { + ContentHeroComponent, + VIEW_IN_PORTAL_HANDOFF, +} from '@iptvnator/ui/components'; import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, @@ -94,6 +98,7 @@ describe('StalkerCollectionDetailComponent', () => { }; let snackBar: { open: jest.Mock }; let playlistsService: { getPlaylistById: jest.Mock }; + let routerNavigate: jest.Mock; const playlist = { _id: 'stalker-1', @@ -152,6 +157,8 @@ describe('StalkerCollectionDetailComponent', () => { ), }; + routerNavigate = jest.fn().mockResolvedValue(true); + await TestBed.configureTestingModule({ imports: [StalkerCollectionDetailComponent], providers: [ @@ -159,6 +166,13 @@ describe('StalkerCollectionDetailComponent', () => { provide: StalkerStore, useValue: stalkerStore, }, + { + provide: Router, + useValue: { + navigate: routerNavigate, + url: '/workspace/global-favorites', + }, + }, { provide: PORTAL_PLAYER, useValue: portalPlayer, @@ -559,6 +573,56 @@ describe('StalkerCollectionDetailComponent', () => { null ); }); + + it('provides itself as the view-in-portal handoff on its element injector', () => { + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'series', + categoryId: '44', + stalkerId: 'series-9', + name: 'Series Nine', + }) + ); + fixture.detectChanges(); + + expect( + fixture.debugElement.injector.get(VIEW_IN_PORTAL_HANDOFF) + ).toBe(fixture.componentInstance); + expect(fixture.componentInstance.viewInPortalAvailable()).toBe(true); + expect(fixture.componentInstance.viewInPortalPlaylistName()).toBe( + 'Stalker Portal' + ); + }); + + it('navigates to the portal category with openStalkerItem and return state', () => { + fixture.componentRef.setInput( + 'item', + buildCollectionItem({ + contentType: 'series', + categoryId: '44', + stalkerId: 'series-9', + name: 'Series Nine', + }) + ); + fixture.detectChanges(); + + fixture.componentInstance.openInPortal(); + + expect(routerNavigate).toHaveBeenCalledWith( + ['/workspace', 'stalker', 'stalker-1', 'series', '44'], + { + state: { + openStalkerItem: expect.objectContaining({ + id: 'series-9', + category_id: '44', + title: 'Series Nine', + }), + stalkerReturnTo: '/workspace/global-favorites', + }, + } + ); + }); }); function buildCollectionItem( diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index a8e575952..3868e46d9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -3,6 +3,7 @@ import { Component, computed, effect, + forwardRef, inject, input, output, @@ -10,11 +11,17 @@ import { untracked, } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { PortalDetailShellComponent } from '@iptvnator/ui/components'; +import { + PortalDetailShellComponent, + VIEW_IN_PORTAL_HANDOFF, + ViewInPortalHandoff, +} from '@iptvnator/ui/components'; import { buildStalkerStateItem, createLogger, + getUnifiedCollectionDetailNavigation, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, @@ -103,6 +110,12 @@ interface StalkerCollectionPlaybackOwner { } `, changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: VIEW_IN_PORTAL_HANDOFF, + useExisting: forwardRef(() => StalkerCollectionDetailComponent), + }, + ], styles: [ ` :host { @@ -114,11 +127,12 @@ interface StalkerCollectionPlaybackOwner { `, ], }) -export class StalkerCollectionDetailComponent { +export class StalkerCollectionDetailComponent implements ViewInPortalHandoff { readonly item = input(null); readonly closeRequested = output(); private readonly playlistsService = inject(PlaylistsService); + private readonly router = inject(Router); private readonly stalkerStore = inject(StalkerStore); readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); @@ -129,6 +143,28 @@ export class StalkerCollectionDetailComponent { private readonly originalState = this.captureStoreState(); private readonly favoritesRefresh = createRefreshTrigger(); + readonly viewInPortalAvailable = computed(() => { + const item = this.item(); + return !!item && getUnifiedCollectionDetailNavigation(item) !== null; + }); + readonly viewInPortalPlaylistName = computed( + () => this.item()?.playlistName ?? null + ); + + openInPortal(): void { + const item = this.item(); + const navigation = item + ? getUnifiedCollectionDetailNavigation(item, { + returnTo: this.router.url, + }) + : null; + if (navigation) { + void this.router.navigate(navigation.link, { + state: navigation.state, + }); + } + } + readonly itemDetails = signal(null); readonly vodDetailsItem = signal(null); readonly inlinePlayback = signal(null); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 66d0195e1..45574ec73 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -162,6 +162,7 @@ } + @if (inlinePlayback(); as playback) { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 1a57e70c9..55d480ac5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -19,6 +19,7 @@ import { DetailMetaTemplateDirective, DetailTagsTemplateDirective, PortalDetailShellComponent, + ViewInPortalActionComponent, SeasonContainerComponent, SeasonContainerPlaybackToggleRequest, } from '@iptvnator/ui/components'; @@ -125,6 +126,7 @@ interface StalkerSeriesPlaybackRequestContext { DetailMetaTemplateDirective, DetailTagsTemplateDirective, PortalDetailShellComponent, + ViewInPortalActionComponent, PortalInlinePlayerComponent, SafePipe, TranslatePipe, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 12ca6c012..46c230a27 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -174,6 +174,7 @@ {{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }} } + @if (inlinePlayback(); as playback) { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 04c25b894..2ba367e6b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -19,6 +19,7 @@ import { DetailMetaTemplateDirective, DetailTagsTemplateDirective, PortalDetailShellComponent, + ViewInPortalActionComponent, SeasonContainerComponent, SeasonContainerPlaybackToggleRequest, } from '@iptvnator/ui/components'; @@ -79,6 +80,7 @@ import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; DetailTagsTemplateDirective, MatIcon, PortalDetailShellComponent, + ViewInPortalActionComponent, PortalInlinePlayerComponent, SeasonContainerComponent, SlicePipe, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 9a79806d7..77f1fcdd8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -250,6 +250,7 @@ } } } + diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 8c011c0be..93e9264e4 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -22,6 +22,7 @@ import { DetailTagsTemplateDirective, DialogService, PortalDetailShellComponent, + ViewInPortalActionComponent, VodSourcesChipComponent, } from '@iptvnator/ui/components'; import { SafePipe } from '@iptvnator/pipes'; @@ -116,6 +117,7 @@ function resolveVodIdentity(item: XtreamVodDetails): number | null { MatTooltip, NgTemplateOutlet, PortalDetailShellComponent, + ViewInPortalActionComponent, SafePipe, SlicePipe, TranslateModule, diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts index 0151f6fc1..7f4520aed 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts @@ -1,6 +1,7 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { VIEW_IN_PORTAL_HANDOFF } from '@iptvnator/ui/components'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; import { XtreamPlaylistData, @@ -27,6 +28,7 @@ describe('XtreamCollectionDetailComponent', () => { let isLoadingDetails: ReturnType>; let detailsError: ReturnType>; let cancelDetailsRequest: jest.Mock; + let routerNavigate: jest.Mock; beforeEach(async () => { playlistId = signal(''); @@ -37,6 +39,7 @@ describe('XtreamCollectionDetailComponent', () => { isLoadingDetails = signal(false); detailsError = signal(null); cancelDetailsRequest = jest.fn(); + routerNavigate = jest.fn().mockResolvedValue(true); await TestBed.configureTestingModule({ imports: [XtreamCollectionDetailComponent], @@ -78,6 +81,13 @@ describe('XtreamCollectionDetailComponent', () => { ), }, }, + { + provide: Router, + useValue: { + navigate: routerNavigate, + url: '/workspace/global-recent', + }, + }, { provide: PlaylistsService, useValue: { @@ -167,4 +177,56 @@ describe('XtreamCollectionDetailComponent', () => { expect(cancelDetailsRequest).toHaveBeenCalledTimes(1); }); + + it('provides itself as the view-in-portal handoff to the inline detail', async () => { + fixture.componentRef.setInput('item', { + uid: 'xtream::xtream-1::movie:99', + name: 'Movie One', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream Portal', + xtreamId: 99, + categoryId: 42, + } satisfies UnifiedCollectionItem); + + fixture.detectChanges(); + await fixture.whenStable(); + await Promise.resolve(); + fixture.detectChanges(); + + expect( + fixture.componentInstance + .detailInjector() + ?.get(VIEW_IN_PORTAL_HANDOFF) + ).toBe(fixture.componentInstance); + expect(fixture.componentInstance.viewInPortalAvailable()).toBe(true); + expect(fixture.componentInstance.viewInPortalPlaylistName()).toBe( + 'Xtream Portal' + ); + + fixture.componentInstance.openInPortal(); + expect(routerNavigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'xtream-1', 'vod', '42', '99'], + { state: undefined } + ); + }); + + it('reports the handoff unavailable when the item lacks a category', () => { + fixture.componentRef.setInput('item', { + uid: 'xtream::xtream-1::movie:99', + name: 'Movie One', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream Portal', + xtreamId: 99, + } satisfies UnifiedCollectionItem); + fixture.detectChanges(); + + expect(fixture.componentInstance.viewInPortalAvailable()).toBe(false); + + fixture.componentInstance.openInPortal(); + expect(routerNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts index f4930706e..2ef2ce6ab 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -4,17 +4,23 @@ import { Component, Injector, Type, + computed, effect, inject, input, signal, untracked, } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { PortalDetailShellComponent } from '@iptvnator/ui/components'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + PortalDetailShellComponent, + VIEW_IN_PORTAL_HANDOFF, + ViewInPortalHandoff, +} from '@iptvnator/ui/components'; import { SeriesResumeTarget, UnifiedCollectionItem, + getUnifiedCollectionDetailNavigation, } from '@iptvnator/portal/shared/util'; import { XtreamPlaylistData, @@ -64,13 +70,34 @@ interface XtreamCollectionStateSnapshot { `, ], }) -export class XtreamCollectionDetailComponent { +export class XtreamCollectionDetailComponent implements ViewInPortalHandoff { readonly item = input(null); readonly seriesResume = input(null); private readonly parentInjector = inject(Injector); private readonly playlistsService = inject(PlaylistsService); + private readonly router = inject(Router); private readonly xtreamStore = inject(XtreamStore); + + readonly viewInPortalAvailable = computed(() => { + const item = this.item(); + return !!item && getUnifiedCollectionDetailNavigation(item) !== null; + }); + readonly viewInPortalPlaylistName = computed( + () => this.item()?.playlistName ?? null + ); + + openInPortal(): void { + const item = this.item(); + const navigation = item + ? getUnifiedCollectionDetailNavigation(item) + : null; + if (navigation) { + void this.router.navigate(navigation.link, { + state: navigation.state, + }); + } + } private readonly originalState = this.captureStoreState(); readonly detailComponent = signal | null>(null); readonly detailInjector = signal(null); @@ -164,6 +191,10 @@ export class XtreamCollectionDetailComponent { provide: XTREAM_SERIES_RESUME_TARGET, useValue: this.seriesResume, }, + { + provide: VIEW_IN_PORTAL_HANDOFF, + useValue: this, + }, ], parent: this.parentInjector, }) diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index dfb6719d0..3517303a3 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -13,6 +13,8 @@ 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/view-in-portal-action/view-in-portal-action.component'; +export * from './lib/view-in-portal-action/view-in-portal-handoff.token'; export * from './lib/progress-capsule/progress-capsule.component'; export * from './lib/resizable/resizable.directive'; export * from './lib/season-container/season-container.component'; diff --git a/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.spec.ts b/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.spec.ts new file mode 100644 index 000000000..c0e3001f9 --- /dev/null +++ b/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.spec.ts @@ -0,0 +1,115 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatTooltip } from '@angular/material/tooltip'; +import { By } from '@angular/platform-browser'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { ViewInPortalActionComponent } from './view-in-portal-action.component'; +import { + VIEW_IN_PORTAL_HANDOFF, + ViewInPortalHandoff, +} from './view-in-portal-handoff.token'; + +@Component({ + imports: [ViewInPortalActionComponent], + template: ``, +}) +class HostComponent {} + +const BUTTON_SELECTOR = '[data-testid="collection-view-in-portal"]'; + +describe('ViewInPortalActionComponent', () => { + function createHandoff( + overrides: Partial<{ + available: boolean; + playlistName: string | null; + }> = {} + ): ViewInPortalHandoff & { openInPortal: jest.Mock } { + return { + viewInPortalAvailable: signal(overrides.available ?? true), + viewInPortalPlaylistName: signal( + overrides.playlistName ?? null + ), + openInPortal: jest.fn(), + }; + } + + async function createFixture( + handoff: ViewInPortalHandoff | null + ): Promise> { + await TestBed.configureTestingModule({ + imports: [HostComponent, TranslateModule.forRoot()], + providers: handoff + ? [{ provide: VIEW_IN_PORTAL_HANDOFF, useValue: handoff }] + : [], + }).compileComponents(); + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + return fixture; + } + + it('renders nothing and hides the host without a handoff token', async () => { + const fixture = await createFixture(null); + + expect(fixture.debugElement.query(By.css(BUTTON_SELECTOR))).toBeNull(); + const host = fixture.debugElement.query( + By.directive(ViewInPortalActionComponent) + ); + expect( + host.nativeElement.classList.contains( + 'view-in-portal-action--hidden' + ) + ).toBe(true); + }); + + it('stays hidden while the handoff reports the target unavailable', async () => { + const fixture = await createFixture( + createHandoff({ available: false }) + ); + + expect(fixture.debugElement.query(By.css(BUTTON_SELECTOR))).toBeNull(); + const host = fixture.debugElement.query( + By.directive(ViewInPortalActionComponent) + ); + expect( + host.nativeElement.classList.contains( + 'view-in-portal-action--hidden' + ) + ).toBe(true); + }); + + it('renders the button and forwards clicks to the handoff', async () => { + const handoff = createHandoff({ available: true }); + const fixture = await createFixture(handoff); + + const button = fixture.debugElement.query(By.css(BUTTON_SELECTOR)); + expect(button).not.toBeNull(); + const host = fixture.debugElement.query( + By.directive(ViewInPortalActionComponent) + ); + expect( + host.nativeElement.classList.contains( + 'view-in-portal-action--hidden' + ) + ).toBe(false); + + button.nativeElement.click(); + expect(handoff.openInPortal).toHaveBeenCalledTimes(1); + }); + + it('uses the playlist name for the tooltip when provided', async () => { + const fixture = await createFixture( + createHandoff({ available: true, playlistName: 'My Portal' }) + ); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + PORTALS: { VIEW_IN_PORTAL_TOOLTIP: 'Open in {{name}}' }, + }); + translate.use('en'); + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css(BUTTON_SELECTOR)); + const tooltip = button.injector.get(MatTooltip); + expect(tooltip.message).toBe('Open in My Portal'); + }); +}); diff --git a/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.ts b/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.ts new file mode 100644 index 000000000..ede1906e0 --- /dev/null +++ b/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-action.component.ts @@ -0,0 +1,75 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; +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 { VIEW_IN_PORTAL_HANDOFF } from './view-in-portal-handoff.token'; + +/** + * Separate-row hero action that jumps from an inline collection detail to the + * same item inside its owning portal. Renders nothing (and removes itself from + * the flex flow) unless a host provides `VIEW_IN_PORTAL_HANDOFF` and reports + * the target as resolvable. + */ +@Component({ + selector: 'app-view-in-portal-action', + imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslateModule], + template: ` + @if (visible()) { + + } + `, + styles: ` + /* Own row inside the hero's flex-wrap action container. */ + :host { + display: block; + flex: 0 0 100%; + margin-top: 4px; + } + + /* Without this the empty host would still claim a full flex row. */ + :host(.view-in-portal-action--hidden) { + display: none; + } + `, + host: { + '[class.view-in-portal-action--hidden]': '!visible()', + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewInPortalActionComponent { + protected readonly handoff = inject(VIEW_IN_PORTAL_HANDOFF, { + optional: true, + }); + + protected readonly visible = computed( + () => this.handoff?.viewInPortalAvailable() ?? false + ); + + protected readonly playlistName = computed( + () => this.handoff?.viewInPortalPlaylistName() ?? null + ); + + protected readonly tooltipParams = computed(() => ({ + name: this.playlistName() ?? '', + })); +} diff --git a/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-handoff.token.ts b/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-handoff.token.ts new file mode 100644 index 000000000..c332ee79d --- /dev/null +++ b/libs/ui/components/src/lib/view-in-portal-action/view-in-portal-handoff.token.ts @@ -0,0 +1,21 @@ +import { InjectionToken, Signal } from '@angular/core'; + +/** + * Contract a collection-detail host provides to surface the + * "View in portal" action inside a projected detail view. The token is + * deliberately absent in router-mounted portal details, which is what keeps + * the action scoped to inline collection contexts (global and portal-scoped + * favorites/recent). + * + * Member names carry the `viewInPortal` prefix so hosts can implement the + * interface directly without clashing with their existing members. + */ +export interface ViewInPortalHandoff { + readonly viewInPortalAvailable: Signal; + readonly viewInPortalPlaylistName: Signal; + openInPortal(): void; +} + +export const VIEW_IN_PORTAL_HANDOFF = new InjectionToken( + 'VIEW_IN_PORTAL_HANDOFF' +); diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index b142889e3..06e89030a 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -260,6 +260,7 @@ } } + @if (inlinePlayback(); as playback) { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 4e21f977d..7512cc347 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -20,6 +20,7 @@ import { DetailMetaTemplateDirective, DetailTagsTemplateDirective, PortalDetailShellComponent, + ViewInPortalActionComponent, } from '@iptvnator/ui/components'; import { Router } from '@angular/router'; import { @@ -69,6 +70,7 @@ import { createVodDownloadState } from './vod-download-state.util'; DetailTagsTemplateDirective, MatIcon, PortalDetailShellComponent, + ViewInPortalActionComponent, PortalInlinePlayerComponent, SafePipe, TranslatePipe,