mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
commit
1d945e91b1
46 files changed
+2647
-82
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user