mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
Merge remote-tracking branch 'origin/master' into claude/design-handoff-prompt-d0aee4
This commit is contained in:
commit
31899d7b58
54 files changed
+4075
-1631
No files matched your search
+20
@@ -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();
|
||||
|
||||
+30
-2
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
}
|
||||
+46
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user