Files
iptvnator/apps/web/src/app/settings/settings-form.facade.ts
T
4grayandClaude Fable 5.1 512e9787a8 perf(web): load Angular date locales lazily per language (#1695)
Plan thread C1, journey **J1 `launch`**, counter **`renderer.initialBytes`**. Stacked on #1694 → #1693 → #1692; merge in order.

`apps/web/src/app/app-date-locales.ts` imported the locale data of all 18 supported languages eagerly, so every user shipped and parsed all of it at startup. Each locale is now a dynamic import keyed by the Angular locale id that `normalizeDateLocale()` derives from the app language (`by` → `be`, `ary` → `ar-MA`, `zhtw` → `zh-Hant`); English needs no data.

Ordering is preserved so no template renders a locale whose data has not arrived (Angular throws in that case):
- `main.ts` awaits the initial language's data (from `getInitialLanguage()`) before `bootstrapApplication`.
- Both `TranslateService.use()` call sites, `AppComponent.initSettings()` and `SettingsFormFacade.applySavedSettings()`, register the data first through the new `AppDateLocaleService`.
- A failed load never leaves the locale without data: English formatting is registered under the requested id (eager 1.1 KB `@angular/common/locales/en`), so `DatePipe` renders instead of throwing; the locale is not marked registered, so the next call retries and a success replaces the fallback (review follow-up).
- `AppDateLocaleService.use()` orders switches by request, not by completion: a switch whose data arrives after a newer request is dropped, so the language chosen last wins (review follow-up).

No kill switch: behavior is identical once the locale resolves, and the only new failure mode (a same-origin chunk failing to load) is shared with every lazy route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:44:29 +02:00

226 lines
7.6 KiB
TypeScript

import { DestroyRef, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder } from '@angular/forms';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
EpgSourceReconciliationError,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
CoverSize,
EpgViewMode,
Language,
Theme,
} from '@iptvnator/shared/interfaces';
import { SettingsSnackbarService } from './settings-snackbar.service';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
import { AppDateLocaleService } from '../app-date-locales';
import {
applyEpgUrlsToFormArray,
createEpgUrlControl,
createSettingsForm,
createSettingsFromFormValue,
SettingsForm,
} from './settings-form.utils';
type SettingsFormPatch = Parameters<SettingsForm['patchValue']>[0];
/**
* Owns the settings form: creation, hydration from the store, the small
* mutations the section components trigger, and persisting on submit.
*/
@Injectable()
export class SettingsFormFacade {
private readonly dateLocales = inject(AppDateLocaleService);
private readonly destroyRef = inject(DestroyRef);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly formBuilder = inject(FormBuilder);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsService = inject(SettingsService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly settingsStore = inject(SettingsStore);
readonly supportsEpg =
this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement;
/** Settings form object */
readonly form = createSettingsForm(this.formBuilder, this.supportsEpg);
/** Form array with epg sources — absent when EPG is unsupported */
readonly epgUrl = this.form.get('epgUrl') as FormArray;
/** Trimmed, non-empty EPG source URLs currently in the form */
get epgUrls(): string[] {
return ((this.epgUrl?.value as string[] | undefined) ?? [])
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
}
/** Waits for the persisted settings to be available */
async loadSettings(): Promise<void> {
await this.settingsStore.loadSettings();
if (this.settingsStore.storageFailure() === 'load') {
// The form is about to show defaults that are not the user's saved
// values — say so instead of letting them look genuine.
this.settingsSnackbar.storageFailure('load');
}
}
/**
* Sets saved settings from the indexed db store
*/
hydrateFromStore(): void {
const currentSettings = this.settingsStore.getSettings();
this.form.patchValue(currentSettings);
this.syncDashboardControlsEnabledState(
currentSettings.showDashboard ?? true
);
if (this.supportsEpg && currentSettings.epgUrl) {
this.epgUrl.clear();
this.setEpgUrls(currentSettings.epgUrl);
}
}
bindDashboardControlsEnabledState(): void {
this.form
.get('showDashboard')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((showDashboard) =>
this.syncDashboardControlsEnabledState(showDashboard ?? true)
);
}
selectTheme(theme: Theme): void {
if (this.form.value.theme === theme) {
return;
}
this.patchAndMarkDirty({ theme }, 'theme');
this.settingsService.changeTheme(theme);
}
/**
* Staged like every other control — the write happens on Save. These two
* used to persist eagerly, which made Discard a lie: `hydrateFromStore()`
* would faithfully reload the just-persisted edit and the unsaved bar
* disappeared without anything being reverted.
*/
selectCoverSize(coverSize: CoverSize): void {
if (this.form.value.coverSize === coverSize) {
return;
}
this.patchAndMarkDirty({ coverSize }, 'coverSize');
}
selectEpgViewMode(epgViewMode: EpgViewMode): void {
if (this.form.value.epgViewMode === epgViewMode) {
return;
}
this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode');
}
setRecordingFolder(recordingFolder: string): void {
this.patchAndMarkDirty({ recordingFolder }, 'recordingFolder');
}
/**
* Sets the epg urls to the form array
* @param epgUrls urls of the EPG sources
*/
setEpgUrls(epgUrls: string[] | string): void {
applyEpgUrlsToFormArray(this.epgUrl, epgUrls);
}
/**
* Initializes new entry in form array for EPG URL
*/
addEpgSource(): void {
this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl());
}
/**
* Removes entry from form array for EPG URL
* @param index index of the item to remove
*/
removeEpgSource(index: number): void {
this.epgUrl.removeAt(index);
this.epgUrl.markAsDirty();
this.form.markAsDirty();
}
/**
* Persists the form to the settings store and mirrors the result to the
* desktop backend.
* @param onSaved runs after the store write, before the backend is
* notified, so the UI confirmation is not delayed by IPC
*/
async save(onSaved: () => void): Promise<void> {
const settings = createSettingsFromFormValue(
this.form,
this.settingsStore.getSettings()
);
let cleanupError: EpgSourceReconciliationError | undefined;
try {
await this.settingsStore.updateSettings(settings, {
retryEpgCleanup: this.epgUrl?.dirty ?? false,
});
} catch (error) {
if (!(error instanceof EpgSourceReconciliationError)) throw error;
// This error follows a successful settings write. Mirror the
// committed values, but retain the dirty form for cleanup retry.
cleanupError = error;
}
if (!cleanupError) onSaved();
if (window.electron) {
window.electron.updateSettings(settings);
if (this.runtime.supportsExternalPlayerPathSettings) {
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
}
}
if (cleanupError) throw cleanupError;
}
/** Applies the saved language/theme and resets the dirty state */
applySavedSettings(): void {
this.form.markAsPristine();
// The switch re-renders every date with the new locale; its data is
// a lazy chunk that must be registered first, and a newer choice
// must win over an older one whose data arrives later.
void this.dateLocales.use(this.form.value.language ?? Language.ENGLISH);
this.settingsService.changeTheme(
this.form.value.theme ?? Theme.SystemTheme
);
}
private patchAndMarkDirty(
value: SettingsFormPatch,
controlName: string
): void {
this.form.patchValue(value);
this.form.get(controlName)?.markAsDirty();
this.form.markAsDirty();
}
private syncDashboardControlsEnabledState(showDashboard: boolean): void {
const dashboardRails = this.form.get('dashboardRails');
if (!dashboardRails) {
return;
}
if (showDashboard) {
dashboardRails.enable({ emitEvent: false });
} else {
dashboardRails.disable({ emitEvent: false });
}
}
}