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

Details opened from the dashboard, global favorites/recent, or a portal's
own favorites/recent tabs render full-width without the category sidebar,
so there was no way back to the title's place in its portal.

A shared `app-view-in-portal-action` renders a separate-row action below
the main hero buttons. It is gated on the new `VIEW_IN_PORTAL_HANDOFF`
token, provided only by the two collection-detail wrappers, so
router-mounted portal details never show it without any URL sniffing.

Targets come from `getUnifiedCollectionDetailNavigation()`, which unlike
`getUnifiedCollectionNavigation` never degrades to a category- or
section-only route: an Xtream item without a resolvable category and
positive id keeps the action hidden rather than promising a jump to the
title and landing in a list. Unlike the download handoff it does not pass
`detailPresentation: 'provider-only'` — the item exists in the provider
catalog, so the full detail is wanted. Stalker carries `stalkerReturnTo`
so back returns to the originating collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-12 07:58:28 +02:00
1 parent 4bcd4bd390
commit db1ec7cf5e
40 files changed
+716 -7

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.
+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,151 @@
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('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,77 @@
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
WorkspaceNavigationTarget,
} from './workspace-portal-navigation';
import { 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') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlistId,
type: item.contentType,
categoryId: item.categoryId,
item: buildStalkerStateItem(
item.stalkerItem as StalkerPortalItem | undefined,
{
id: item.stalkerId ?? lastUidSegment(item.uid) ?? '',
title: item.name,
type: item.contentType,
category_id: item.categoryId,
poster_url: item.posterUrl ?? item.logo ?? undefined,
}
),
returnTo: options?.returnTo ?? null,
});
}
return null;
}
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,