window.App = window.App || {}; App.storage = App.storage || {}; App.session = App.session || {}; (function() { const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY, FEED_END_BEHAVIOR_KEY } = App.constants; // Basic localStorage helpers. App.storage.getConfig = function() { return JSON.parse(localStorage.getItem('config')) || { servers: [] }; }; App.storage.setConfig = function(nextConfig) { localStorage.setItem('config', JSON.stringify(nextConfig)); }; App.storage.getSession = function() { return JSON.parse(localStorage.getItem('session')) || null; }; App.storage.setSession = function(nextSession) { localStorage.setItem('session', JSON.stringify(nextSession)); }; App.storage.getPreferences = function() { return JSON.parse(localStorage.getItem('preferences')) || {}; }; App.storage.setPreferences = function(nextPreferences) { localStorage.setItem('preferences', JSON.stringify(nextPreferences)); }; App.storage.getPreferredQuality = function() { return localStorage.getItem(PREFERRED_QUALITY_KEY) || '1080'; }; App.storage.setPreferredQuality = function(nextQuality) { localStorage.setItem(PREFERRED_QUALITY_KEY, nextQuality); }; // Reels/TikTok mode behavior when a video reaches its end: 'loop' replays // the same clip; 'scroll' advances to the next video. Defaults to 'loop'. App.storage.getFeedEndBehavior = function() { return localStorage.getItem(FEED_END_BEHAVIOR_KEY) === 'scroll' ? 'scroll' : 'loop'; }; App.storage.setFeedEndBehavior = function(nextBehavior) { localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop'); }; // Grid density: 'comfortable' (default) or 'compact' (more, smaller columns). App.storage.getDensity = function() { return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable'; }; App.storage.setDensity = function(nextDensity) { localStorage.setItem('density', nextDensity === 'compact' ? 'compact' : 'comfortable'); }; App.storage.getServerEntries = function() { const config = App.storage.getConfig(); if (!config.servers || !Array.isArray(config.servers)) return []; return config.servers.map((serverObj) => { const server = Object.keys(serverObj)[0]; return { url: server, data: serverObj[server] || null }; }); }; // Synthetic filter id used to let a channel group expose its member // channels as a toggleable multi-select, so the user can browse "All " // while disabling individual channels. App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels'; // Options/session helpers that power channel selection and filters. App.session.serializeOptions = function(options) { const serialized = {}; Object.entries(options || {}).forEach(([key, value]) => { if (Array.isArray(value)) { serialized[key] = value.map((entry) => entry.id); } else if (value && value.id) { serialized[key] = value.id; } }); return serialized; }; App.session.hydrateOptions = function(channel, savedOptions) { const hydrated = {}; if (!channel || !Array.isArray(channel.options)) return hydrated; const saved = savedOptions || {}; channel.options.forEach((optionGroup) => { const allOptions = optionGroup.options || []; const savedValue = saved[optionGroup.id]; if (optionGroup.multiSelect) { const fallback = optionGroup.selectAllDefault ? allOptions.slice() : allOptions.slice(0, 1); if (Array.isArray(savedValue)) { const selected = allOptions.filter((opt) => savedValue.includes(opt.id)); hydrated[optionGroup.id] = selected.length > 0 ? selected : fallback; } else { hydrated[optionGroup.id] = fallback; } } else { const selected = allOptions.find((opt) => opt.id === savedValue) || allOptions[0]; if (selected) hydrated[optionGroup.id] = selected; } }); return hydrated; }; // Builds a pseudo-channel representing a whole channel group, used so the // rest of the app (session, filters, video loading) can treat a selected // group the same way it treats a single channel. App.session.buildGroupChannel = function(group, channels) { if (!group) return null; const knownIds = new Set((channels || []).map((channel) => channel.id)); const channelIds = Array.isArray(group.channelIds) ? group.channelIds.filter((id) => knownIds.has(id)) : []; const channelOptions = channelIds.map((id) => { const channel = (channels || []).find((ch) => ch.id === id); return { id: id, title: (channel && (channel.name || channel.id)) || id }; }); return { id: `group:${group.id}`, name: group.title || group.id, isGroup: true, groupId: group.id, channelIds: channelIds, // Expose member channels as a multi-select filter (all on by // default) so the user can disable individual channels while // browsing the whole group. options: channelOptions.length > 0 ? [{ id: App.session.GROUP_CHANNELS_OPTION_ID, title: 'Channels', multiSelect: true, selectAllDefault: true, options: channelOptions }] : [] }; }; // Resolves a stored channel id (which may reference a single channel or a // "group:" pseudo-channel) against a server's status payload. App.session.resolveChannelById = function(serverData, channelId) { if (!serverData || !channelId) return null; const channels = Array.isArray(serverData.channels) ? serverData.channels : []; if (channelId.startsWith('group:')) { const groupId = channelId.slice('group:'.length); const groups = Array.isArray(serverData.channelGroups) ? serverData.channelGroups : []; const group = groups.find((g) => g.id === groupId); return App.session.buildGroupChannel(group, channels); } return channels.find((channel) => channel.id === channelId) || null; }; App.session.savePreference = function(session) { if (!session || !session.server || !session.channel) return; const prefs = App.storage.getPreferences(); const serverPrefs = prefs[session.server] || {}; serverPrefs.channelId = session.channel.id; serverPrefs.optionsByChannel = serverPrefs.optionsByChannel || {}; serverPrefs.optionsByChannel[session.channel.id] = App.session.serializeOptions(session.options); prefs[session.server] = serverPrefs; App.storage.setPreferences(prefs); }; App.session.buildDefaultOptions = function(channel) { const selected = {}; if (!channel || !Array.isArray(channel.options)) return selected; channel.options.forEach((optionGroup) => { if (!optionGroup.options || optionGroup.options.length === 0) return; if (optionGroup.multiSelect) { selected[optionGroup.id] = optionGroup.selectAllDefault ? optionGroup.options.slice() : [optionGroup.options[0]]; } else { selected[optionGroup.id] = optionGroup.options[0]; } }); return selected; }; // 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({ servers: [ { "https://getfigleaf.com": {} }, { "https://hottubapp.io": {} }, { "https://hottub.spacemoehre.de": {} } ] })); } if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'dark'); } if (!localStorage.getItem(PREFERRED_QUALITY_KEY)) { localStorage.setItem(PREFERRED_QUALITY_KEY, '1080'); } if (!localStorage.getItem(FAVORITES_KEY)) { localStorage.setItem(FAVORITES_KEY, JSON.stringify([])); } if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) { localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true'); } if (!localStorage.getItem(FEED_END_BEHAVIOR_KEY)) { localStorage.setItem(FEED_END_BEHAVIOR_KEY, 'loop'); } App.storage.ensureSessionFromCache(); }; // 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 false; const fetchDirectStatus = async (server) => { const directUrl = `${server.replace(/\/$/, '')}/api/status`; const response = await fetch(directUrl); if (!response.ok) throw new Error(`Direct status request failed: ${response.status}`); return await response.json(); }; const fetchProxiedStatus = async (server) => { const response = await fetch(`/api/status`, { method: "POST", body: JSON.stringify({ server: server }), headers: { "Content-Type": "application/json" }, }); if (!response.ok) throw new Error(`Proxied status request failed: ${response.status}`); return await response.json(); }; 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 { serverObj[server] = await fetchDirectStatus(server); } catch (directErr) { serverObj[server] = await fetchProxiedStatus(server); } } catch (err) { // 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 before = sessionSignature(App.storage.getSession()); App.storage.ensureSessionFromCache(); const after = sessionSignature(App.storage.getSession()); return before !== after; }; // 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); }); }; })();