mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(portals): add "View in portal" action to inline collection details (#1422)
This commit is contained in:
1 parent
ea4214a0d8
commit
2d7811eb5f
41 files changed
+900
-8
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -958,6 +958,8 @@
|
||||
"BROWSE_ALL_MOVIES": "تصفح جميع الأفلام",
|
||||
"BROWSE_ALL_SERIES": "تصفح جميع المسلسلات",
|
||||
"BROWSE_ALL_LIVE": "تصفح جميع قنوات البث المباشر",
|
||||
"VIEW_IN_PORTAL": "عرض في البوابة",
|
||||
"VIEW_IN_PORTAL_TOOLTIP": "فتح في {{name}}",
|
||||
"SIDEBAR": {
|
||||
"MAIN": "الرئيسية",
|
||||
"MOVIES": "الأفلام",
|
||||
|
||||
@@ -958,6 +958,8 @@
|
||||
"BROWSE_ALL_MOVIES": "تصفح جميع الأفلام",
|
||||
"BROWSE_ALL_SERIES": "تصفح جميع السلاسل",
|
||||
"BROWSE_ALL_LIVE": "تصفح جميع البث المباشر",
|
||||
"VIEW_IN_PORTAL": "شوف فالبوابة",
|
||||
"VIEW_IN_PORTAL_TOOLTIP": "افتح ف {{name}}",
|
||||
"SIDEBAR": {
|
||||
"MAIN": "الرئيسي",
|
||||
"MOVIES": "الأفلام",
|
||||
|
||||
@@ -958,6 +958,8 @@
|
||||
"BROWSE_ALL_MOVIES": "Прагледзець усе фільмы",
|
||||
"BROWSE_ALL_SERIES": "Прагледзець усе серыялы",
|
||||
"BROWSE_ALL_LIVE": "Прагледзець увесь прамы эфір",
|
||||
"VIEW_IN_PORTAL": "Паказаць у партале",
|
||||
"VIEW_IN_PORTAL_TOOLTIP": "Адкрыць у {{name}}",
|
||||
"SIDEBAR": {
|
||||
"MAIN": "Галоўная",
|
||||
"MOVIES": "Фільмы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ταινίες",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "映画",
|
||||
|
||||
@@ -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": "영화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Фильмы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -958,6 +958,8 @@
|
||||
"BROWSE_ALL_MOVIES": "浏览所有电影",
|
||||
"BROWSE_ALL_SERIES": "浏览所有剧集",
|
||||
"BROWSE_ALL_LIVE": "浏览所有直播电视",
|
||||
"VIEW_IN_PORTAL": "在门户中查看",
|
||||
"VIEW_IN_PORTAL_TOOLTIP": "在{{name}}中打开",
|
||||
"SIDEBAR": {
|
||||
"MAIN": "主页",
|
||||
"MOVIES": "电影",
|
||||
|
||||
@@ -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": "電影",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
+238
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<UnifiedCollectionItem | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
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<StalkerSelectedVodItem | null>(null);
|
||||
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
|
||||
+1
@@ -162,6 +162,7 @@
|
||||
</button>
|
||||
}
|
||||
<app-favorites-button [itemId]="serial.id" [item]="serial" />
|
||||
<app-view-in-portal-action />
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
<app-view-in-portal-action />
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -250,6 +250,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
<app-view-in-portal-action />
|
||||
</ng-template>
|
||||
|
||||
<ng-template #inlinePlayer>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof signal<boolean>>;
|
||||
let detailsError: ReturnType<typeof signal<string | null>>;
|
||||
let cancelDetailsRequest: jest.Mock;
|
||||
let routerNavigate: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
playlistId = signal('');
|
||||
@@ -37,6 +39,7 @@ describe('XtreamCollectionDetailComponent', () => {
|
||||
isLoadingDetails = signal(false);
|
||||
detailsError = signal<string | null>(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();
|
||||
});
|
||||
});
|
||||
@@ -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<UnifiedCollectionItem | null>(null);
|
||||
readonly seriesResume = input<SeriesResumeTarget | null>(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<Type<unknown> | null>(null);
|
||||
readonly detailInjector = signal<Injector | null>(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,
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
+115
@@ -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: `<app-view-in-portal-action />`,
|
||||
})
|
||||
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<ComponentFixture<HostComponent>> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-testid="collection-view-in-portal"
|
||||
[matTooltip]="
|
||||
playlistName()
|
||||
? ('PORTALS.VIEW_IN_PORTAL_TOOLTIP'
|
||||
| translate: tooltipParams())
|
||||
: ''
|
||||
"
|
||||
(click)="handoff?.openInPortal()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
{{ 'PORTALS.VIEW_IN_PORTAL' | translate }}
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
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() ?? '',
|
||||
}));
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
readonly viewInPortalPlaylistName: Signal<string | null>;
|
||||
openInPortal(): void;
|
||||
}
|
||||
|
||||
export const VIEW_IN_PORTAL_HANDOFF = new InjectionToken<ViewInPortalHandoff>(
|
||||
'VIEW_IN_PORTAL_HANDOFF'
|
||||
);
|
||||
@@ -260,6 +260,7 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<app-view-in-portal-action />
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user