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 = {