feat: labeled loading spinners + [Fetch More] pagination on the feed list
Loading indicators: the braille spinner now carries a contextual label (Refreshing…, Fetching…, Loading more…, Discovering…, Searching…) and is shown in every loading state that previously rendered nothing — Discover results, Search results fallback, and the empty Feed list. Feed pagination: a focusable "[Fetch More]" row at the bottom of the flat feed list advances every feed's loaded window by 50 episodes via the new loadMoreAllFeeds/hasMoreAcrossAll store API. Behavior is a setting (Fetch More: manual|auto, default manual) persisted in config.json; auto fetches when focus reaches the bottom row. The button row is excluded from episode focus so no episode is double-highlighted while it is active.
This commit is contained in:
@@ -399,52 +399,79 @@ function createFeedStore() {
|
||||
return loaded < cached.length;
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for one feed from the cache.
|
||||
* No global guard — callers own the `isLoadingMore` flag so batches
|
||||
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
||||
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
}
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for a feed from the cache.
|
||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||
const loadMoreEpisodes = async (feedId: string) => {
|
||||
if (isLoadingMore()) return;
|
||||
const feed = getFeed(feedId);
|
||||
if (!feed) return;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
await loadMoreEpisodesForFeed(feedId);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
if (!cached) {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
||||
cached = parsed.episodes;
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
/** True if any feed still has cached episodes beyond its loaded window. */
|
||||
const hasMoreAcrossAll = (): boolean => {
|
||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||
};
|
||||
|
||||
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
||||
* still has cached episodes — powers the Feed page's "[Fetch More]". */
|
||||
const loadMoreAllFeeds = async () => {
|
||||
if (isLoadingMore()) return;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
||||
for (const feed of pending) {
|
||||
await loadMoreEpisodesForFeed(feed.id);
|
||||
}
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
@@ -487,6 +514,8 @@ function createFeedStore() {
|
||||
refreshFeed,
|
||||
refreshAllFeeds,
|
||||
loadMoreEpisodes,
|
||||
loadMoreAllFeeds,
|
||||
hasMoreAcrossAll,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
|
||||
Reference in New Issue
Block a user