Searches are kept in local storage and the five that best fit what is in
the box are offered under it: the ones it starts, then the ones it starts
a word of, then the ones it appears in anywhere, most recently searched
first. Walk them with the arrows, take one with enter or a tap, dismiss
with escape. The part that matched is picked out, and rows are built from
text nodes -- these strings are the reader's own and they are going back
on screen.
The box searches as you type, so there is no submit to record and storing
every pause would fill the list with the way to a query rather than the
query: a new entry swallows the ones it grew out of ("hot", "hote",
"hotel" leaves "hotel"), and going back to something shorter adds
nothing. Enter records straight away, since that is a reader saying they
meant it.
That turned up a bug the suggestions would have run into constantly: a
search started while the box's own last search was still out was dropped
-- loadVideos saw a load running and returned -- leaving the grid on the
previous query. Changing the result set now stops the load that is
running before starting the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
455 lines
20 KiB
JavaScript
455 lines
20 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');
|
|
};
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Searches
|
|
//
|
|
// What was searched for before, newest first. The grid searches as you
|
|
// type, so the box never sees a "submit" to record: every pause in the
|
|
// typing is a search of its own and storing each one would fill the list
|
|
// with the way to a query rather than the query. So a new entry swallows
|
|
// the ones it grew out of -- typing "hot", "hote", "hotel" leaves "hotel"
|
|
// -- and going back to something shorter than what is already stored adds
|
|
// nothing, since that is a step back through the same typing.
|
|
// ---------------------------------------------------------------------
|
|
const SEARCH_HISTORY_MAX = 50;
|
|
|
|
App.storage.getSearches = function() {
|
|
try {
|
|
const stored = JSON.parse(localStorage.getItem(App.constants.SEARCH_HISTORY_KEY));
|
|
return Array.isArray(stored) ? stored.filter((q) => typeof q === 'string' && q.trim()) : [];
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
App.storage.rememberSearch = function(query) {
|
|
const next = (query || '').trim();
|
|
if (!next) return App.storage.getSearches();
|
|
const lower = next.toLowerCase();
|
|
const stored = App.storage.getSearches();
|
|
// Already on the way to something longer: nothing new to record.
|
|
if (stored.some((q) => q.toLowerCase() !== lower && q.toLowerCase().startsWith(lower))) {
|
|
return stored;
|
|
}
|
|
const kept = stored.filter((q) => !lower.startsWith(q.toLowerCase()));
|
|
kept.unshift(next);
|
|
const capped = kept.slice(0, SEARCH_HISTORY_MAX);
|
|
try {
|
|
localStorage.setItem(App.constants.SEARCH_HISTORY_KEY, JSON.stringify(capped));
|
|
} catch (err) { /* a full or blocked store is not worth failing a search over */ }
|
|
return capped;
|
|
};
|
|
|
|
App.storage.forgetSearches = function() {
|
|
localStorage.removeItem(App.constants.SEARCH_HISTORY_KEY);
|
|
};
|
|
|
|
// The past searches worth offering for what is being typed, best first.
|
|
// Best means where the typing lands in them -- at the front, at the start
|
|
// of a word, anywhere at all -- and then which was searched for most
|
|
// recently. What is already typed in full is not a suggestion.
|
|
App.storage.suggestSearches = function(query, limit) {
|
|
const want = Math.max(1, limit || 5);
|
|
const typed = (query || '').trim().toLowerCase();
|
|
const scored = [];
|
|
App.storage.getSearches().forEach((entry, age) => {
|
|
const candidate = entry.toLowerCase();
|
|
if (candidate === typed) return;
|
|
let rank = 0;
|
|
if (typed) {
|
|
const at = candidate.indexOf(typed);
|
|
if (at < 0) return;
|
|
rank = at === 0 ? 3 : (candidate[at - 1] === ' ' ? 2 : 1);
|
|
}
|
|
scored.push({ entry: entry, rank: rank, age: age });
|
|
});
|
|
scored.sort((a, b) => (b.rank - a.rank) || (a.age - b.age));
|
|
return scored.slice(0, want).map((s) => s.entry);
|
|
};
|
|
|
|
// 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');
|
|
};
|
|
|
|
// User-tunable card width / text size multipliers (default 1.0). Clamped so a
|
|
// stale or hand-edited value can never break the layout.
|
|
const clampScale = function(value, min, max, fallback) {
|
|
const n = parseFloat(value);
|
|
if (!isFinite(n)) return fallback;
|
|
return Math.min(max, Math.max(min, n));
|
|
};
|
|
|
|
App.storage.getCardScale = function() {
|
|
return clampScale(localStorage.getItem('cardScale'), 0.7, 1.5, 1);
|
|
};
|
|
|
|
App.storage.setCardScale = function(next) {
|
|
localStorage.setItem('cardScale', clampScale(next, 0.7, 1.5, 1));
|
|
};
|
|
|
|
App.storage.getFontScale = function() {
|
|
return clampScale(localStorage.getItem('fontScale'), 0.8, 1.4, 1);
|
|
};
|
|
|
|
App.storage.setFontScale = function(next) {
|
|
localStorage.setItem('fontScale', clampScale(next, 0.8, 1.4, 1));
|
|
};
|
|
|
|
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);
|
|
});
|
|
};
|
|
})();
|