Files
iptvnator/apps/web/src/app/settings/settings-tmdb-section.component.ts
T
4grayandClaude Opus 5.5 8d01ae5f75 perf(web): provide form-field defaults per component, not at the root
app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root
injector. Importing the token kept @angular/material/form-field in
main.js, and Material 22.2's form field imports @angular/forms/signals,
which would add @angular/forms to startup as well (the reason the
Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every
form field renders in a lazy chunk, so the outline + dynamic defaults
now come from provideFormFieldDefaults()
(@iptvnator/ui/components/form-field-defaults) on each component that
renders <mat-form-field>.

tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate,
run in CI) reports a component that renders a form field without the
provider, and any use of the token outside the shared provider. The
Xtream import form test asserts outlined, dynamically sized fields with
no root provider; without the component provider they render as fill.

renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local
production build; the baseline is lowered to the measured value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:39:00 +02:00

174 lines
5.6 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 { 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 { MatSlideToggleModule } from '@angular/material/slide-toggle';
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';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@Component({
selector: 'app-settings-tmdb-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSlideToggleModule,
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);
}
}