mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* docs(performance): inventory the zoneless change-detection migration Plan item C6 step 2. docs/architecture/zoneless-migration.md lists the 66 production files (67 components) that still set ChangeDetectionStrategy.Eager, the ten places where a template-read plain field is written outside an Angular event, the NgZone and ChangeDetectorRef calls to remove at the flip, and the IPC, player, observer, timer and dialog paths checked as signal-safe. On Angular 22 an unset changeDetection already means OnPush, so only the explicit Eager components re-render on every tick. zoneless-migration.spec.ts in the performance harness compares the checklist with the code: a new Eager component, or a converted one left unticked, fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(ui): make the libs/ui Eager components OnPush Plan item C6 step 3 for libs/ui/components and libs/ui/epg: eleven components (twelve with the EPG trust dialog) set ChangeDetectionStrategy.Eager and were checked on every tick, among them the always-mounted EPG progress panel the idle audit found re-rendering on every idle tick. Their template state is already signals, signal inputs, immutable dialog data or fields written from template events, so they switch to OnPush without other changes. The epg-item-description spec mutated dialog data after creation and marked only the fixture's host view; it now marks the component's own view, which OnPush requires. The libs/ui playback and remote-control components stay Eager for their own PRs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
7.7 KiB
TypeScript
245 lines
7.7 KiB
TypeScript
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);
|
|
}
|
|
|
|
@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',
|
|
})
|
|
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.translateWithFallback(
|
|
'EPG.ALLOW_PRIVATE_SOURCE',
|
|
'Allow source'
|
|
);
|
|
}
|
|
|
|
return this.translateWithFallback('EPG.TRUST_TLS_HOST', 'Trust host');
|
|
}
|
|
|
|
confirmTrust(item: EpgImportProgress): void {
|
|
if (
|
|
item.errorCode ===
|
|
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
|
|
) {
|
|
this.openTrustDialog(
|
|
{
|
|
title: this.translateWithFallback(
|
|
'EPG.ALLOW_PRIVATE_SOURCE_TITLE',
|
|
'Allow private-network EPG source?'
|
|
),
|
|
message: this.translateWithFallback(
|
|
'EPG.ALLOW_PRIVATE_SOURCE_WARNING',
|
|
'Only allow this if you trust the EPG source. IPTVnator will let this exact EPG URL connect to private or local network addresses.'
|
|
),
|
|
confirmLabel: this.translateWithFallback(
|
|
'EPG.ALLOW_PRIVATE_SOURCE',
|
|
'Allow source'
|
|
),
|
|
},
|
|
() => {
|
|
void this.epgProgress.trustPrivateNetworkSourceAndRetry(
|
|
item.url
|
|
);
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.openTrustDialog(
|
|
{
|
|
title: this.translateWithFallback(
|
|
'EPG.TRUST_TLS_HOST_TITLE',
|
|
'Trust invalid certificate?'
|
|
),
|
|
message: this.translateWithFallback(
|
|
'EPG.TRUST_TLS_HOST_WARNING',
|
|
'Only continue if you trust this host. IPTVnator will allow invalid TLS certificates for this host, but other hosts still require valid certificates.'
|
|
),
|
|
confirmLabel: this.translateWithFallback(
|
|
'EPG.TRUST_TLS_HOST',
|
|
'Trust 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();
|
|
}
|
|
});
|
|
}
|
|
|
|
private translateWithFallback(key: string, fallback: string): string {
|
|
const translated = this.translate.instant(key);
|
|
return translated === key ? fallback : translated;
|
|
}
|
|
}
|