From 455e5cf8d8fe516a9383c00da9b13f88f18e9dab Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 24 Jun 2026 20:48:49 +0000 Subject: [PATCH] non blocking status loading --- frontend/js/main.js | 6 ++ frontend/js/storage.js | 141 +++++++++++++++++++++++++++++++---------- 2 files changed, 114 insertions(+), 33 deletions(-) diff --git a/frontend/js/main.js b/frontend/js/main.js index 99b5f63..38949a1 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -34,6 +34,12 @@ window.App = window.App || {}; await App.videos.loadVideos(); App.favorites.syncButtons(); + + // The UI above is rendered entirely from the last known status cached in + // localStorage, so startup never blocks on (or breaks because of) a slow + // or failing status endpoint. Now fetch fresh status in the background and + // reconcile the UI with whatever comes back. + App.storage.refreshServerStatusInBackground(); } initApp(); diff --git a/frontend/js/storage.js b/frontend/js/storage.js index f8dd861..d206ea8 100644 --- a/frontend/js/storage.js +++ b/frontend/js/storage.js @@ -174,7 +174,11 @@ App.session = App.session || {}; return selected; }; - // Ensures defaults exist and refreshes server status. + // Ensures defaults exist and establishes a session from cached status. + // Intentionally does NOT touch the network: the last known status of every + // server is persisted in localStorage, so the UI can render instantly from + // it. Fresh status is fetched separately (and non-blockingly) via + // refreshServerStatusInBackground(). App.storage.ensureDefaults = async function() { if (!localStorage.getItem('config')) { localStorage.setItem('config', JSON.stringify({ @@ -200,13 +204,79 @@ App.session = App.session || {}; if (!localStorage.getItem(FEED_END_BEHAVIOR_KEY)) { localStorage.setItem(FEED_END_BEHAVIOR_KEY, 'loop'); } - await App.storage.initializeServerStatus(); + App.storage.ensureSessionFromCache(); }; - // Fetches server status and keeps the session pointing to a valid channel/options. + // A stable fingerprint of which server/channel a session targets, used to + // decide whether a status refresh actually changed what's being shown (and + // thus whether videos need reloading). + function sessionSignature(session) { + if (!session) return ''; + return `${session.server}::${session.channel ? session.channel.id : ''}`; + } + + // Builds a session pointing at a valid channel/options using ONLY the status + // data already cached in `config` (no network). Returns the session object, + // or null if no server in the config currently exposes any channels. + App.session.buildSessionFromCache = function(config) { + if (!config || !Array.isArray(config.servers) || config.servers.length === 0) return null; + const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]); + const existingSession = App.storage.getSession(); + const selectedServerKey = existingSession && serverKeys.includes(existingSession.server) + ? existingSession.server + : serverKeys[0]; + const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey); + const serverData = serverEntry ? serverEntry[selectedServerKey] : null; + if (!serverData || !Array.isArray(serverData.channels) || serverData.channels.length === 0) { + return null; + } + const prefs = App.storage.getPreferences(); + const serverPrefs = prefs[selectedServerKey] || {}; + const channel = App.session.resolveChannelById(serverData, serverPrefs.channelId) || serverData.channels[0]; + const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null; + const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel); + return { + server: selectedServerKey, + channel: channel, + options: options, + }; + }; + + // Ensures the stored session points at a channel that still exists in the + // cached status, rebuilding it from cache if necessary. Never clears a valid + // selection. Returns true if a usable session exists afterwards. + App.storage.ensureSessionFromCache = function() { + const config = App.storage.getConfig(); + const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]); + const existingSession = App.storage.getSession(); + + // Leave a still-valid session untouched so we don't disturb the user's + // current server/channel selection on refresh. + if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) { + const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === existingSession.server); + const serverData = serverEntry ? serverEntry[existingSession.server] : null; + if (serverData && App.session.resolveChannelById(serverData, existingSession.channel.id)) { + return true; + } + } + + const sessionData = App.session.buildSessionFromCache(config); + if (sessionData) { + App.storage.setSession(sessionData); + App.session.savePreference(sessionData); + return true; + } + return false; + }; + + // Fetches fresh server status and merges it into the cached config. Crucially, + // a failed status request preserves the server's LAST KNOWN status (channels, + // groups, etc.) instead of wiping it -- so a flaky/down status endpoint can no + // longer brick the app. Returns true if the active session's target changed + // (e.g. channels appeared for the first time), signalling a video reload. App.storage.initializeServerStatus = async function() { const config = JSON.parse(localStorage.getItem('config')); - if (!config || !config.servers) return; + if (!config || !config.servers) return false; const fetchDirectStatus = async (server) => { const directUrl = `${server.replace(/\/$/, '')}/api/status`; @@ -231,6 +301,7 @@ App.session = App.session || {}; const statusPromises = config.servers.map(async (serverObj) => { const server = Object.keys(serverObj)[0]; + const prior = serverObj[server]; try { // Try a direct request first, then fall back to the server-side proxy. try { @@ -239,41 +310,45 @@ App.session = App.session || {}; serverObj[server] = await fetchProxiedStatus(server); } } catch (err) { - serverObj[server] = { - online: false, - channels: [] - }; + // The request failed. Keep the last known good status so the user + // doesn't lose their channels when the status endpoint is down; + // just flag it offline. Only fall back to an empty stub when we've + // never successfully fetched this server. + if (prior && Array.isArray(prior.channels) && prior.channels.length > 0) { + serverObj[server] = Object.assign({}, prior, { online: false }); + } else { + serverObj[server] = { + online: false, + channels: [] + }; + } } }); await Promise.all(statusPromises); localStorage.setItem('config', JSON.stringify(config)); - const existingSession = App.storage.getSession(); - const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]); - if (serverKeys.length === 0) return; - const selectedServerKey = existingSession && serverKeys.includes(existingSession.server) - ? existingSession.server - : serverKeys[0]; - const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey); - const serverData = serverEntry ? serverEntry[selectedServerKey] : null; + const before = sessionSignature(App.storage.getSession()); + App.storage.ensureSessionFromCache(); + const after = sessionSignature(App.storage.getSession()); + return before !== after; + }; - if (serverData && serverData.channels && serverData.channels.length > 0) { - const prefs = App.storage.getPreferences(); - const serverPrefs = prefs[selectedServerKey] || {}; - const preferredChannelId = serverPrefs.channelId; - const channel = App.session.resolveChannelById(serverData, preferredChannelId) || serverData.channels[0]; - const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null; - const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel); - - const sessionData = { - server: selectedServerKey, - channel: channel, - options: options, - }; - - App.storage.setSession(sessionData); - App.session.savePreference(sessionData); - } + // Refreshes server status without blocking; updates the menu and reloads + // videos only if the refresh actually changed the active selection. Safe to + // fire-and-forget during startup so the UI renders from cache immediately. + App.storage.refreshServerStatusInBackground = function() { + return App.storage.initializeServerStatus() + .then((changed) => { + if (App.ui && typeof App.ui.renderMenu === 'function') { + App.ui.renderMenu(); + } + if (changed && App.videos && typeof App.videos.resetAndReload === 'function') { + App.videos.resetAndReload(); + } + }) + .catch((err) => { + console.error('Background status refresh failed:', err); + }); }; })();