Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950

# Conflicts:
#	apps/electron-backend-e2e/src/detail-back-lane.e2e.ts
#	apps/web-e2e/src/m3u-movie-details.e2e.ts
#	docs/architecture/iptvnator-ui-guidelines.md
#	libs/ui/components/src/lib/content-hero/content-hero.component.scss
#	libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss
This commit is contained in:
4gray committed 2026-10-03 11:25:57 +02:00
commit 87d7e1d46c
122 files changed
+2080 -1040

No files matched your search

@@ -59,7 +59,11 @@
<mat-dialog-actions align="end">
@if (currentMapping()) {
<button mat-button color="warn" (click)="removeMapping()">
<button
mat-button
class="app-destructive-button"
(click)="removeMapping()"
>
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
</button>
}
@@ -52,3 +52,49 @@ describe('ConfirmDialogComponent actions', () => {
}
);
});
describe('ConfirmDialogComponent labels and tone', () => {
async function render(data: Record<string, unknown>) {
await TestBed.configureTestingModule({
imports: [
ConfirmDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'Remove playlist',
message: 'Delete it?',
confirmLabel: 'Remove playlist',
onConfirm: jest.fn(),
...data,
},
},
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmDialogComponent);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll(
'button'
) as NodeListOf<HTMLButtonElement>;
return { cancel: buttons[0], confirm: buttons[1] };
}
it('names the action instead of answering "Yes", and cancels by default', async () => {
const { cancel, confirm } = await render({});
expect(confirm.textContent?.trim()).toBe('Remove playlist');
// No translations are loaded, so the key is the rendered text.
expect(cancel.textContent?.trim()).toBe('CANCEL');
expect(confirm.classList).not.toContain('app-destructive-button');
});
it('styles destructive confirmations as destructive', async () => {
const { confirm } = await render({ tone: 'destructive' });
expect(confirm.classList).toContain('app-destructive-button');
});
});
@@ -3,12 +3,25 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
/**
* `destructive` renders the confirm action with the app-wide error styling
* (`.app-destructive-button`). Use it whenever confirming removes or discards
* user data; Material's `warn` color input has no effect with the M3 theme.
*/
export type ConfirmDialogTone = 'default' | 'destructive';
export interface ConfirmDialogData {
title: string;
message: string;
width?: string;
confirmLabel?: string;
/**
* Translated verb that names the action ("Remove playlist", "Clear").
* Required so a confirmation never falls back to an ambiguous "Yes".
*/
confirmLabel: string;
/** Translated dismiss label; defaults to "Cancel". */
cancelLabel?: string;
tone?: ConfirmDialogTone;
/** Run the action in the dialog; only Close/backdrop/Escape dismiss it. */
keepOpenOnConfirm?: boolean;
onConfirm: () => void;
@@ -27,23 +40,25 @@ export interface ConfirmDialogData {
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial>
{{ dialogData.cancelLabel || 'NO' | translate }}
{{ dialogData.cancelLabel || ('CANCEL' | translate) }}
</button>
@if (dialogData.keepOpenOnConfirm) {
<button
mat-flat-button
data-test-id="confirm-dialog-confirm"
[class.app-destructive-button]="isDestructive"
(click)="dialogData.onConfirm()"
color="primary"
>
{{ dialogData.confirmLabel || 'YES' | translate }}
{{ dialogData.confirmLabel }}
</button>
} @else {
<button
mat-flat-button
data-test-id="confirm-dialog-confirm"
[class.app-destructive-button]="isDestructive"
[mat-dialog-close]="true"
color="primary"
>
{{ dialogData.confirmLabel || 'YES' | translate }}
{{ dialogData.confirmLabel }}
</button>
}
</mat-dialog-actions>
@@ -56,4 +71,8 @@ export class ConfirmDialogComponent {
constructor() {
this.dialogData = this.data;
}
get isDestructive(): boolean {
return this.dialogData.tone === 'destructive';
}
}
@@ -33,6 +33,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
confirmLabel: 'Remove playlist',
onConfirm: jest.fn(),
});
@@ -57,6 +58,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Refresh Xtream Playlist',
message: 'Confirm refresh?',
confirmLabel: 'Refresh playlist',
width: '400px',
onConfirm: jest.fn(),
});
@@ -78,6 +80,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
confirmLabel: 'Remove playlist',
onConfirm,
});
@@ -89,6 +92,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Recovery',
message: 'Path',
confirmLabel: 'Copy path',
keepOpenOnConfirm: true,
onConfirm,
});
@@ -157,8 +157,6 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 48px 32px 24px;
// Reserved by the detail shell while its sticky Back control shows.
padding-inline-start: max(32px, var(--detail-back-lane, 0px));
box-sizing: border-box;
&--skeleton {
@@ -435,7 +433,6 @@
.hero__content {
padding: 40px 20px 24px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
}
.details {
@@ -450,7 +447,6 @@
@media (max-width: 640px) {
.hero__content {
padding-inline: 16px;
padding-inline-start: max(16px, var(--detail-back-lane, 0px));
}
.details__title {
@@ -6,8 +6,6 @@
max-width: var(--content-max-width, 1400px);
margin: 0 auto;
padding: 24px 40px 40px;
// The detail shell's sticky Back lane (0 when there is no Back).
padding-inline-start: max(40px, var(--detail-back-lane, 0px));
border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12));
&__heading {
@@ -129,7 +127,6 @@
@media (max-width: 800px) {
.about {
padding: 20px 20px 32px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
&__poster {
width: 92px;
@@ -1,22 +1,3 @@
@if (backAvailable()) {
@let label = backLabel() || ('BACK' | translate);
<!-- Route-level Back in both states. Watch keeps the arrow's meaning;
closing the player belongs to the player's own Close button and Escape. -->
<div class="shell__navigation">
<button
#backButton
class="shell__back-button"
type="button"
[attr.aria-label]="label"
[attr.aria-keyshortcuts]="isWatch() ? null : 'Escape'"
[title]="isWatch() ? label : label + ' (Esc)'"
(click)="backClicked.emit()"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
</div>
}
<!-- Hero (browse state) — collapses when playback is active -->
<div class="shell__hero" [class.shell__hero--collapsed]="isWatch()">
<div class="shell__hero-inner">
@@ -13,13 +13,6 @@
--tag-border: color-mix(in srgb, var(--app-on-surface, #1d1b1e) 14%, transparent);
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.3);
--content-max-width: 1400px;
// The sticky Back control floats over the scroll owner. While it is shown,
// every content column (hero, player, episodes, About, extras) starts
// after its lane — inset + button + the same inset again — so nothing
// scrolls beneath it. content-hero and content-about read the lane too.
--shell-back-inset: 16px;
--shell-back-size: 40px;
--detail-back-lane: 0px;
display: block;
height: 100%;
@@ -40,58 +33,10 @@
}
}
:host(.shell-host--back) {
--detail-back-lane: calc(
2 * var(--shell-back-inset) + var(--shell-back-size)
);
}
:host-context(.dark-theme) {
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
}
.shell__navigation {
// Direct child of the scroll owner: sticky lasts through the entire page.
position: sticky;
top: var(--shell-back-inset);
height: 0;
z-index: 10;
pointer-events: none;
}
.shell__back-button {
position: absolute;
inset-inline-start: var(--shell-back-inset);
pointer-events: auto;
app-region: no-drag;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: var(--shell-back-size);
height: var(--shell-back-size);
border-radius: 50%;
background: var(--app-widget-bg, var(--surface-bg));
color: var(--app-on-surface, var(--text-primary));
border: 1px solid var(--app-separator, var(--tag-border));
cursor: pointer;
&:hover {
background: var(--app-card-hover-bg, var(--surface-bg));
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// ============================================================================
// Hero collapse (browse ↔ watch morph, ~300ms, no height measuring)
// ============================================================================
@@ -132,7 +77,6 @@
&--active {
padding: 16px 40px 0;
padding-inline-start: max(40px, var(--detail-back-lane));
animation: shell-fade-in 300ms ease 50ms both;
}
}
@@ -141,7 +85,6 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 32px;
padding-inline-start: max(32px, var(--detail-back-lane));
&:empty {
display: none;
@@ -162,7 +105,6 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 8px 32px 40px;
padding-inline-start: max(32px, var(--detail-back-lane));
&:empty {
display: none;
@@ -183,38 +125,14 @@
@media (max-width: 800px) {
.shell__player--active {
padding: 8px 12px 0;
padding-inline-start: max(12px, var(--detail-back-lane));
}
.shell__episodes {
padding: 0 20px;
padding-inline-start: max(20px, var(--detail-back-lane));
}
.shell__extras {
padding: 0 20px 32px;
padding-inline-start: max(20px, var(--detail-back-lane));
}
}
// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside
// the context panel; the component sets the class from the pane's width)
// would clip the player's controls. There the control takes a row instead —
// an opaque sticky bar in flow that content scrolls beneath, never alongside.
:host(.shell-host--compact) {
--detail-back-lane: 0px;
.shell__navigation {
top: 0;
height: calc(var(--shell-back-size) + 16px);
background: var(--app-header-bg);
// The bar hides what scrolls beneath it, so it must not let taps
// through to that hidden content either.
pointer-events: auto;
}
.shell__back-button {
top: 8px;
}
}
@@ -1,6 +1,7 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import {
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -69,6 +70,9 @@ describe('PortalDetailShellComponent', () => {
return element;
};
const backTarget = () =>
TestBed.inject(WorkspaceBackNavigationService).target();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent, TranslateModule.forRoot()],
@@ -130,13 +134,13 @@ describe('PortalDetailShellComponent', () => {
expect(query('.details__meta .details__meta-item')).toBeTruthy();
expect(query('.action-buttons .play-btn')).toBeTruthy();
expect(query('app-content-about')).toBeNull();
expect(query('.shell__back-button')?.getAttribute('aria-label')).toBe(
'Return to downloads'
);
// The header renders Back; the page itself keeps no arrow.
expect(query('button[aria-label="Return to downloads"]')).toBeNull();
expect(backTarget()?.label()).toBe('Return to downloads');
});
it.each([{ isLoading: true }, { errorMessage: 'Unavailable' }])(
'keeps the translated fallback Back available in loading/error states',
'keeps the generic Back available in loading/error states',
(state) => {
fixture.destroy();
const shellFixture = TestBed.createComponent(
@@ -145,16 +149,14 @@ describe('PortalDetailShellComponent', () => {
for (const [key, value] of Object.entries(state))
shellFixture.componentRef.setInput(key, value);
shellFixture.detectChanges();
const element = shellFixture.nativeElement as HTMLElement;
const button = element.querySelector<HTMLButtonElement>(
'.shell__back-button'
);
expect(button?.type).toBe('button');
expect(button?.getAttribute('aria-label')).toBe('Go back');
// No host label: the header falls back to the translated "Back".
expect(backTarget()?.label()).toBeNull();
const back = jest.fn();
shellFixture.componentInstance.backClicked.subscribe(back);
button?.click();
backTarget()?.run();
expect(back).toHaveBeenCalledTimes(1);
shellFixture.destroy();
expect(backTarget()).toBeNull();
}
);
@@ -277,30 +279,33 @@ describe('PortalDetailShellComponent', () => {
expect(host.closeRequests).toBe(0);
});
it('keeps one route-back control outside the collapsing hero in both states', () => {
const back = requiredQuery('.shell__back-button');
expect(back.closest('app-content-hero')).toBeNull();
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
back.click();
it('offers one route-level Back to the header in both states', () => {
const target = backTarget();
expect(target?.label()).toBe('Return to downloads');
expect(target?.escapeShortcut()).toBe(true);
target?.run();
expect(host.backRequests).toBe(1);
host.playbackActive.set(true);
fixture.detectChanges();
expect(query('.shell__back-button')).toBe(back);
expect(backTarget()).toBe(target);
// Watch keeps the arrow's meaning: it leaves the page, it does not
// close the player. Escape is the close shortcut, so the hint goes.
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
expect(back.getAttribute('aria-keyshortcuts')).toBeNull();
expect(back.getAttribute('title')).toBe('Return to downloads');
back.click();
expect(target?.escapeShortcut()).toBe(false);
target?.run();
expect(host.backRequests).toBe(2);
expect(host.closeRequests).toBe(0);
});
it('moves lost focus to the back control after Escape closes the player', async () => {
it('releases the header Back when the page goes away', () => {
expect(backTarget()).not.toBeNull();
fixture.destroy();
expect(backTarget()).toBeNull();
});
it('keeps focus on the page after Escape closes the player', async () => {
host.playbackActive.set(true);
fixture.detectChanges();
const back = requiredQuery('.shell__back-button');
const shell = requiredQuery('app-portal-detail-shell');
const player = requiredQuery('.fake-player');
player.tabIndex = 0;
player.focus();
@@ -315,13 +320,13 @@ describe('PortalDetailShellComponent', () => {
await fixture.whenStable();
expect(host.closeRequests).toBe(1);
expect(host.backRequests).toBe(0);
expect(document.activeElement).toBe(back);
expect(document.activeElement).toBe(shell);
});
it('has no dead-end browse action for a host without back navigation', () => {
host.backAvailable.set(false);
fixture.detectChanges();
expect(query('.shell__back-button')).toBeNull();
expect(backTarget()).toBeNull();
requiredQuery('app-portal-detail-shell').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
@@ -334,7 +339,7 @@ describe('PortalDetailShellComponent', () => {
fixture.detectChanges();
// No route to go back to, so no arrow in watch either; the player's
// own Close button and Escape remain the exits.
expect(query('.shell__back-button')).toBeNull();
expect(backTarget()).toBeNull();
requiredQuery('app-portal-detail-shell').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
@@ -451,67 +456,3 @@ describe('PortalDetailShellComponent', () => {
expect(host.closeRequests).toBe(0);
});
});
describe('PortalDetailShellComponent pane width', () => {
const original = globalThis.ResizeObserver;
const disconnect = jest.fn();
// The hero observes itself too, so keep each callback with its target.
let observers: { callback: ResizeObserverCallback; target?: Element }[];
beforeEach(() => {
observers = [];
disconnect.mockClear();
globalThis.ResizeObserver = class {
private readonly entry: (typeof observers)[number];
constructor(callback: ResizeObserverCallback) {
this.entry = { callback };
observers.push(this.entry);
}
observe(target: Element): void {
this.entry.target = target;
}
unobserve = jest.fn();
disconnect = disconnect;
} as unknown as typeof ResizeObserver;
TestBed.configureTestingModule({
imports: [HostComponent, TranslateModule.forRoot()],
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
});
const resize = (shell: HTMLElement, inlineSize: number): void => {
const observer = observers.find(({ target }) => target === shell);
if (!observer) throw new Error('The shell does not observe itself.');
observer.callback(
[
{
borderBoxSize: [{ inlineSize, blockSize: 800 }],
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
};
it('trades the Back lane for a bar when the pane itself is narrow', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const shell = (fixture.nativeElement as HTMLElement).querySelector(
'app-portal-detail-shell'
) as HTMLElement;
// A 780px window leaves the pane ~402px beside the context panel.
resize(shell, 402);
expect(shell.classList).not.toContain('shell-host--compact');
// A 641px window leaves it ~260px: too narrow for the lane.
resize(shell, 260);
expect(shell.classList).toContain('shell-host--compact');
resize(shell, 1280);
expect(shell.classList).not.toContain('shell-host--compact');
fixture.destroy();
expect(disconnect).toHaveBeenCalled();
});
});
@@ -2,7 +2,6 @@ import { NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
Injector,
computed,
@@ -11,11 +10,10 @@ import {
inject,
input,
output,
viewChild,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { ContentAboutComponent } from './content-about.component';
import {
@@ -24,14 +22,6 @@ import {
DetailTagsTemplateDirective,
} from './detail-template.directives';
/**
* Below this pane width the Back lane (16 + 40 + 16px) would leave the player
* card under ~316px, where its control row clips, so the control takes a
* sticky bar instead. The pane decides, not the viewport: beside the context
* panel a desktop pane can be narrower than a phone.
*/
const COMPACT_SHELL_WIDTH = 400;
/**
* Two-state layout shell for portal VOD/series detail pages.
*
@@ -41,22 +31,17 @@ const COMPACT_SHELL_WIDTH = 400;
* About block below the episodes slot.
*
* The shell owns the page scroll, the browse↔watch animation, Escape
* handling, the one sticky Back control (route-level in both states; closing
* the player is the player's own Close button and Escape), and never
* conditionally wraps the `[detail-player]` slot — the
* host's own `@if (inlinePlayback())` is the only thing that creates or
* destroys the player, so shell state changes cannot recreate it.
* handling and the page's Back action, which the workspace header renders in
* its leading slot (route-level in both states; closing the player is the
* player's own Close button and Escape). It never conditionally wraps the
* `[detail-player]` slot — the host's own `@if (inlinePlayback())` is the
* only thing that creates or destroys the player, so shell state changes
* cannot recreate it.
*/
@Component({
selector: 'app-portal-detail-shell',
standalone: true,
imports: [
ContentHeroComponent,
ContentAboutComponent,
NgTemplateOutlet,
MatIconModule,
TranslateModule,
],
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
templateUrl: './portal-detail-shell.component.html',
styleUrls: ['./portal-detail-shell.component.scss'],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
@@ -70,17 +55,13 @@ const COMPACT_SHELL_WIDTH = 400;
// on body, while preserving already-handled events and overlay guards.
'(keydown.escape)': 'onEscape($event)',
'[class.shell-host--watch]': 'isWatch()',
// Content columns reserve the sticky Back control's lane.
'[class.shell-host--back]': 'backAvailable()',
'(document:keydown.escape)': 'onEscape($event)',
},
})
export class PortalDetailShellComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly backButton =
viewChild<ElementRef<HTMLButtonElement>>('backButton');
private readonly backNavigation = inject(WorkspaceBackNavigationService);
readonly title = input<string>();
/** "Movie · playlist name" eyebrow above the hero title. */
@@ -100,7 +81,7 @@ export class PortalDetailShellComponent {
/** True while inline playback is active — flips the layout to watch state. */
readonly playbackActive = input(false);
/** The sticky control in either state, or Escape in browse. */
/** The header's Back in either state, or Escape in browse. */
readonly backClicked = output<void>();
/** Emitted by Escape during inline playback. */
readonly closePlayerRequested = output<void>();
@@ -113,6 +94,12 @@ export class PortalDetailShellComponent {
readonly isWatch = computed(() => this.playbackActive());
private readonly backTarget: WorkspaceBackTarget = {
label: computed(() => this.backLabel() || null),
escapeShortcut: computed(() => !this.isWatch()),
run: () => this.backClicked.emit(),
};
constructor() {
afterNextRender(() => {
const element = this.host.nativeElement;
@@ -126,7 +113,10 @@ export class PortalDetailShellComponent {
element.focus({ preventScroll: true });
}
});
this.observeCompactWidth();
effect((onCleanup) => {
if (!this.backAvailable()) return;
onCleanup(this.backNavigation.register(this.backTarget));
});
let wasWatch = false;
effect(() => {
const watch = this.isWatch();
@@ -233,15 +223,15 @@ export class PortalDetailShellComponent {
afterNextRender(
() => {
const element = this.host.nativeElement;
// The page keeps focus, so the next Escape still unwinds it
// and the arrow keys still scroll it.
if (
element.isConnected &&
!element.closest('[inert]') &&
element.ownerDocument.activeElement ===
element.ownerDocument.body
) {
(this.backButton()?.nativeElement ?? element).focus({
preventScroll: true,
});
element.focus({ preventScroll: true });
}
},
{ injector: this.injector }
@@ -260,24 +250,4 @@ export class PortalDetailShellComponent {
}
element.scrollTo({ top: 0, behavior: 'auto' });
}
/**
* Toggles `shell-host--compact` straight on the host, so the class lands
* in the same frame as the resize instead of after a change-detection
* pass. The border box keeps the threshold independent of scrollbar width.
*/
private observeCompactWidth(): void {
if (typeof ResizeObserver === 'undefined') return;
const element = this.host.nativeElement;
const observer = new ResizeObserver(([entry]) => {
const width =
entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth;
element.classList.toggle(
'shell-host--compact',
width < COMPACT_SHELL_WIDTH
);
});
observer.observe(element);
this.destroyRef.onDestroy(() => observer.disconnect());
}
}