feat(portals): add "View in portal" action to inline collection details (#1422)

This commit is contained in:
4gray authored and GitHub committed 2026-08-13 07:30:57 +02:00
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.
+38 -1
View File
@@ -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)
+2
View File
@@ -958,6 +958,8 @@
"BROWSE_ALL_MOVIES": "تصفح جميع الأفلام",
"BROWSE_ALL_SERIES": "تصفح جميع المسلسلات",
"BROWSE_ALL_LIVE": "تصفح جميع قنوات البث المباشر",
"VIEW_IN_PORTAL": "عرض في البوابة",
"VIEW_IN_PORTAL_TOOLTIP": "فتح في {{name}}",
"SIDEBAR": {
"MAIN": "الرئيسية",
"MOVIES": "الأفلام",
+2
View File
@@ -958,6 +958,8 @@
"BROWSE_ALL_MOVIES": "تصفح جميع الأفلام",
"BROWSE_ALL_SERIES": "تصفح جميع السلاسل",
"BROWSE_ALL_LIVE": "تصفح جميع البث المباشر",
"VIEW_IN_PORTAL": "شوف فالبوابة",
"VIEW_IN_PORTAL_TOOLTIP": "افتح ف {{name}}",
"SIDEBAR": {
"MAIN": "الرئيسي",
"MOVIES": "الأفلام",
+2
View File
@@ -958,6 +958,8 @@
"BROWSE_ALL_MOVIES": "Прагледзець усе фільмы",
"BROWSE_ALL_SERIES": "Прагледзець усе серыялы",
"BROWSE_ALL_LIVE": "Прагледзець увесь прамы эфір",
"VIEW_IN_PORTAL": "Паказаць у партале",
"VIEW_IN_PORTAL_TOOLTIP": "Адкрыць у {{name}}",
"SIDEBAR": {
"MAIN": "Галоўная",
"MOVIES": "Фільмы",
+2
View File
@@ -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",
+2
View File
@@ -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": "Ταινίες",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "映画",
+2
View File
@@ -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": "영화",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "Фильмы",
+2
View File
@@ -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",
+2
View File
@@ -958,6 +958,8 @@
"BROWSE_ALL_MOVIES": "浏览所有电影",
"BROWSE_ALL_SERIES": "浏览所有剧集",
"BROWSE_ALL_LIVE": "浏览所有直播电视",
"VIEW_IN_PORTAL": "在门户中查看",
"VIEW_IN_PORTAL_TOOLTIP": "在{{name}}中打开",
"SIDEBAR": {
"MAIN": "主页",
"MOVIES": "电影",
+2
View File
@@ -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.
+1
View File
@@ -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';
@@ -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);
@@ -162,6 +162,7 @@
</button>
}
<app-favorites-button [itemId]="serial.id" [item]="serial" />
<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';
@@ -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,
})
+2
View File
@@ -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';
@@ -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,