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

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

View File

@@ -16,9 +16,10 @@
* 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 { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
@@ -56,18 +57,48 @@ function FeedPage() {
const episodes = createMemo<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 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 = () =>
episodes().length === 0 ? 0 : Math.min(focus(), episodes().length - 1);
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
const curLen = () => episodes().length;
focusedOnMore()
? -1
: 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 = () => {
if (episodes().length > 0 && focus() >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 0);
if (rowCount() > 0 && focus() >= rowCount())
nav.setDepthFocus(rowCount() - 1, 0);
};
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(() => {
nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
@@ -118,6 +149,10 @@ function FeedPage() {
// ── open ───────────────────────────────────────────────────────────────────
function open() {
if (focusedOnMore()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
playEpisode(focusedItem());
}
@@ -185,7 +220,16 @@ function FeedPage() {
when={episodes().length > 0}
fallback={
<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>
}
>
@@ -240,60 +284,106 @@ function FeedPage() {
);
}}
</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()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
<LoadingIndicator label="Refreshing…" />
</box>
</Show>
</Show>
);
// ── preview pane: hovered-episode detail ───────────────────────────────────
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const previewContent = () => (
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
<strong>[Fetch More]</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 ? "…" : ""}
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes across all feeds (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h back</text>
<text fg={muted()}>enter: load more · h back</text>
</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 (

View File

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

View File

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

View File

@@ -115,5 +115,19 @@ export function usePreferencesItems(): SettingItem[] {
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 });
},
},
];
}