mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
15b6ba985a
commit
bbbe7b4036
4 files changed
+124
-8
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
Reference in new issue
Block a user