mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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>
226 lines
7.6 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
}
|