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

+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'
);