diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 764a6c31f..933a9aa11 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -106,7 +106,21 @@ export class AppComponent implements OnInit { // Settings are stored in IndexedDB and loaded by the settings store // Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component - this.translate.use(settings.language ?? this.DEFAULT_LANG); + const resolvedLang = + settings.language ?? this.DEFAULT_LANG; + this.translate.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 + // flash for non-English users. + try { + localStorage.setItem( + 'iptvnator:preferred-language', + resolvedLang + ); + } catch { + // Ignore quota / privacy mode errors. + } // Fetch EPG if URLs are configured (only fetch stale data) if ( diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index ba11259aa..56a9f91df 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -47,6 +47,48 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); } +/** + * Synchronously read the user's preferred language from localStorage so the + * very first render uses the right locale instead of flashing English first + * and re-rendering once the IDB-backed settings finish loading. + * + * The hint is written by AppComponent.initSettings() whenever the saved + * settings resolve, so first-ever boots fall back to English (no hint + * present yet) and every subsequent boot uses the saved language. + */ +const SUPPORTED_LANGS = new Set([ + 'ar', + 'ary', + 'by', + 'de', + 'el', + 'en', + 'es', + 'fr', + 'it', + 'ja', + 'ko', + 'nl', + 'pl', + 'pt', + 'ru', + 'tr', + 'zh', + 'zhtw', +]); +export const PREFERRED_LANGUAGE_STORAGE_KEY = 'iptvnator:preferred-language'; +export function getInitialLanguage(): string { + try { + const saved = localStorage.getItem(PREFERRED_LANGUAGE_STORAGE_KEY); + if (saved && SUPPORTED_LANGS.has(saved)) { + return saved; + } + } catch { + // localStorage may throw in privacy modes; fall through to default. + } + return 'en'; +} + /** * Conditionally provides the necessary service based on the current environment */ @@ -81,6 +123,7 @@ export const appConfig: ApplicationConfig = { loadingText: 'This item is actually loading...', }), TranslateModule.forRoot({ + defaultLanguage: getInitialLanguage(), loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, diff --git a/apps/web/src/index.html b/apps/web/src/index.html index a4d7ad9e0..ef7b41fe9 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -20,13 +20,62 @@ - + #initial-splash .splash-mark { + font-size: 18px; + font-weight: 600; + letter-spacing: 0.04em; + opacity: 0.85; + } + #initial-splash .splash-spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(255, 255, 255, 0.12); + border-top-color: rgba(255, 255, 255, 0.7); + border-radius: 50%; + animation: initial-splash-spin 0.9s linear infinite; + } + @keyframes initial-splash-spin { + to { + transform: rotate(360deg); + } + } + @media (prefers-reduced-motion: reduce) { + #initial-splash .splash-spinner { + animation: none; + opacity: 0.6; + } + } + +
+ IPTVnator + +
diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 9517f00e2..cf089c664 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -5,6 +5,16 @@ import { appConfig } from './app/app.config'; registerAppDateLocales(); -bootstrapApplication(AppComponent, appConfig).catch((err) => - console.error(err) -); +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 + // is bootstrapped, AppComponent has rendered and we can drop the + // splash. requestAnimationFrame ensures the swap happens after the + // first AppComponent paint, avoiding a flash of empty background + // between splash removal and Angular's first frame. + requestAnimationFrame(() => { + document.getElementById('initial-splash')?.remove(); + }); + }) + .catch((err) => console.error(err));