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>
364 lines
16 KiB
JavaScript
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);
|
|
});
|
|
};
|
|
})();
|