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

+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,