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:
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user