Merge remote-tracking branch 'origin/master' into claude/design-handoff-prompt-d0aee4

This commit is contained in:
4gray committed 2026-10-14 03:53:06 +02:00
commit 31899d7b58
54 files changed
+4075 -1631

No files matched your search

@@ -816,6 +816,26 @@ describe('CategoryContentViewComponent', () => {
expect(catalog.saveScrollPosition).toHaveBeenCalledWith(333);
});
it('saves the offset the grid last reported when it is destroyed detached', () => {
const infiniteFixture = createInfiniteFixture();
infiniteFixture.detectChanges();
const grid = infiniteFixture.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement;
// A detached element reports scrollTop 0, as in a browser.
Object.defineProperty(grid, 'scrollTop', {
configurable: true,
get: () => (grid.isConnected ? 420 : 0),
});
grid.dispatchEvent(new Event('scroll'));
// The view's DOM leaves the document before ngOnDestroy runs.
infiniteFixture.nativeElement.remove();
infiniteFixture.destroy();
expect(catalog.saveScrollPosition).toHaveBeenCalledWith(420);
});
it('consumes a saved scroll position once the list is rendered', () => {
catalog.consumeSavedScrollPosition.mockReturnValue(500);
const infiniteFixture = createInfiniteFixture();
@@ -198,6 +198,18 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
});
private previousGridResetKey: string | null = null;
private hasAttemptedScrollRestore = false;
/**
* The grid's last reported offset. On destroy the view is already
* detached from the document, where `scrollTop` reads 0, so the hook
* saves this instead. Scroll events do not bubble but can be captured.
*/
private lastGridScrollTop = 0;
private readonly trackGridScroll = (event: Event) => {
const target = event.target;
if (target instanceof HTMLElement && target.matches('app-grid-list')) {
this.lastGridScrollTop = target.scrollTop;
}
};
private readonly routeCategory = signal<{ id: string | null } | null>(null);
private initializedRoute: {
category: { id: string | null };
@@ -205,6 +217,16 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
} | null = null;
constructor() {
const host = this.hostElement.nativeElement;
host.addEventListener('scroll', this.trackGridScroll, {
capture: true,
passive: true,
});
this.destroyRef.onDestroy(() =>
host.removeEventListener('scroll', this.trackGridScroll, {
capture: true,
})
);
effect(() => {
const category = this.routeCategory();
if (!category || this.catalog.routeReady?.() === false) {
@@ -305,11 +327,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
// Leaving the list without opening a detail (e.g. switching portal
// tabs) still snapshots the spot; the facade validates the selection
// before ever restoring it.
// before ever restoring it. Angular detaches the view's DOM before
// this hook runs, and a detached grid reports a scrollTop of 0, so
// the tracked offset stands in for it.
if (!this.selectedItem()) {
const grid = this.gridElement();
if (grid) {
this.catalog.saveScrollPosition?.(grid.scrollTop);
this.catalog.saveScrollPosition?.(
grid.isConnected ? grid.scrollTop : this.lastGridScrollTop
);
}
}
}
@@ -346,6 +372,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
private scrollGridToTop(): void {
// A grid already at the top fires no scroll event for this call.
this.lastGridScrollTop = 0;
this.gridElement()?.scrollTo?.({ top: 0 });
}
+1
View File
@@ -11,6 +11,7 @@ export * from './lib/content-hero/content-hero.component';
export * from './lib/expandable-text/expandable-text.component';
export * from './lib/form-field-defaults/form-field-defaults';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/lazy-dialog/lazy-mat-dialog';
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';
@@ -1,6 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { ConfirmDialogComponent } from './confirm-dialog.component';
import { DialogService } from './dialog.service';
describe('DialogService', () => {
@@ -25,12 +26,12 @@ describe('DialogService', () => {
service = TestBed.inject(DialogService);
});
it('opens the confirm dialog with the expected config', () => {
it('opens the confirm dialog with the expected config once its chunk is in', async () => {
dialog.open.mockReturnValue({
afterClosed: () => of(false),
});
service.openConfirmDialog({
await service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
confirmLabel: 'Remove playlist',
@@ -38,7 +39,7 @@ describe('DialogService', () => {
});
expect(dialog.open).toHaveBeenCalledWith(
expect.any(Function),
ConfirmDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
title: 'Remove',
@@ -50,12 +51,12 @@ describe('DialogService', () => {
);
});
it('uses a custom dialog width when provided', () => {
it('uses a custom dialog width when provided', async () => {
dialog.open.mockReturnValue({
afterClosed: () => of(false),
});
service.openConfirmDialog({
await service.openConfirmDialog({
title: 'Refresh Xtream Playlist',
message: 'Confirm refresh?',
confirmLabel: 'Refresh playlist',
@@ -71,13 +72,13 @@ describe('DialogService', () => {
);
});
it('invokes onConfirm only after the dialog resolves truthy', () => {
it('invokes onConfirm only after the dialog resolves truthy', async () => {
const onConfirm = jest.fn();
dialog.open.mockReturnValue({
afterClosed: () => of(true),
});
service.openConfirmDialog({
await service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
confirmLabel: 'Remove playlist',
@@ -86,10 +87,10 @@ describe('DialogService', () => {
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('does not repeat a persistent dialog action on programmatic close', () => {
it('does not repeat a persistent dialog action on programmatic close', async () => {
const onConfirm = jest.fn();
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
service.openConfirmDialog({
await service.openConfirmDialog({
title: 'Recovery',
message: 'Path',
confirmLabel: 'Copy path',
@@ -98,4 +99,25 @@ describe('DialogService', () => {
});
expect(onConfirm).not.toHaveBeenCalled();
});
it('logs a failed chunk load and performs no action', async () => {
const error = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const onConfirm = jest.fn();
service.loadConfirmDialog = () =>
Promise.reject(new Error('chunk failed'));
await service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
confirmLabel: 'Remove playlist',
onConfirm,
});
expect(dialog.open).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledTimes(1);
error.mockRestore();
});
});
@@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
import { LazyMatDialog } from '../lazy-dialog/lazy-mat-dialog';
import type {
ConfirmDialogComponent,
ConfirmDialogData,
} from './confirm-dialog.component';
@@ -9,20 +9,42 @@ import {
providedIn: 'root',
})
export class DialogService {
private dialog = inject(MatDialog);
private readonly lazyDialog = inject(LazyMatDialog);
/**
* Opens the confirm dialog with provided data
* The dynamic import; a field so specs can substitute it. The component
* loads with the first confirmation: this service is created with the
* app, so a static import would put the component, Material's dialog
* and buttons on the initial path.
*/
loadConfirmDialog = () => import('./confirm-dialog.component');
/**
* Opens the confirm dialog with provided data. The returned promise
* settles once the dialog is open; a failed chunk load is logged, and
* the action is not performed.
* @param data dialog meta info
*/
openConfirmDialog(data: ConfirmDialogData): void {
const dialogRef = this.dialog.open<
async openConfirmDialog(data: ConfirmDialogData): Promise<void> {
let dialogModule: Awaited<ReturnType<typeof this.loadConfirmDialog>>;
let dialog: Awaited<ReturnType<LazyMatDialog['resolve']>>;
try {
[dialog, dialogModule] = await Promise.all([
this.lazyDialog.resolve(),
this.loadConfirmDialog(),
]);
} catch (error) {
console.error('Could not open the confirmation dialog:', error);
return;
}
const dialogRef = dialog.open<
ConfirmDialogComponent,
ConfirmDialogData
>(ConfirmDialogComponent, {
>(dialogModule.ConfirmDialogComponent, {
data,
// Wide enough for two side-by-side action buttons with translated
// labels; 300px forced them to wrap into a vertical stack.
// Wide enough for two side-by-side action buttons with
// translated labels; 300px forced them to wrap into a
// vertical stack.
width: data.width ?? '420px',
maxWidth: 'calc(100vw - 32px)',
});
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { LazyMatDialog } from './lazy-mat-dialog';
describe('LazyMatDialog', () => {
const dialog = { open: jest.fn() };
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{ provide: MatDialog, useValue: dialog }],
});
});
it('resolves the root MatDialog from the dialog chunk once', async () => {
const service = TestBed.inject(LazyMatDialog);
const load = jest.spyOn(service, 'loadDialogModule');
await expect(service.resolve()).resolves.toBe(dialog);
await expect(service.resolve()).resolves.toBe(dialog);
expect(load).toHaveBeenCalledTimes(1);
});
it('retries the chunk load after a failure instead of caching it', async () => {
const service = TestBed.inject(LazyMatDialog);
const original = service.loadDialogModule;
service.loadDialogModule = jest
.fn()
.mockRejectedValueOnce(new Error('chunk failed'))
.mockImplementation(original);
await expect(service.resolve()).rejects.toThrow('chunk failed');
await expect(service.resolve()).resolves.toBe(dialog);
expect(service.loadDialogModule).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,31 @@
import { EnvironmentInjector, inject, Injectable } from '@angular/core';
import type { MatDialog } from '@angular/material/dialog';
type DialogModule = typeof import('@angular/material/dialog');
/**
* Resolves the root `MatDialog` on first use. `@angular/material/dialog`
* and `@angular/cdk/dialog` share one bundler chunk, and one static import
* from a service created with the app puts both on the initial path although
* a dialog opens only after a user action. Services that exist at startup
* resolve the dialog through this helper; a component in a lazy chunk can
* inject `MatDialog` directly.
*/
@Injectable({ providedIn: 'root' })
export class LazyMatDialog {
private readonly injector = inject(EnvironmentInjector);
private dialog: Promise<MatDialog> | null = null;
/** The dynamic import; a field so specs can substitute it. */
loadDialogModule: () => Promise<DialogModule> = () =>
import('@angular/material/dialog');
resolve(): Promise<MatDialog> {
this.dialog ??= this.loadDialogModule().then((module) =>
this.injector.get(module.MatDialog)
);
// A failed chunk load must not poison later attempts.
this.dialog.catch(() => (this.dialog = null));
return this.dialog;
}
}
@@ -0,0 +1,229 @@
import {
Component,
computed,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
EpgImportProgress,
EpgProgressService,
} from '@iptvnator/epg/data-access';
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
import { normalizeDateLocale } from '@iptvnator/pipes/date-format';
import { formatEpgImportDisplayUrl } from './epg-import-display-url';
interface EpgTrustConfirmDialogData {
confirmLabel: string;
message: string;
title: string;
}
@Component({
selector: 'app-epg-trust-confirm-dialog',
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content class="mat-typography">
{{ data.message }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CANCEL' | translate }}
</button>
<button mat-flat-button color="primary" [mat-dialog-close]="true">
{{ data.confirmLabel }}
</button>
</mat-dialog-actions>
`,
})
class EpgTrustConfirmDialogComponent {
readonly data = inject<EpgTrustConfirmDialogData>(MAT_DIALOG_DATA);
}
/**
* The import progress panel itself. `EpgProgressPanelComponent` defers it
* until the first import starts, so this file and its Material dependencies
* (tooltip, progress bar, icon, dialog) must stay out of every file that
* loads with the app.
*/
@Component({
selector: 'app-epg-progress-panel-view',
imports: [
MatButtonModule,
MatDialogModule,
MatIconModule,
MatTooltip,
MatProgressBar,
TranslatePipe,
],
templateUrl: './epg-progress-panel-view.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './epg-progress-panel-view.component.scss',
})
export class EpgProgressPanelViewComponent {
private readonly epgProgress = inject(EpgProgressService);
private readonly dialog = inject(MatDialog);
private readonly translate = inject(TranslateService);
readonly imports = this.epgProgress.imports;
readonly isVisible = this.epgProgress.isVisible;
readonly queuedCount = this.epgProgress.queuedCount;
readonly activeCount = this.epgProgress.activeCount;
readonly minimized = signal(false);
readonly loadingCount = computed(
() => this.imports().filter((i) => i.status === 'loading').length
);
toggleMinimize(): void {
this.minimized.update((v) => !v);
}
get activeImports() {
return this.imports().filter((item) => item.status !== 'queued');
}
get queuedImports() {
return this.imports()
.filter((item) => item.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
}
getStatusIcon(status: EpgImportProgress['status']): string {
switch (status) {
case 'queued':
return 'schedule';
case 'loading':
return 'sync';
case 'complete':
return 'check_circle';
case 'error':
return 'error';
case 'cancelled':
return 'cancel';
}
}
getStatusClass(status: EpgImportProgress['status']): string {
return `status-${status}`;
}
getDisplayUrl(url: string): string {
return formatEpgImportDisplayUrl(url);
}
formatCount(value: number): string {
// App language codes are not all BCP 47 tags (`zhtw`, `ary`, `by`);
// the date pipes already map those aliases for Intl.
const locale = normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
try {
return new Intl.NumberFormat(locale).format(value);
} catch {
return String(value);
}
}
dismiss(url: string): void {
this.epgProgress.dismiss(url);
}
dismissAll(): void {
this.epgProgress.dismissAll();
}
retry(url: string): void {
this.epgProgress.retry(url);
}
isActionableSecurityError(item: EpgImportProgress): boolean {
return (
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked ||
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate
);
}
getActionLabel(item: EpgImportProgress): string {
if (
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
) {
return this.translate.instant('EPG.ALLOW_PRIVATE_SOURCE');
}
return this.translate.instant('EPG.TRUST_TLS_HOST');
}
confirmTrust(item: EpgImportProgress): void {
if (
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
) {
this.openTrustDialog(
{
title: this.translate.instant(
'EPG.ALLOW_PRIVATE_SOURCE_TITLE'
),
message: this.translate.instant(
'EPG.ALLOW_PRIVATE_SOURCE_WARNING'
),
confirmLabel: this.translate.instant(
'EPG.ALLOW_PRIVATE_SOURCE'
),
},
() => {
void this.epgProgress.trustPrivateNetworkSourceAndRetry(
item.url
);
}
);
return;
}
this.openTrustDialog(
{
title: this.translate.instant('EPG.TRUST_TLS_HOST_TITLE'),
message: this.translate.instant('EPG.TRUST_TLS_HOST_WARNING'),
confirmLabel: this.translate.instant('EPG.TRUST_TLS_HOST'),
},
() => {
void this.epgProgress.trustInsecureTlsHostAndRetry(
item.url,
item.errorHost
);
}
);
}
private openTrustDialog(
data: EpgTrustConfirmDialogData,
onConfirm: () => void
): void {
this.dialog
.open<EpgTrustConfirmDialogComponent, EpgTrustConfirmDialogData>(
EpgTrustConfirmDialogComponent,
{ data, width: '420px' }
)
.afterClosed()
.subscribe((confirmed) => {
if (confirmed) {
onConfirm();
}
});
}
}
@@ -0,0 +1,109 @@
import { signal } from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
DeferBlockState,
TestBed,
} from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
EpgImportProgress,
EpgProgressService,
} from '@iptvnator/epg/data-access';
import { EpgProgressPanelComponent } from './epg-progress-panel.component';
describe('EpgProgressPanelComponent', () => {
const imports = signal<EpgImportProgress[]>([]);
const isVisible = signal(false);
const activeCount = signal(0);
const queuedCount = signal(0);
let fixture: ComponentFixture<EpgProgressPanelComponent>;
function setImports(items: EpgImportProgress[]): void {
imports.set(items);
isVisible.set(items.length > 0);
activeCount.set(items.filter((i) => i.status !== 'queued').length);
queuedCount.set(items.filter((i) => i.status === 'queued').length);
}
/** Lets the deferred view's chunk resolve, then renders it. */
async function settle(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
fixture.detectChanges();
}
async function configure(
deferBlockBehavior = DeferBlockBehavior.Playthrough
): Promise<void> {
await TestBed.configureTestingModule({
imports: [EpgProgressPanelComponent, TranslateModule.forRoot()],
providers: [
{
provide: EpgProgressService,
useValue: {
imports,
isVisible,
activeCount,
queuedCount,
dismiss: jest.fn(),
dismissAll: jest.fn(),
retry: jest.fn(),
},
},
],
deferBlockBehavior,
}).compileComponents();
fixture = TestBed.createComponent(EpgProgressPanelComponent);
await settle();
}
beforeEach(async () => {
setImports([]);
await configure();
});
afterEach(() => fixture.destroy());
it('renders no view while no import is running', () => {
expect(
fixture.nativeElement.querySelector('app-epg-progress-panel-view')
).toBeNull();
});
it('shows the panel with the first import and hides it again when the list empties', async () => {
setImports([{ url: 'https://example.com/epg.xml', status: 'loading' }]);
await settle();
expect(
fixture.nativeElement.querySelector(
'.epg-progress-panel .import-item.status-loading'
)
).not.toBeNull();
setImports([]);
await settle();
expect(
fixture.nativeElement.querySelector('.epg-progress-panel')
).toBeNull();
});
it('tells the user when the view chunk cannot be loaded during an import', async () => {
fixture.destroy();
TestBed.resetTestingModule();
await configure(DeferBlockBehavior.Manual);
setImports([{ url: 'https://example.com/epg.xml', status: 'loading' }]);
fixture.detectChanges();
const [block] = await fixture.getDeferBlocks();
await block.render(DeferBlockState.Error);
const notice = fixture.nativeElement.querySelector('[role="alert"]');
expect(notice?.textContent).toContain('EPG.IMPORT_PANEL_UNAVAILABLE');
expect(
fixture.nativeElement.querySelector('app-epg-progress-panel-view')
).toBeNull();
});
});
@@ -1,224 +1,54 @@
import {
Component,
computed,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
EpgImportProgress,
EpgProgressService,
} from '@iptvnator/epg/data-access';
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
import { normalizeDateLocale } from '@iptvnator/pipes/date-format';
import { formatEpgImportDisplayUrl } from './epg-import-display-url';
interface EpgTrustConfirmDialogData {
confirmLabel: string;
message: string;
title: string;
}
@Component({
selector: 'app-epg-trust-confirm-dialog',
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content class="mat-typography">
{{ data.message }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CANCEL' | translate }}
</button>
<button mat-flat-button color="primary" [mat-dialog-close]="true">
{{ data.confirmLabel }}
</button>
</mat-dialog-actions>
`,
})
class EpgTrustConfirmDialogComponent {
readonly data = inject<EpgTrustConfirmDialogData>(MAT_DIALOG_DATA);
}
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgProgressService } from '@iptvnator/epg/data-access';
import { EpgProgressPanelViewComponent } from './epg-progress-panel-view.component';
/**
* Mounted by the app shell on every launch, but the panel has nothing to
* show until an EPG import starts. The view, with its Material tooltip,
* progress bar, icon and dialog, is therefore loaded when the first import
* makes the panel visible instead of with the app. Should that chunk fail
* to load, a notice takes the panel's place while an import is running, so
* the import and its errors do not go unreported.
*/
@Component({
selector: 'app-epg-progress-panel',
standalone: true,
imports: [
MatButtonModule,
MatDialogModule,
MatIconModule,
MatTooltip,
MatProgressBar,
TranslatePipe,
],
templateUrl: './epg-progress-panel.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './epg-progress-panel.component.scss',
imports: [EpgProgressPanelViewComponent, TranslatePipe],
template: `
@defer (when isVisible()) {
<app-epg-progress-panel-view />
} @error {
@if (isVisible()) {
<div class="epg-progress-panel-unavailable" role="alert">
{{ 'EPG.IMPORT_PANEL_UNAVAILABLE' | translate }}
</div>
}
}
`,
styles: `
.epg-progress-panel-unavailable {
position: fixed;
bottom: 20px;
left: 76px;
z-index: 900;
width: 300px;
max-width: calc(100vw - 96px);
padding: 10px 12px;
border: 1px solid var(--app-separator);
border-radius: 12px;
background: var(--app-widget-bg);
color: var(--app-body-color);
font-size: 0.78rem;
line-height: 1.4;
}
@media (max-width: 640px) {
.epg-progress-panel-unavailable {
left: 12px;
}
}
`,
})
export class EpgProgressPanelComponent {
private readonly epgProgress = inject(EpgProgressService);
private readonly dialog = inject(MatDialog);
private readonly translate = inject(TranslateService);
readonly imports = this.epgProgress.imports;
readonly isVisible = this.epgProgress.isVisible;
readonly queuedCount = this.epgProgress.queuedCount;
readonly activeCount = this.epgProgress.activeCount;
readonly minimized = signal(false);
readonly loadingCount = computed(
() => this.imports().filter((i) => i.status === 'loading').length
);
toggleMinimize(): void {
this.minimized.update((v) => !v);
}
get activeImports() {
return this.imports().filter((item) => item.status !== 'queued');
}
get queuedImports() {
return this.imports()
.filter((item) => item.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
}
getStatusIcon(status: EpgImportProgress['status']): string {
switch (status) {
case 'queued':
return 'schedule';
case 'loading':
return 'sync';
case 'complete':
return 'check_circle';
case 'error':
return 'error';
case 'cancelled':
return 'cancel';
}
}
getStatusClass(status: EpgImportProgress['status']): string {
return `status-${status}`;
}
getDisplayUrl(url: string): string {
return formatEpgImportDisplayUrl(url);
}
formatCount(value: number): string {
// App language codes are not all BCP 47 tags (`zhtw`, `ary`, `by`);
// the date pipes already map those aliases for Intl.
const locale = normalizeDateLocale(
this.translate.currentLang || this.translate.defaultLang
);
try {
return new Intl.NumberFormat(locale).format(value);
} catch {
return String(value);
}
}
dismiss(url: string): void {
this.epgProgress.dismiss(url);
}
dismissAll(): void {
this.epgProgress.dismissAll();
}
retry(url: string): void {
this.epgProgress.retry(url);
}
isActionableSecurityError(item: EpgImportProgress): boolean {
return (
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked ||
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate
);
}
getActionLabel(item: EpgImportProgress): string {
if (
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
) {
return this.translate.instant('EPG.ALLOW_PRIVATE_SOURCE');
}
return this.translate.instant('EPG.TRUST_TLS_HOST');
}
confirmTrust(item: EpgImportProgress): void {
if (
item.errorCode ===
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
) {
this.openTrustDialog(
{
title: this.translate.instant(
'EPG.ALLOW_PRIVATE_SOURCE_TITLE'
),
message: this.translate.instant(
'EPG.ALLOW_PRIVATE_SOURCE_WARNING'
),
confirmLabel: this.translate.instant(
'EPG.ALLOW_PRIVATE_SOURCE'
),
},
() => {
void this.epgProgress.trustPrivateNetworkSourceAndRetry(
item.url
);
}
);
return;
}
this.openTrustDialog(
{
title: this.translate.instant('EPG.TRUST_TLS_HOST_TITLE'),
message: this.translate.instant('EPG.TRUST_TLS_HOST_WARNING'),
confirmLabel: this.translate.instant('EPG.TRUST_TLS_HOST'),
},
() => {
void this.epgProgress.trustInsecureTlsHostAndRetry(
item.url,
item.errorHost
);
}
);
}
private openTrustDialog(
data: EpgTrustConfirmDialogData,
onConfirm: () => void
): void {
this.dialog
.open<EpgTrustConfirmDialogComponent, EpgTrustConfirmDialogData>(
EpgTrustConfirmDialogComponent,
{ data, width: '420px' }
)
.afterClosed()
.subscribe((confirmed) => {
if (confirmed) {
onConfirm();
}
});
}
readonly isVisible = inject(EpgProgressService).isVisible;
}
@@ -444,6 +444,52 @@ describe('PlayerControlsComponent settings panel', () => {
fixture.nativeElement.remove();
});
it('returns focus to tune although removal fires focusout with no related target', async () => {
const tune = query(
'[data-test-id="player-controls-settings-button"]'
) as HTMLButtonElement;
document.body.appendChild(fixture.nativeElement);
tune.focus();
const matches = tune.matches.bind(tune);
Object.defineProperty(tune, 'matches', {
configurable: true,
value: (selector: string) =>
selector === ':focus-visible' || matches(selector),
});
tune.click();
fixture.detectChanges();
await fixture.whenStable();
const sheet = query(
'[data-test-id="player-controls-settings-panel"]'
) as HTMLElement;
expect(document.activeElement).toBe(sheet);
// Chromium fires focusout with relatedTarget null on the focused
// element while its subtree is being removed, in the same task
// as the removal; jsdom does not, so the event is dispatched by
// hand right before the close destroys the panel.
sheet.dispatchEvent(
new FocusEvent('focusout', {
bubbles: true,
relatedTarget: null,
})
);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
expect(
query('[data-test-id="player-controls-settings-panel"]')
).toBeNull();
expect(document.activeElement).toBe(
query('[data-test-id="player-controls-settings-button"]')
);
fixture.nativeElement.remove();
});
it('leaves focus alone when a pointer opens the sheet', async () => {
query('[data-test-id="player-controls-settings-button"]')?.click();
fixture.detectChanges();
@@ -122,7 +122,20 @@ export class PlayerSettingsPanelComponent implements OnDestroy {
onFocusOut(event: FocusEvent): void {
const next = event.relatedTarget;
this.focusInside = next instanceof Node && this.host.contains(next);
if (next instanceof Node) {
this.focusInside = this.host.contains(next);
return;
}
// No related target: focus went to the page, or the panel is being
// removed with focus inside, which Chromium reports with this event
// while the panel is still attached. The close destroys the panel in
// the same task, before this microtask runs, so a removal leaves the
// flag for ngOnDestroy to act on.
queueMicrotask(() => {
this.focusInside =
this.host.isConnected &&
this.host.contains(document.activeElement);
});
}
/**