diff --git a/src/components/LoadingIndicator.tsx b/src/components/LoadingIndicator.tsx
index bc5b1b3..ce2c9ce 100644
--- a/src/components/LoadingIndicator.tsx
+++ b/src/components/LoadingIndicator.tsx
@@ -1,23 +1,30 @@
-import { createSignal, createMemo, onCleanup } from "solid-js";
+import { createSignal, createMemo, Show, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
-export function LoadingIndicator() {
- const { theme } = useTheme();
- const [index, setIndex] = createSignal(0);
+/**
+ * Animated braille spinner with an optional label (e.g. "Refreshing…").
+ * 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(() => {
- setIndex((i) => (i + 1) % spinnerChars.length);
- }, 65);
+ const interval = setInterval(() => {
+ setIndex((i) => (i + 1) % spinnerChars.length);
+ }, 65);
- onCleanup(() => clearInterval(interval));
+ onCleanup(() => clearInterval(interval));
- const currentChar = createMemo(() => spinnerChars[index()]);
+ const currentChar = createMemo(() => spinnerChars[index()]);
- return (
-
-
-
- );
+ return (
+
+
+
+
+
+
+ );
}
diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx
index b0fea76..a73b475 100644
--- a/src/pages/Discover/DiscoverPage.tsx
+++ b/src/pages/Discover/DiscoverPage.tsx
@@ -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={
- No podcasts found. :refresh
+ No podcasts found. :refresh
+ }
+ >
+
+
}
>
@@ -274,6 +282,11 @@ function DiscoverPage() {
);
}}
+
+
+
+
+
>
diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx
index 614c3fe..1c5f210 100644
--- a/src/pages/Feed/FeedPage.tsx
+++ b/src/pages/Feed/FeedPage.tsx
@@ -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(
() => 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={
- No feeds. Subscribe from Discover/Search.
+
+ No feeds. Subscribe from Discover/Search.
+
+ }
+ >
+
+
}
>
@@ -240,60 +284,106 @@ function FeedPage() {
);
}}
+
+ {
+ nav.setActivePane(DEPTH_CENTER_PANE);
+ nav.setDepthFocus(episodes().length, 0);
+ }}
+ >
+
+ {focusedOnMore() ? "❯" : " "}
+
+ }
+ >
+
+ [Fetch More]
+
+
+
+
-
+
);
- // ── preview pane: hovered-episode detail ───────────────────────────────────
+ // ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const previewContent = () => (
-
- No episode focused
-
- }
- >
- {(item) => (
+ <>
+
-
- {item().episode.episodeNumber
- ? `#${item().episode.episodeNumber} `
- : ""}
- {item().episode.title}
-
+ [Fetch More]
-
- {formatDate(item().episode.pubDate)}
- {formatDuration(item().episode.duration)}
-
-
- {downloadLabel(item().episode.id)}
-
-
-
- {item().feed.customName || item().feed.podcast.title}
-
-
- by {item().feed.podcast.author}
-
-
-
- {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)."}
- enter: play · space: select · h back
+ enter: load more · h back
- )}
-
+
+
+
+ No episode focused
+
+ }
+ >
+ {(item) => (
+
+
+
+ {item().episode.episodeNumber
+ ? `#${item().episode.episodeNumber} `
+ : ""}
+ {item().episode.title}
+
+
+
+ {formatDate(item().episode.pubDate)}
+ {formatDuration(item().episode.duration)}
+
+
+ {downloadLabel(item().episode.id)}
+
+
+
+
+ {item().feed.customName || item().feed.podcast.title}
+
+
+ by {item().feed.podcast.author}
+
+
+
+ {item().episode.description?.slice(0, 400) ??
+ "No description available."}
+ {(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
+
+
+ enter: play · space: select · h back
+
+ )}
+
+
+ >
);
return (
diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx
index 2790cb4..fc00573 100644
--- a/src/pages/MyShows/MyShowsPage.tsx
+++ b/src/pages/MyShows/MyShowsPage.tsx
@@ -346,7 +346,7 @@ export function MyShowsPage() {
-
+
diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx
index c92f353..aa51ff2 100644
--- a/src/pages/Search/SearchPage.tsx
+++ b/src/pages/Search/SearchPage.tsx
@@ -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() {
/>
- Searching...
+
{searchStore.error()}
@@ -298,11 +299,18 @@ function SearchPage() {
when={results().length > 0}
fallback={
-
- {searchStore.query()
- ? "No results found"
- : "Enter a search term to find podcasts"}
-
+
+ {searchStore.query()
+ ? "No results found"
+ : "Enter a search term to find podcasts"}
+
+ }
+ >
+
+
}
>
diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx
index 210a497..90c2059 100644
--- a/src/pages/Settings/PreferencesPanel.tsx
+++ b/src/pages/Settings/PreferencesPanel.tsx
@@ -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 });
+ },
+ },
];
}
diff --git a/src/stores/app.ts b/src/stores/app.ts
index a40f3df..d09e1d7 100644
--- a/src/stores/app.ts
+++ b/src/stores/app.ts
@@ -37,6 +37,7 @@ const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoJumpToPlayer: true,
+ fetchMoreMode: "manual",
};
const defaultState: AppState = {
diff --git a/src/stores/feed.ts b/src/stores/feed.ts
index 8484a0a..996f018 100644
--- a/src/stores/feed.ts
+++ b/src/stores/feed.ts
@@ -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,
diff --git a/src/types/settings.ts b/src/types/settings.ts
index 13e69ba..64f3219 100644
--- a/src/types/settings.ts
+++ b/src/types/settings.ts
@@ -84,11 +84,16 @@ export type AppSettings = {
visualizer: VisualizerSettings;
};
+/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */
+export type FetchMoreMode = "manual" | "auto";
+
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
+ /** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
+ fetchMoreMode: FetchMoreMode;
};
export type AppState = {
diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts
index 1be4613..8878caf 100644
--- a/src/utils/app-persistence.ts
+++ b/src/utils/app-persistence.ts
@@ -40,6 +40,7 @@ const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoJumpToPlayer: true,
+ fetchMoreMode: "manual",
};
const defaultState: AppState = {