Files
jacuzzi/frontend/js/storage.js
Simon 138c3224de Add modern UI enhancements over the classic redesign
Layered progressive polish on the warm classic + brass theme:
- Card entrance animation on first mount (virtualizer-aware)
- Cursor-tracking brass spotlight border on cards
- Thumbnail skeleton shimmer until the poster paints
- Hover video preview after a short dwell (only when formats resolved)
- View Transition + blurred-poster ambient backdrop on player open
- Favorite heart pop + expanding ring on add
- ⌘K command palette (search, theme, density, reels, source/channel)
- Scroll-progress bar + back-to-top FAB
- Grid density toggle (comfortable/compact)
- Reels HUD: serif title, brass scrubber, muted-state pulse

All new motion respects prefers-reduced-motion; no JS/HTML structure
changes to the core grid/feed. New glue lives in frontend/js/enhance.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:25:37 +00:00

364 lines
16 KiB
JavaScript

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 <group>"
// 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:<id>" 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);
});
};
})();