diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js index 7483733..8a4a916 100644 --- a/public/configuration/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -57,7 +57,10 @@ const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // Match the React LoadingView background so the pre-React shell paints + // the same gradient — no light-blue login-skeleton flash before the + // loader takes over. + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index cff14e5..4d8fd1f 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -48,6 +48,26 @@ "timezone.settings": "Europe/Amsterdam", "youtube.publish.disabled": false, "user.badges.group.slot.enabled": true, + + "_comment_loading_screen": "Schermata di caricamento — sostituibili per traduzione/branding. Logo, sfondo e colore della barra usano lo standard CSS (URL, gradient, colore esadecimale). Le label compaiono sotto la barra di progresso man mano che ogni fase del boot completa.", + "loading.logo.url": "", + "loading.background": "", + "loading.progress.color": "linear-gradient(90deg,#4f8cff,#2563eb)", + "loading.task.boot": "Avvio in corso...", + "loading.task.session": "Verifica sessione", + "loading.task.renderer": "Inizializzazione renderer", + "loading.task.warmup": "Caricamento contenuti...", + "loading.task.assets": "Sto caricando gli asset di gioco", + "loading.task.localization": "Sto caricando le traduzioni", + "loading.task.avatar": "Sto caricando il guardaroba", + "loading.task.sounds": "Sto caricando i suoni", + "loading.task.startsession": "Avvio sessione", + "loading.task.userdata": "Caricamento dati utente", + "loading.task.rooms": "Caricamento stanze", + "loading.task.engine": "Caricamento engine grafico", + "loading.task.connect": "Connessione al server", + "loading.task.ready": "Pronto!", + "login.screen.enabled": true, "login.endpoint": "${api.url}/api/auth/login", "login.register.endpoint": "${api.url}/api/auth/register", diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 4b082fc..44976c6 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => { const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // Match the React LoadingView background so the pre-React shell paints + // the same gradient — no light-blue login-skeleton flash before the + // loader takes over. + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/src/App.tsx b/src/App.tsx index 6c3f00f..6bf4d4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,6 +72,29 @@ export const App: FC<{}> = props => const [ showLogin, setShowLogin ] = useState(false); const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin()); const [ prepareTrigger, setPrepareTrigger ] = useState(0); + const [ loadingProgress, setLoadingProgress ] = useState(0); + const [ loadingTask, setLoadingTask ] = useState(''); + // Look up a loader-stage label from renderer-config so the strings the user + // sees during the boot ("Sto caricando il guardaroba", "Connessione…") can + // be translated by editing the JSON/JSON5 config — fallback keeps the + // Italian baseline shipped with the client. + const taskLabel = useCallback((key: string, fallback: string): string => + { + try + { + const raw = GetConfiguration().getValue(key, ''); + return (typeof raw === 'string' && raw.length) ? raw : fallback; + } + catch + { + return fallback; + } + }, []); + const bumpProgress = useCallback((value: number, task?: string) => + { + setLoadingProgress(prev => (value > prev ? value : prev)); + if(task !== undefined) setLoadingTask(task); + }, []); const warmupPromiseRef = useRef>(null); const rendererPromiseRef = useRef>(null); const gameInitPromiseRef = useRef | null>(null); @@ -104,8 +127,36 @@ export const App: FC<{}> = props => catch {} }, []); + const showSessionExpired = useCallback(() => + { + console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)'); + clearStoredCredentials(); + + const baseUrl = window.location.origin + '/'; + setHomeUrl(baseUrl); + setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); + setIsReady(false); + setShowLogin(false); + setIsEnteringHotel(false); + }, [ clearStoredCredentials ]); + const fallbackToLogin = useCallback(() => { + // When login.screen.enabled is false this hotel uses SSO-only auth + // (CMS issues the ticket and redirects here). Surfacing a login form + // on init failure would just dump an empty/broken placeholder, since + // the form's backgrounds and Turnstile aren't even configured. Send + // the user back to the hotel home page instead. + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + + if(!loginScreenEnabled) + { + console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead'); + showSessionExpired(); + return; + } + // Using console.warn (not NitroLogger.log) on purpose: NitroLogger // is gated on LOG_DEBUG, which only flips to true once startWarmup's // GetConfiguration().init() completes. Auth-failure paths fire before @@ -119,20 +170,7 @@ export const App: FC<{}> = props => setIsReady(false); setShowLogin(true); setIsEnteringHotel(false); - }, [ clearStoredCredentials ]); - - const showSessionExpired = useCallback(() => - { - console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)'); - clearStoredCredentials(); - - const baseUrl = window.location.origin + '/'; - setHomeUrl(baseUrl); - setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); - setIsReady(false); - setShowLogin(false); - setIsEnteringHotel(false); - }, [ clearStoredCredentials ]); + }, [ clearStoredCredentials, showSessionExpired ]); const applySsoTicket = useCallback((ssoTicket: string) => { @@ -352,6 +390,7 @@ export const App: FC<{}> = props => warmupPromiseRef.current = (async () => { await GetConfiguration().init(); + bumpProgress(25, taskLabel('loading.task.warmup', 'Caricamento contenuti...')); GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); @@ -388,18 +427,29 @@ export const App: FC<{}> = props => loginImageUrls.forEach(preloadImage); gamedataUrls.forEach(url => preloadUrl(url)); - await Promise.all( - [ - GetAssetManager().downloadAssets(assetUrls), - GetLocalizationManager().init(), - GetAvatarRenderManager().init(), - GetSoundManager().init() - ] - ); + // Wire each warmup task to a progress bump so the bar reflects + // real subsystem-init completion, not a fake timer. Range 25→70. + // Each task carries a friendly label so the user sees what is + // currently being prepared instead of raw file names. + const warmupTasks: { promise: Promise; label: string }[] = [ + { promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Sto caricando gli asset di gioco') }, + { promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Sto caricando le traduzioni') }, + { promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Sto caricando il guardaroba') }, + { promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Sto caricando i suoni') } + ]; + let warmupDone = 0; + const warmupStart = 25; + const warmupSpan = 45; + await Promise.all(warmupTasks.map(t => t.promise.then(value => + { + warmupDone++; + bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label); + return value; + }))); })(); return warmupPromiseRef.current; - }, [ startRenderer ]); + }, [ startRenderer, bumpProgress, taskLabel ]); useEffect(() => { @@ -434,12 +484,18 @@ export const App: FC<{}> = props => urlSso: new URLSearchParams(window.location.search).get('sso') }); + const bootLabel = taskLabel('loading.task.boot', 'Avvio in corso...'); + setLoadingProgress(0); + setLoadingTask(bootLabel); + bumpProgress(5, bootLabel); + try { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); let ssoTicket = window.NitroConfig['sso.ticket']; if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); + bumpProgress(10, taskLabel('loading.task.session', 'Verifica sessione')); if(!ssoTicket || ssoTicket === '') { @@ -506,17 +562,23 @@ export const App: FC<{}> = props => } const renderer = await startRenderer(width, height); + bumpProgress(20, taskLabel('loading.task.renderer', 'Inizializzazione renderer')); await startWarmup(width, height); + bumpProgress(70, taskLabel('loading.task.startsession', 'Avvio sessione')); if(!gameInitPromiseRef.current) { gameInitPromiseRef.current = (async () => { await GetSessionDataManager().init(); + bumpProgress(78, taskLabel('loading.task.userdata', 'Caricamento dati utente')); await GetRoomSessionManager().init(); + bumpProgress(85, taskLabel('loading.task.rooms', 'Caricamento stanze')); await GetRoomEngine().init(); + bumpProgress(92, taskLabel('loading.task.engine', 'Caricamento engine grafico')); await GetCommunication().init(); + bumpProgress(98, taskLabel('loading.task.connect', 'Connessione al server')); })(); } @@ -545,6 +607,7 @@ export const App: FC<{}> = props => GetTicker().add(ticker => GetTexturePool().run()); } + bumpProgress(100, taskLabel('loading.task.ready', 'Pronto!')); setIsReady(true); setShowLogin(false); setIsEnteringHotel(false); @@ -581,12 +644,12 @@ export const App: FC<{}> = props => if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current); }; - }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]); + }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]); return ( { !isReady && !showLogin && - 0 } message={ errorMessage } homeUrl={ homeUrl } /> } + 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> } { !isReady && showLogin && } { isReady && } { /* Reconnect overlay must NOT render before we've actually entered diff --git a/src/components/avatar-effects/AvatarEffectsView.tsx b/src/components/avatar-effects/AvatarEffectsView.tsx index 887b9fb..eed77d8 100644 --- a/src/components/avatar-effects/AvatarEffectsView.tsx +++ b/src/components/avatar-effects/AvatarEffectsView.tsx @@ -1,4 +1,5 @@ import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { loadGamedata } from '@nitrots/utils'; import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; @@ -65,9 +66,11 @@ export const AvatarEffectsView: FC<{}> = () => { try { - const response = await fetch(url); - if(!response.ok) throw new Error(`HTTP ${ response.status }`); - const json = await response.json(); + // The effectmap is served either as a single JSON file or as a + // tiered directory with core/custom/seasonal manifests using + // JSON5 syntax (// comments allowed). loadGamedata picks the + // right mode for us and merges tiers. + const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url); if(cancelled) return; const list: EffectMapEntry[] = Array.isArray(json?.effects) diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx index 30e3c0c..f0542dd 100644 --- a/src/components/loading/LoadingView.tsx +++ b/src/components/loading/LoadingView.tsx @@ -1,22 +1,77 @@ -import { FC } from 'react'; +import { GetConfiguration } from '@nitrots/nitro-renderer'; +import { FC, useMemo } from 'react'; import loadingGif from '@/assets/images/loading/loading.gif'; +import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png'; import { Base, Column, Text } from '../../common'; interface LoadingViewProps { isError?: boolean; message?: string; homeUrl?: string; + progress?: number; + currentTask?: string; } +const resolveConfigUrl = (key: string): string => +{ + try + { + const raw = GetConfiguration().getValue(key, ''); + if(!raw) return ''; + + const interpolated = GetConfiguration().interpolate(raw) || raw; + return interpolated; + } + catch + { + return ''; + } +}; + +const resolveConfigString = (key: string, fallback = ''): string => +{ + try + { + const raw = GetConfiguration().getValue(key, ''); + if(!raw) return fallback; + return raw; + } + catch + { + return fallback; + } +}; + export const LoadingView: FC = props => { - const { isError = false, message = '', homeUrl = '' } = props; + const { isError = false, message = '', homeUrl = '', progress, currentTask = '' } = props; + + const customLogoUrl = useMemo(() => resolveConfigUrl('loading.logo.url'), []); + const customBackground = useMemo(() => resolveConfigString('loading.background', ''), []); + const progressBarColor = useMemo(() => resolveConfigString('loading.progress.color', 'linear-gradient(90deg,#4f8cff,#2563eb)'), []); + + const clampedProgress = typeof progress === 'number' && Number.isFinite(progress) + ? Math.max(0, Math.min(100, Math.round(progress))) + : null; + + const backgroundStyle = customBackground + ? { background: customBackground } + : undefined; + + const backgroundClassName = customBackground + ? 'fixed inset-0 z-[2147483000]' + : 'fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]'; return ( - + + Nitro V3 - { isError && (message && message.length) ? @@ -32,15 +87,59 @@ export const LoadingView: FC = props => } : - - - { message && message.length ? - - { message } - - : null + <> + + + { message && message.length ? + + { message } + + : null + } + + { clampedProgress !== null && + + + + + { clampedProgress }% + + + + { currentTask } + + } - + }