Merge remote-tracking branch 'origin/master' into claude/parental-control-feature-31dde2

# Conflicts:
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png
This commit is contained in:
4gray committed 2026-09-26 14:18:36 +02:00
commit 1d945e91b1
46 files changed
+2647 -82

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import { formatDate } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { Language } from '@iptvnator/shared/interfaces';
import {
APP_DATE_LOCALE_LOADERS,
AppDateLocaleService,
registerAppDateLocale,
} from './app-date-locales';
const sampleDate = new Date(2024, 0, 15, 13, 5);
describe('registerAppDateLocale', () => {
it('resolves for English without loading anything', async () => {
const loader = jest.fn(() => import('@angular/common/locales/de'));
await registerAppDateLocale('en', { en: loader });
await registerAppDateLocale('', { en: loader });
await registerAppDateLocale(undefined, { en: loader });
expect(loader).not.toHaveBeenCalled();
});
it('loads the locale once and shares the in-flight import', async () => {
const loader = jest.fn(() => import('@angular/common/locales/fr'));
await Promise.all([
registerAppDateLocale('fr', { fr: loader }),
registerAppDateLocale('fr', { fr: loader }),
]);
await registerAppDateLocale('fr', { fr: loader });
expect(loader).toHaveBeenCalledTimes(1);
expect(formatDate(sampleDate, 'MMMM', 'fr')).toBe('janvier');
});
it('maps app language aliases to Angular locale ids before loading', async () => {
const loader = jest.fn(() => import('@angular/common/locales/be'));
await registerAppDateLocale('by', { be: loader });
expect(loader).toHaveBeenCalledTimes(1);
expect(formatDate(sampleDate, 'MMMM', 'be')).toBe('студзеня');
});
it('falls back to English formatting on a failed import and retries on the next call', async () => {
const failing = jest.fn(() => Promise.reject(new Error('offline')));
const working = jest.fn(() => import('@angular/common/locales/pl'));
await expect(
registerAppDateLocale('pl', { pl: failing })
).resolves.toBeUndefined();
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('January');
await registerAppDateLocale('pl', { pl: working });
expect(working).toHaveBeenCalledTimes(1);
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('stycznia');
});
it('resolves for a language without bundled data instead of rejecting', async () => {
await expect(registerAppDateLocale('xx', {})).resolves.toBeUndefined();
});
it('bundles a loader for every supported language except English', () => {
const expectedLocales = Object.values(Language)
.filter((language) => language !== Language.ENGLISH)
.map((language) => normalizeDateLocale(language))
.sort();
expect(Object.keys(APP_DATE_LOCALE_LOADERS).sort()).toEqual(
expectedLocales
);
});
it('registers working data for every supported language', async () => {
for (const language of Object.values(Language)) {
await registerAppDateLocale(language);
const locale = normalizeDateLocale(language);
expect(formatDate(sampleDate, 'MMMM', locale)).not.toBe('');
}
expect(formatDate(sampleDate, 'MMMM', 'ar-MA')).toBe('يناير');
expect(formatDate(sampleDate, 'MMMM', 'zh-Hant')).toBe('1月');
});
});
describe('AppDateLocaleService', () => {
let translate: { use: jest.Mock };
let service: AppDateLocaleService;
beforeEach(() => {
translate = { use: jest.fn() };
TestBed.configureTestingModule({
providers: [{ provide: TranslateService, useValue: translate }],
});
service = TestBed.inject(AppDateLocaleService);
});
it('delegates register to registerAppDateLocale', async () => {
await expect(
service.register(Language.GERMAN)
).resolves.toBeUndefined();
expect(formatDate(sampleDate, 'MMMM', 'de')).toBe('Januar');
});
it('registers the locale data before switching the language', async () => {
await service.use(Language.HUNGARIAN);
expect(translate.use).toHaveBeenCalledWith(Language.HUNGARIAN);
expect(formatDate(sampleDate, 'MMMM', 'hu')).toBe('január');
});
it('applies only the language requested last when switches overlap', async () => {
await Promise.all([
service.use(Language.ITALIAN),
service.use(Language.TURKISH),
]);
expect(translate.use).toHaveBeenCalledTimes(1);
expect(translate.use).toHaveBeenCalledWith(Language.TURKISH);
});
});
+117 -42
View File
@@ -1,48 +1,123 @@
import { registerLocaleData } from '@angular/common';
import localeAr from '@angular/common/locales/ar';
import localeArMa from '@angular/common/locales/ar-MA';
import localeBe from '@angular/common/locales/be';
import localeDe from '@angular/common/locales/de';
import localeEl from '@angular/common/locales/el';
import localeEs from '@angular/common/locales/es';
import localeFr from '@angular/common/locales/fr';
import localeHu from '@angular/common/locales/hu';
import localeIt from '@angular/common/locales/it';
import localeJa from '@angular/common/locales/ja';
import localeKo from '@angular/common/locales/ko';
import localeNl from '@angular/common/locales/nl';
import localePl from '@angular/common/locales/pl';
import localePt from '@angular/common/locales/pt';
import localeRu from '@angular/common/locales/ru';
import localeTr from '@angular/common/locales/tr';
import localeZh from '@angular/common/locales/zh';
import localeZhHant from '@angular/common/locales/zh-Hant';
import localeEn from '@angular/common/locales/en';
import { inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { normalizeDateLocale } from '@iptvnator/pipes';
import { createDevLogger } from '@iptvnator/shared/interfaces';
let localesRegistered = false;
type LocaleDataModule = { default: unknown };
export type AppDateLocaleLoader = () => Promise<LocaleDataModule>;
export function registerAppDateLocales(): void {
if (localesRegistered) {
return;
const debug = createDevLogger('AppDateLocales');
/**
* Angular ships only English locale data; every other UI language needs its
* data registered before `DatePipe` can format with it. Each entry is a
* separate lazy chunk, keyed by the Angular locale id that
* `normalizeDateLocale()` produces for an app language (`by` -> `be`,
* `ary` -> `ar-MA`, `zhtw` -> `zh-Hant`). Importing all of them eagerly put
* the data for 18 languages into the initial bundle of every user.
*/
export const APP_DATE_LOCALE_LOADERS: Readonly<
Record<string, AppDateLocaleLoader>
> = {
ar: () => import('@angular/common/locales/ar'),
'ar-MA': () => import('@angular/common/locales/ar-MA'),
be: () => import('@angular/common/locales/be'),
de: () => import('@angular/common/locales/de'),
el: () => import('@angular/common/locales/el'),
es: () => import('@angular/common/locales/es'),
fr: () => import('@angular/common/locales/fr'),
hu: () => import('@angular/common/locales/hu'),
it: () => import('@angular/common/locales/it'),
ja: () => import('@angular/common/locales/ja'),
ko: () => import('@angular/common/locales/ko'),
nl: () => import('@angular/common/locales/nl'),
pl: () => import('@angular/common/locales/pl'),
pt: () => import('@angular/common/locales/pt'),
ru: () => import('@angular/common/locales/ru'),
tr: () => import('@angular/common/locales/tr'),
zh: () => import('@angular/common/locales/zh'),
'zh-Hant': () => import('@angular/common/locales/zh-Hant'),
};
const registeredLocales = new Set<string>(['en']);
const pendingLocales = new Map<string, Promise<void>>();
/**
* Registers the Angular locale data for an app language, loading it on first
* use. Resolves once `DatePipe` can format with that locale, so call it before
* `TranslateService.use()`: the language switch re-renders every date with the
* new locale, and a locale whose data has not arrived throws. English resolves
* immediately, as does a locale that is already registered or in flight.
*
* A failed load resolves rather than rejects, but never leaves the locale
* without data: English formatting is registered under the requested id so
* `DatePipe` keeps rendering instead of throwing in every template that passes
* the locale. The locale is not marked registered, so the next call retries
* the import and a success replaces the fallback.
*/
export function registerAppDateLocale(
language: string | null | undefined,
loaders: Readonly<
Record<string, AppDateLocaleLoader>
> = APP_DATE_LOCALE_LOADERS
): Promise<void> {
const locale = normalizeDateLocale(language);
if (registeredLocales.has(locale)) {
return Promise.resolve();
}
const inFlight = pendingLocales.get(locale);
if (inFlight) {
return inFlight;
}
const loader = loaders[locale];
if (!loader) {
debug('No bundled Angular locale data for', locale);
return Promise.resolve();
}
registerLocaleData(localeAr, 'ar');
registerLocaleData(localeArMa, 'ar-MA');
registerLocaleData(localeBe, 'be');
registerLocaleData(localeDe, 'de');
registerLocaleData(localeEl, 'el');
registerLocaleData(localeEs, 'es');
registerLocaleData(localeFr, 'fr');
registerLocaleData(localeHu, 'hu');
registerLocaleData(localeIt, 'it');
registerLocaleData(localeJa, 'ja');
registerLocaleData(localeKo, 'ko');
registerLocaleData(localeNl, 'nl');
registerLocaleData(localePl, 'pl');
registerLocaleData(localePt, 'pt');
registerLocaleData(localeRu, 'ru');
registerLocaleData(localeTr, 'tr');
registerLocaleData(localeZh, 'zh');
registerLocaleData(localeZhHant, 'zh-Hant');
localesRegistered = true;
const load = loader()
.then((module) => {
registerLocaleData(module.default, locale);
registeredLocales.add(locale);
})
.catch((error: unknown) => {
debug(
'Loading Angular locale data failed; using English formatting for',
locale,
error
);
registerLocaleData(localeEn, locale);
})
.finally(() => {
pendingLocales.delete(locale);
});
pendingLocales.set(locale, load);
return load;
}
/** Gates UI language switches on the locale data they render with. */
@Injectable({ providedIn: 'root' })
export class AppDateLocaleService {
private readonly translate = inject(TranslateService);
private latestRequest = 0;
register(language: string | null | undefined): Promise<void> {
return registerAppDateLocale(language);
}
/**
* Registers the locale data, then switches the UI language. Switches are
* ordered by request, not by completion: a switch whose data arrives
* after a newer request was made is dropped, so the language chosen last
* is the one that ends up active.
*/
async use(language: string): Promise<void> {
const request = ++this.latestRequest;
await registerAppDateLocale(language);
if (request === this.latestRequest) {
this.translate.use(language);
}
}
}
+8 -2
View File
@@ -33,6 +33,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { AppComponent } from './app.component';
import { AppDateLocaleService } from './app-date-locales';
import { ElectronServiceStub } from './services/electron.service.stub';
import { SettingsService } from './services/settings.service';
@@ -146,6 +147,11 @@ describe('AppComponent', () => {
setDefaultLang: jest.fn(),
use: jest.fn(),
}),
// The real service imports locale chunks; the language switch
// is gated on it, so it must resolve deterministically here.
MockProvider(AppDateLocaleService, {
use: jest.fn().mockResolvedValue(undefined),
}),
{
provide: WORKSPACE_SHELL_ACTIONS,
useValue: {
@@ -233,12 +239,12 @@ describe('AppComponent', () => {
});
settingsService.getValueFromLocalStorage.mockReturnValue(of(settings));
jest.spyOn(settingsService, 'changeTheme');
jest.spyOn(translateService, 'use');
const dateLocales = TestBed.inject(AppDateLocaleService);
component.initSettings();
await fixture.whenStable();
expect(translateService.use).toHaveBeenCalledWith(Language.SPANISH);
expect(dateLocales.use).toHaveBeenCalledWith(Language.SPANISH);
expect(settingsService.changeTheme).toHaveBeenCalledWith(
Theme.DarkTheme
);
+5 -1
View File
@@ -39,6 +39,7 @@ import {
Theme,
createDevLogger,
} from '@iptvnator/shared/interfaces';
import { AppDateLocaleService } from './app-date-locales';
import { SettingsService } from './services/settings.service';
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
@@ -71,6 +72,7 @@ export class AppComponent implements OnInit {
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
private readonly dateLocales = inject(AppDateLocaleService);
private epgBridge = inject(EpgRuntimeBridgeService);
private epgService = inject(EpgService);
private snackBar = inject(MatSnackBar);
@@ -160,7 +162,9 @@ export class AppComponent implements OnInit {
// Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component
const resolvedLang = settings.language ?? this.DEFAULT_LANG;
this.translate.use(resolvedLang);
// The switch re-renders every date with the new locale;
// its data is a lazy chunk that must be registered first.
void this.dateLocales.use(resolvedLang);
// Mirror the active language to localStorage so the next
// cold start can read it synchronously in app.config.ts's
// getInitialLanguage() and avoid the English-then-localized
@@ -12,10 +12,10 @@ import {
Language,
Theme,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
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,
@@ -32,6 +32,7 @@ type SettingsFormPatch = Parameters<SettingsForm['patchValue']>[0];
*/
@Injectable()
export class SettingsFormFacade {
private readonly dateLocales = inject(AppDateLocaleService);
private readonly destroyRef = inject(DestroyRef);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly formBuilder = inject(FormBuilder);
@@ -39,7 +40,6 @@ export class SettingsFormFacade {
private readonly settingsService = inject(SettingsService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly settingsStore = inject(SettingsStore);
private readonly translate = inject(TranslateService);
readonly supportsEpg =
this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement;
@@ -192,7 +192,10 @@ export class SettingsFormFacade {
/** Applies the saved language/theme and resets the dirty state */
applySavedSettings(): void {
this.form.markAsPristine();
this.translate.use(this.form.value.language ?? Language.ENGLISH);
// 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
);
+2 -2
View File
@@ -3,11 +3,11 @@
// `ng build --env=prod` then `index.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `.angular-cli.json`.
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: false,
environment: 'DEV',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'http://localhost:3000',
};
@@ -1,8 +1,8 @@
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: true,
environment: 'PROD',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'https://iptvnator-playlist-parser-api.vercel.app',
};
+2 -2
View File
@@ -1,8 +1,8 @@
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: false,
environment: 'LOCAL',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'http://localhost:3000',
};
+2 -2
View File
@@ -1,8 +1,8 @@
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: false,
environment: 'WEB',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'http://localhost:3333',
};
+8 -5
View File
@@ -1,10 +1,8 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces';
import { registerAppDateLocales } from './app/app-date-locales';
import { registerAppDateLocale } from './app/app-date-locales';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
registerAppDateLocales();
import { appConfig, getInitialLanguage } from './app/app.config';
// A reloaded packaged renderer arrives on index.html with the route it was
// on carried in the query string (the Electron main process recovers the
@@ -18,7 +16,12 @@ if (restoredHref !== null) {
window.history.replaceState(window.history.state, '', restoredHref);
}
bootstrapApplication(AppComponent, appConfig)
// Angular ships only English locale data; the preferred language's data is a
// lazy chunk. It is awaited before bootstrapping because the first route
// renders dates with that locale, and a locale without data throws. English
// (the default) resolves immediately.
registerAppDateLocale(getInitialLanguage())
.then(() => bootstrapApplication(AppComponent, appConfig))
.then(() => {
// Splash is rendered eagerly by index.html so the user sees something
// immediately instead of a blank Material-grey background. Once Angular
+12
View File
@@ -0,0 +1,12 @@
// Jest's ESM loader exposes a JSON module only as a default export, while the
// app imports `{ version }` from '@package' so esbuild can tree-shake the rest
// of package.json out of the bundle. This stub serves the real file's fields
// as named exports for tests.
import { readFileSync } from 'node:fs';
const packageJson = JSON.parse(
readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8')
);
export const version = packageJson.version;
export default packageJson;