perf(renderer): inline splash + sync language hint + drop legacy polyfill

Three first-paint fixes for cold app start.

#1 — Inline splash in index.html
Cold start used to show a blank Material-grey background until the
~1.5MB preloaded chunk + 300KB styles.css downloaded and Angular
bootstrapped. On slow disks/networks that's 1-3+ s of "is the app
frozen?" before any pixel of UI appears.

Add a self-contained splash (inline CSS, no extra HTTP, no assets) —
"IPTVnator" wordmark + a CSS spinner — that paints immediately from
the parsed HTML. Removed in main.ts after bootstrapApplication()
resolves, gated on requestAnimationFrame so the swap happens after
AppComponent's first paint (no flash of empty background between
splash removal and the real UI).

Respects prefers-reduced-motion (spinner stops, opacity dims).

#3 — Drop legacy `global = window` polyfill
The inline <script> in <head> ("if (global === undefined) var global =
window") was a workaround for an older library expecting Node's
`global`. Verified nothing in the current bundle reads window.global
(the only matches are TypeScript `declare global { ... }` blocks and
unrelated route paths). Removes one render-blocking inline <script>
parse from the critical path.

#5 — Synchronous language hint
Settings live in IndexedDB (via @ngx-pwa/local-storage), which the
TranslateModule cannot read synchronously at bootstrap. So the first
render historically used English regardless of the user's preference,
then re-rendered every TranslatePipe in the tree once initSettings()
finished — visible flash for non-English users.

Mirror the active language to localStorage in initSettings() once the
IDB-backed settings resolve. Add getInitialLanguage() in app.config.ts
that reads that key synchronously before bootstrap and passes it to
TranslateModule.forRoot({ defaultLanguage }). First-ever boot still
falls back to English (no hint yet); every subsequent boot renders in
the saved language from the very first frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-03 00:00:06 +02:00
1 parent 15b6ba985a
commit bbbe7b4036
4 files changed
+124 -8

No files matched your search

+15 -1
View File
@@ -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 (
+43
View File
@@ -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,
+53 -4
View File
@@ -20,13 +20,62 @@
<link rel="manifest" href="manifest.webmanifest" />
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png" />
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico" />
<script>
if (global === undefined) {
var global = window;
<style>
/* Inline splash — paints immediately while the JS bundle and the
styles.css (~300KB) are still downloading and Angular is
bootstrapping. Removed from the DOM by main.ts after
bootstrapApplication() resolves. No assets, zero extra HTTP. */
#initial-splash {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
background: #1f1f23;
color: #f5f5f7;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
z-index: 99999;
}
</script>
#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;
}
}
</style>
</head>
<body class="mat-app-background">
<div
id="initial-splash"
role="status"
aria-label="Loading IPTVnator"
>
<span class="splash-mark">IPTVnator</span>
<span class="splash-spinner" aria-hidden="true"></span>
</div>
<app-root></app-root>
</body>
</html>
+13 -3
View File
@@ -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));