Files
iptvnator/apps/web/src/app/settings/settings-tmdb-section.component.ts
T
77458b3931 perf(web): make the app root and settings components OnPush (#1823)
* perf(web): make the app root and settings components OnPush

Plan item C6 step 3 for apps/web: the fifteen Eager components switch to
OnPush, among them the app root and the update notification panel that
the idle audit found re-rendering on every idle tick. Their template
state is signals from the settings facades, signal inputs and the shared
reactive settings form.

The checklist flagged the backup import, which patches the form from a
detached file input with no template event. A new spec patches only a
value, which changes no form status, and confirms the OnPush general
section still shows the new theme; it guards that path for the zoneless
flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): re-render OnPush sections when the form changes outside them

Review follow-ups (Greptile, Codex):

- The settings sections read form values in their templates (selected
  theme and cover size, epgField.value, form().value.player), and the
  parent changes the form outside their events: Discard and backup import
  patch it, the store hydrates it, the EPG file picker sets a control
  after an await. Under OnPush the section kept the old selection or EPG
  status. Each section now marks itself on its form's events
  (markSectionForCheckOnFormEvents).
- The value-only patch test no longer forces detectChanges(); with the
  fixture rendering on its own it fails without the marking, and so does
  a new test for a control set outside the EPG section.
- The zoneless guard counts only changeDetection metadata outside
  comments, so a comment naming the strategy is not an Eager component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(settings): guard the unsaved-changes bar after a save off the sections

Review follow-up (Codex): Save marks the form pristine after an async
store write, also on Backup, Reset or search, where no form section is
rendered. The OnPush page re-renders anyway because pristine and valid
read the form's state signals; the new test checks that on the Backup
page without forcing a render (it waits for the scheduled one).

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>
2026-10-06 15:58:28 +02:00

172 lines
5.5 KiB
TypeScript

import {
Component,
inject,
input,
signal,
ViewEncapsulation,
ChangeDetectionStrategy,
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@Component({
selector: 'app-settings-tmdb-section',
imports: [
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
ReactiveFormsModule,
TranslateModule,
],
templateUrl: './settings-tmdb-section.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
app-settings-tmdb-section {
display: contents;
}
.tmdb-key-test {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.tmdb-key-test__result {
align-items: center;
display: inline-flex;
font-size: 0.875rem;
gap: 4px;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
.tmdb-key-test__result--success {
color: #4caf50;
}
.tmdb-key-test__result--error {
color: #f44336;
}
.tmdb-cache__size {
font-variant-numeric: tabular-nums;
}
.tmdb-cache__error {
color: #f44336;
}
`,
],
})
export class SettingsTmdbSectionComponent {
private readonly tmdbApi = inject(TmdbApiService);
private readonly tmdbCache = inject(TmdbCacheService);
readonly form = input.required<FormGroup>();
readonly keyTestState = signal<TmdbKeyTestState>('idle');
readonly cacheStats = signal<TmdbCacheStats | null>(null);
readonly cacheError = signal(false);
readonly isClearing = signal(false);
constructor() {
markSectionForCheckOnFormEvents(this.form);
// Sizing the cache is a full table scan, but this component only
// exists while its section page is open, so loading on construction
// preserves the old "wait until the user is actually looking"
// behaviour. A failed read leaves the signals in their "unknown"
// state and reopening the section recreates the component, which
// retries naturally.
void this.refreshCacheStats();
}
get enteredApiKey(): string {
return (this.form().value.tmdb?.apiKey ?? '').trim();
}
/**
* The M3U movie-recognition toggle lives at the settings ROOT (it is a
* player-layout preference, not part of the persisted `tmdb` group this
* section's `formGroupName` wraps), so the template binds it via
* `[formControl]` instead of a group-relative `formControlName`.
* Nullable: the template hides the row for a form without the control.
*/
get m3uVodDetailsControl(): FormControl | null {
const control = this.form().get('m3uVodDetails');
return control instanceof FormControl ? control : null;
}
async testApiKey(): Promise<void> {
const apiKey = this.enteredApiKey;
if (!apiKey || this.keyTestState() === 'testing') {
return;
}
this.keyTestState.set('testing');
const isValid = await this.tmdbApi.validateApiKey(apiKey);
this.keyTestState.set(isValid ? 'success' : 'error');
}
async clearCache(): Promise<void> {
if (this.isClearing()) {
return;
}
this.isClearing.set(true);
try {
const deleted = await this.tmdbCache.clear();
if (deleted === null) {
// Do not claim an empty cache we failed to empty
this.cacheError.set(true);
this.cacheStats.set(null);
return;
}
await this.refreshCacheStats();
} finally {
this.isClearing.set(false);
}
}
/** "12.4 MB" — cache payloads are JSON, so decimal units read right */
formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB'];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
}
private async refreshCacheStats(): Promise<void> {
const stats = await this.tmdbCache.getStats();
this.cacheError.set(stats === null);
this.cacheStats.set(stats);
}
}