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:
2026-08-10 10:38:20 -04:00
parent 2e69868ffc
commit 4a94ff5910
10 changed files with 274 additions and 106 deletions

View File

@@ -1,23 +1,30 @@
import { createSignal, createMemo, onCleanup } from "solid-js"; import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export function LoadingIndicator() { /**
const { theme } = useTheme(); * Animated braille spinner with an optional label (e.g. "Refreshing…").
const [index, setIndex] = createSignal(0); * The spinner is rendered in the theme primary color; the label in muted.
*/
export function LoadingIndicator(props: { label?: string }) {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);
const interval = setInterval(() => { const interval = setInterval(() => {
setIndex((i) => (i + 1) % spinnerChars.length); setIndex((i) => (i + 1) % spinnerChars.length);
}, 65); }, 65);
onCleanup(() => clearInterval(interval)); onCleanup(() => clearInterval(interval));
const currentChar = createMemo(() => spinnerChars[index()]); const currentChar = createMemo(() => spinnerChars[index()]);
return ( return (
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start"> <box flexDirection="row" gap={1} alignItems="flex-start">
<text fg={theme.primary} content={currentChar()} /> <text fg={theme.primary} content={currentChar()} />
</box> <Show when={props.label}>
); <text fg={theme.muted || theme.text} content={props.label} />
</Show>
</box>
);
} }

View File

@@ -30,6 +30,7 @@ import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const DiscoverPaneCount = 1; export const DiscoverPaneCount = 1;
@@ -226,7 +227,14 @@ function DiscoverPage() {
when={podcasts().length > 0} when={podcasts().length > 0}
fallback={ fallback={
<box padding={1}> <box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text> <Show
when={discoverStore.isLoading()}
fallback={
<text fg={muted()}>No podcasts found. :refresh</text>
}
>
<LoadingIndicator label="Discovering…" />
</Show>
</box> </box>
} }
> >
@@ -274,6 +282,11 @@ function DiscoverPage() {
); );
}} }}
</For> </For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
</box>
</Show>
</Show> </Show>
</Show> </Show>
</> </>

View File

@@ -16,9 +16,10 @@
* everything over `nav.action`; this page only handles list/preview data. * everything over `nav.action`; this page only handles list/preview data.
*/ */
import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
@@ -56,18 +57,48 @@ function FeedPage() {
const episodes = createMemo<EpItem[]>( const episodes = createMemo<EpItem[]>(
() => feedStore.getAllEpisodesChronological() as EpItem[], () => feedStore.getAllEpisodesChronological() as EpItem[],
); );
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0); const focus = () => nav.depthFocus(0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(), rowCount() - 1);
const focusedOnMore = () =>
showFetchMore() && focusedRow() === episodes().length;
// -1 while the Fetch More row is focused so no episode row renders the
// cursor/highlight (the button is the focused row, not the last episode).
const focusedEpIdx = () => const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1); focusedOnMore()
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()]; ? -1
const curLen = () => episodes().length; : Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
const moreRef = useScrollIntoView(() => focusedOnMore());
const ensureFocus = () => { const ensureFocus = () => {
if (episodes().length > 0 && focus() >= episodes().length) if (rowCount() > 0 && focus() >= rowCount())
nav.setDepthFocus(episodes().length - 1, 0); nav.setDepthFocus(rowCount() - 1, 0);
}; };
onMount(ensureFocus); onMount(ensureFocus);
// Auto mode: reaching the bottom row loads the next batch. Guarded by
// isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreAllFeeds().catch(() => {});
});
onMount(() => { onMount(() => {
nav.registerResolver( nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, `${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
@@ -118,6 +149,10 @@ function FeedPage() {
// ── open ─────────────────────────────────────────────────────────────────── // ── open ───────────────────────────────────────────────────────────────────
function open() { function open() {
if (focusedOnMore()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
playEpisode(focusedItem()); playEpisode(focusedItem());
} }
@@ -185,7 +220,16 @@ function FeedPage() {
when={episodes().length > 0} when={episodes().length > 0}
fallback={ fallback={
<box padding={1}> <box padding={1}>
<text fg={muted()}>No feeds. Subscribe from Discover/Search.</text> <Show
when={feedStore.isLoadingFeeds()}
fallback={
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
}
>
<LoadingIndicator label="Refreshing…" />
</Show>
</box> </box>
} }
> >
@@ -240,60 +284,106 @@ function FeedPage() {
); );
}} }}
</For> </For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 0);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? "" : " "}
</text>
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
</Show>
<Show when={feedStore.isLoadingFeeds()}> <Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}> <box paddingLeft={2} paddingTop={1}>
<LoadingIndicator /> <LoadingIndicator label="Refreshing…" />
</box> </box>
</Show> </Show>
</Show> </Show>
); );
// ── preview pane: hovered-episode detail ─────────────────────────────────── // ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const previewContent = () => ( const previewContent = () => (
<Show <>
when={focusedItem()} <Show when={focusedOnMore()}>
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}> <box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}> <text fg={theme.textPrimary ?? theme.text}>
<strong> <strong>[Fetch More]</strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text> </text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}> <text fg={muted()}>
{item().feed.customName || item().feed.podcast.title} {feedStore.isLoadingMore()
</text> ? "Loading the next batch of episodes…"
<Show when={item().feed.podcast.author}> : fetchMoreMode() === "auto"
<text fg={muted()}>by {item().feed.podcast.author}</text> ? "Auto mode: the next batch loads automatically at the bottom of the list."
</Show> : "Load the next batch of older episodes across all feeds (Enter)."}
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter: play · space: select · h back</text> <text fg={muted()}>enter: load more · h back</text>
</box> </box>
)} </Show>
</Show> <Show when={!focusedOnMore()}>
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h back</text>
</box>
)}
</Show>
</Show>
</>
); );
return ( return (

View File

@@ -346,7 +346,7 @@ export function MyShowsPage() {
</For> </For>
<Show when={feedStore.isLoadingMore()}> <Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}> <box paddingLeft={2} paddingTop={1}>
<LoadingIndicator /> <LoadingIndicator label="Loading more…" />
</box> </box>
</Show> </Show>
</Show> </Show>

View File

@@ -40,6 +40,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source"; import type { SearchResult } from "@/types/source";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
export const SearchPaneCount = 1; export const SearchPaneCount = 1;
@@ -241,7 +242,7 @@ function SearchPage() {
/> />
</box> </box>
<Show when={searchStore.isSearching()}> <Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text> <LoadingIndicator label="Searching…" />
</Show> </Show>
<Show when={searchStore.error()}> <Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text> <text fg={theme.error}>{searchStore.error()}</text>
@@ -298,11 +299,18 @@ function SearchPage() {
when={results().length > 0} when={results().length > 0}
fallback={ fallback={
<box padding={1}> <box padding={1}>
<text fg={muted()}> <Show
{searchStore.query() when={searchStore.isSearching()}
? "No results found" fallback={
: "Enter a search term to find podcasts"} <text fg={muted()}>
</text> {searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
}
>
<LoadingIndicator label="Searching…" />
</Show>
</box> </box>
} }
> >

View File

@@ -115,5 +115,19 @@ export function usePreferencesItems(): SettingItem[] {
autoJumpToPlayer: !prefs().autoJumpToPlayer, autoJumpToPlayer: !prefs().autoJumpToPlayer,
}), }),
}, },
{
id: "fetchMore",
label: "Fetch More",
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
help: () =>
`How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
},
},
]; ];
} }

View File

@@ -37,6 +37,7 @@ const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
autoJumpToPlayer: true, autoJumpToPlayer: true,
fetchMoreMode: "manual",
}; };
const defaultState: AppState = { const defaultState: AppState = {

View File

@@ -399,52 +399,79 @@ function createFeedStore() {
return loaded < cached.length; 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. /** 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. */ * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
const loadMoreEpisodes = async (feedId: string) => { const loadMoreEpisodes = async (feedId: string) => {
if (isLoadingMore()) return; if (isLoadingMore()) return;
const feed = getFeed(feedId);
if (!feed) return;
setIsLoadingMore(true); setIsLoadingMore(true);
try { try {
let cached = fullEpisodeCache.get(feedId); await loadMoreEpisodesForFeed(feedId);
} finally {
setIsLoadingMore(false);
}
};
// If no cache, re-fetch and parse the full feed /** True if any feed still has cached episodes beyond its loaded window. */
if (!cached) { const hasMoreAcrossAll = (): boolean => {
const response = await fetch(feed.podcast.feedUrl, { return feeds().some((f) => hasMoreEpisodes(f.id));
headers: { };
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*", /** 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 (!response.ok) return; if (isLoadingMore()) return;
const xml = await response.text(); setIsLoadingMore(true);
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl); try {
cached = parsed.episodes; const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
fullEpisodeCache.set(feedId, cached); for (const feed of pending) {
// Set current load count to match what's already displayed await loadMoreEpisodesForFeed(feed.id);
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;
});
} finally { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
} }
@@ -487,6 +514,8 @@ function createFeedStore() {
refreshFeed, refreshFeed,
refreshAllFeeds, refreshAllFeeds,
loadMoreEpisodes, loadMoreEpisodes,
loadMoreAllFeeds,
hasMoreAcrossAll,
addSource, addSource,
removeSource, removeSource,
toggleSource, toggleSource,

View File

@@ -84,11 +84,16 @@ export type AppSettings = {
visualizer: VisualizerSettings; visualizer: VisualizerSettings;
}; };
/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */
export type FetchMoreMode = "manual" | "auto";
export type UserPreferences = { export type UserPreferences = {
showExplicit: boolean; showExplicit: boolean;
autoDownload: boolean; autoDownload: boolean;
/** Jump to the Player view automatically when playback starts (default: true) */ /** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean; autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
fetchMoreMode: FetchMoreMode;
}; };
export type AppState = { export type AppState = {

View File

@@ -40,6 +40,7 @@ const defaultPreferences: UserPreferences = {
showExplicit: false, showExplicit: false,
autoDownload: false, autoDownload: false,
autoJumpToPlayer: true, autoJumpToPlayer: true,
fetchMoreMode: "manual",
}; };
const defaultState: AppState = { const defaultState: AppState = {