feat(discover): drill into show episode previews without subscribing
This commit is contained in:
@@ -67,7 +67,7 @@
|
|||||||
"sort": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
|
"subscribe": ["a"], // subscribe focused show in place (Discover/Search)
|
||||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||||
|
|
||||||
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||||
|
|||||||
@@ -5,18 +5,28 @@
|
|||||||
* placeholder (1/5 slot kept).
|
* placeholder (1/5 slot kept).
|
||||||
* depth 1 (current) — podcast results for the drilled category. Parent
|
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||||
* pane = the categories list.
|
* pane = the categories list.
|
||||||
* preview — detail of the hovered item (category summary, or
|
* depth 2 (current) — episodes of the drilled show, fetched on demand
|
||||||
* podcast detail + subscribe action).
|
* WITHOUT subscribing. Parent pane = the results list.
|
||||||
|
* preview — detail of the hovered item (category summary,
|
||||||
|
* podcast detail, or episode detail).
|
||||||
*
|
*
|
||||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
* remains. `l`/Enter drills in (category → results → episodes); `a`
|
||||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
* subscribes the focused show (enter/l never subscribe — they open the
|
||||||
* column. Moving through categories at depth 0 updates the store's selected
|
* episode list); `h` pops a depth (noop at 0). j/k move only within the
|
||||||
* category so the preview follows.
|
* current column. Moving through categories at depth 0 updates the store's
|
||||||
|
* selected category so the preview follows.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
import type { Podcast } from "@/types/podcast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +42,7 @@ 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 { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
@@ -41,11 +52,16 @@ function DiscoverPage() {
|
|||||||
// Static: detection never changes mid-session.
|
// Static: detection never changes mid-session.
|
||||||
const nerd = supportsNerdFonts();
|
const nerd = supportsNerdFonts();
|
||||||
const discoverStore = useDiscoverStore();
|
const discoverStore = useDiscoverStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const marker = useSelectionMarker();
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||||
|
|
||||||
@@ -60,14 +76,37 @@ function DiscoverPage() {
|
|||||||
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||||||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
||||||
|
|
||||||
|
// depth-2 frame ctx = the drilled podcast id (episode preview, no
|
||||||
|
// subscription). Episodes come from the discover store's session cache.
|
||||||
|
const drilledPodcastId = (): string => stack()[2]?.ctx ?? "";
|
||||||
|
const drilledPodcast = (): Podcast | undefined =>
|
||||||
|
podcasts().find((p) => p.id === drilledPodcastId());
|
||||||
|
const episodes = createMemo<Episode[]>(() => {
|
||||||
|
if (depth() < 2) return [];
|
||||||
|
return discoverStore.episodesForPodcast(drilledPodcastId());
|
||||||
|
});
|
||||||
|
const episodesLoading = () =>
|
||||||
|
depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId());
|
||||||
|
const episodesError = () =>
|
||||||
|
depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined;
|
||||||
|
const focusedEpIdx = () =>
|
||||||
|
episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1);
|
||||||
|
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||||
|
|
||||||
const curLen = () =>
|
const curLen = () =>
|
||||||
depth() === 0 ? categories().length : podcasts().length;
|
depth() === 0
|
||||||
|
? categories().length
|
||||||
|
: depth() === 1
|
||||||
|
? podcasts().length
|
||||||
|
: episodes().length;
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (categories().length > 0 && focus(0) >= categories().length)
|
if (categories().length > 0 && focus(0) >= categories().length)
|
||||||
nav.setDepthFocus(categories().length - 1, 0);
|
nav.setDepthFocus(categories().length - 1, 0);
|
||||||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||||
nav.setDepthFocus(podcasts().length - 1, 1);
|
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||||
|
if (episodes().length > 0 && focus(2) >= episodes().length)
|
||||||
|
nav.setDepthFocus(episodes().length - 1, 2);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
@@ -80,13 +119,56 @@ function DiscoverPage() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return categories()[i]?.id;
|
if (depth() === 0) return categories()[i]?.id;
|
||||||
return podcasts()[i]?.id;
|
if (depth() === 1) return podcasts()[i]?.id;
|
||||||
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
/** The subscribed feed backing a podcast, if any (matched by directory id
|
||||||
|
* or feed URL — a Discover show may already be subscribed). */
|
||||||
|
const feedForPodcast = (p: Podcast) =>
|
||||||
|
feedStore.feeds().find(
|
||||||
|
(f) =>
|
||||||
|
f.podcast.id === p.id ||
|
||||||
|
(!!p.feedUrl && f.podcast.feedUrl === p.feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return theme.warning;
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return theme.primary;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return theme.success;
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return theme.error;
|
||||||
|
default:
|
||||||
|
return muted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const playEpisode = (ep: Episode) => {
|
||||||
|
audio.play(ep).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id);
|
||||||
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
@@ -97,9 +179,19 @@ function DiscoverPage() {
|
|||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
const pod = focusedPodcast();
|
||||||
if (pod) discoverStore.toggleSubscription(pod.id);
|
if (!pod) return;
|
||||||
|
// Drill into the show's episode list WITHOUT subscribing — `l`,
|
||||||
|
// right, and Enter open the episodes; `a` is the subscribe key.
|
||||||
|
discoverStore.openEpisodes(pod).catch(() => {});
|
||||||
|
nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame);
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) playEpisode(ep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +207,59 @@ function DiscoverPage() {
|
|||||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
open: () => open(),
|
open: () => open(),
|
||||||
"toggle-select": () => {
|
"toggle-select": () => {
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
const pod = focusedPodcast();
|
||||||
if (pod) nav.toggleSelected(pod.id);
|
if (pod) nav.toggleSelected(pod.id);
|
||||||
}
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) nav.toggleSelected(ep.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
download: () => {
|
||||||
|
if (depth() !== 2) return;
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (!pod || !ep) return;
|
||||||
|
// Under its subscribed feed when already subscribed, otherwise as
|
||||||
|
// an "unsubscribed show" download (mirrors Search).
|
||||||
|
const feed = feedForPodcast(pod);
|
||||||
|
if (feed) downloadStore.startDownload(ep, feed.id);
|
||||||
|
else downloadStore.startUnsubscribedDownload(ep, pod);
|
||||||
|
},
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() !== 2) return;
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (!ep) return;
|
||||||
|
const id = ep.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
|
// `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open
|
||||||
|
// the episode list, so subscribing moved off open).
|
||||||
|
subscribe: () => {
|
||||||
|
if (depth() === 1) {
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id);
|
||||||
},
|
},
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
discoverStore.refresh().catch(() => {});
|
discoverStore.refresh().catch(() => {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -161,7 +300,9 @@ function DiscoverPage() {
|
|||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? "Categories"
|
? "Categories"
|
||||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
: depth() === 1
|
||||||
|
? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`
|
||||||
|
: `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||||
@@ -174,7 +315,7 @@ function DiscoverPage() {
|
|||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<TabListPane muted />
|
<TabListPane muted />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={depth() >= 1}>
|
<Show when={depth() === 1}>
|
||||||
<For each={categories()}>
|
<For each={categories()}>
|
||||||
{(cat, index) => {
|
{(cat, index) => {
|
||||||
const lf = () => nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
@@ -203,6 +344,33 @@ function DiscoverPage() {
|
|||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={depth() >= 2}>
|
||||||
|
<For each={podcasts()}>
|
||||||
|
{(podcast, index) => {
|
||||||
|
const lf = () => nav.depthFocus(1);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(index(), lf(), false)}>
|
||||||
|
{index() === lf() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
|
||||||
|
{podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={podcast.isSubscribed}>
|
||||||
|
<text flexShrink={0} fg={muted()}>[+]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -243,7 +411,7 @@ function DiscoverPage() {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: results */}
|
{/* depth ≥1: results */}
|
||||||
<Show when={depth() >= 1}>
|
<Show when={depth() === 1}>
|
||||||
<Show
|
<Show
|
||||||
when={podcasts().length > 0}
|
when={podcasts().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -304,11 +472,59 @@ function DiscoverPage() {
|
|||||||
</For>
|
</For>
|
||||||
<Show when={discoverStore.isLoading()}>
|
<Show when={discoverStore.isLoading()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
|
||||||
|
<Show when={depth() >= 2}>
|
||||||
|
<Show when={episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<LoadingIndicator label="Loading episodes…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={episodesError() && !episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.error}>{episodesError()}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>r: retry · h: back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episodes found. :refresh</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={episodes()}>
|
||||||
|
{(ep, index) => (
|
||||||
|
<EpisodeRow
|
||||||
|
episode={ep}
|
||||||
|
index={index}
|
||||||
|
focused={focusedEpIdx}
|
||||||
|
active={isActive}
|
||||||
|
selected={() => nav.isSelected(ep.id)}
|
||||||
|
downloadLabel={() => downloadLabel(ep.id)}
|
||||||
|
downloadColor={() => downloadColor(ep.id)}
|
||||||
|
marker={marker}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 2);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -357,8 +573,8 @@ function DiscoverPage() {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : depth() === 1 ? (
|
||||||
// depth ≥1 preview: hovered podcast + subscribe
|
// depth 1 preview: hovered podcast + episode-list hint
|
||||||
<Show
|
<Show
|
||||||
when={focusedPodcast()}
|
when={focusedPodcast()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -376,10 +592,10 @@ function DiscoverPage() {
|
|||||||
<text fg={muted()}>by {pod().author}</text>
|
<text fg={muted()}>by {pod().author}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={pod().isSubscribed}>
|
<Show when={pod().isSubscribed}>
|
||||||
<text fg={theme.success}>✓ Subscribed</text>
|
<text fg={theme.success}>✓ Subscribed · x: unsubscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!pod().isSubscribed}>
|
<Show when={!pod().isSubscribed}>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>a: subscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={theme.textSecondary}>
|
<text fg={theme.textSecondary}>
|
||||||
@@ -398,10 +614,67 @@ function DiscoverPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
|
<text fg={muted()}>enter/l: episodes · h: back · r: refresh</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
) : (
|
||||||
|
// depth ≥2 preview: hovered episode (or loading/error/empty)
|
||||||
|
<>
|
||||||
|
<Show when={episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<LoadingIndicator label="Loading episodes…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={episodesError() && !episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.error}>{episodesError()}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>r: retry · h: back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episodes found.</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() &&
|
||||||
|
!episodesError() &&
|
||||||
|
episodes().length > 0 &&
|
||||||
|
focusedEpisode()
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ep) => (
|
||||||
|
<EpisodePreview
|
||||||
|
episode={() => ep()}
|
||||||
|
author={() => drilledPodcast()?.author}
|
||||||
|
downloadLabel={() => downloadLabel(ep().id)}
|
||||||
|
downloadColor={() => downloadColor(ep().id)}
|
||||||
|
hint={() =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(ep().id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
drilledPodcast()?.isSubscribed ? "" : " · a: subscribe"
|
||||||
|
} · h: back`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
|
import type { Episode } from "../types/episode";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
|
|
||||||
export interface DiscoverCategory {
|
export interface DiscoverCategory {
|
||||||
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
|
|||||||
/** Cache window for the remote featured list (24 hours) */
|
/** Cache window for the remote featured list (24 hours) */
|
||||||
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Max episodes to load when previewing an unsubscribed show's episode list
|
||||||
|
* from Discover (drill-in, no subscription). Mirrors the refresh window. */
|
||||||
|
const PREVIEW_EPISODE_LIMIT = 50;
|
||||||
|
|
||||||
/** Shape of a single entry in the remote JSON */
|
/** Shape of a single entry in the remote JSON */
|
||||||
interface FeaturedEntry {
|
interface FeaturedEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,6 +95,19 @@ export function createDiscoverStore() {
|
|||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||||
|
|
||||||
|
// Episodes fetched for an unsubscribed show's preview list (drill-in from
|
||||||
|
// a podcast result, no subscription). Cached per podcast id for the
|
||||||
|
// session; keyed by id so switching shows never clobbers another's list.
|
||||||
|
const [previewEpisodes, setPreviewEpisodes] = createSignal<
|
||||||
|
Record<string, Episode[]>
|
||||||
|
>({});
|
||||||
|
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [previewErrors, setPreviewErrors] = createSignal<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||||
let cachedAt = 0;
|
let cachedAt = 0;
|
||||||
|
|
||||||
@@ -174,13 +192,64 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSubscription = (podcastId: string) => {
|
// ── episode preview (drill-in, no subscription) ──────────────────────────
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
/** Cached episode list for a previewed show (empty until first drill-in). */
|
||||||
if (podcast?.isSubscribed) {
|
const episodesForPodcast = (podcastId: string): Episode[] =>
|
||||||
unsubscribe(podcastId);
|
previewEpisodes()[podcastId] ?? [];
|
||||||
} else {
|
|
||||||
subscribe(podcastId);
|
const isLoadingEpisodesFor = (podcastId: string): boolean =>
|
||||||
|
previewLoading().has(podcastId);
|
||||||
|
|
||||||
|
const previewError = (podcastId: string): string | undefined =>
|
||||||
|
previewErrors()[podcastId];
|
||||||
|
|
||||||
|
/** Fetch a show's episode list WITHOUT subscribing (Discover preview).
|
||||||
|
* The list is cached per podcast id; a failed fetch records an error
|
||||||
|
* and keeps any previous cache (a retry via refreshEpisodes clears it). */
|
||||||
|
const openEpisodes = async (podcast: Podcast): Promise<void> => {
|
||||||
|
if (previewEpisodes()[podcast.id] || previewLoading().has(podcast.id))
|
||||||
|
return;
|
||||||
|
if (!podcast.feedUrl) {
|
||||||
|
setPreviewErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[podcast.id]: "No RSS feed listed for this show.",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setPreviewLoading((prev) => new Set(prev).add(podcast.id));
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const { episodes } = await feedStore.fetchEpisodes(
|
||||||
|
podcast.feedUrl,
|
||||||
|
PREVIEW_EPISODE_LIMIT,
|
||||||
|
);
|
||||||
|
if (episodes) {
|
||||||
|
setPreviewEpisodes((prev) => ({ ...prev, [podcast.id]: episodes }));
|
||||||
|
} else {
|
||||||
|
setPreviewErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[podcast.id]: "Couldn't load episodes.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setPreviewLoading((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(podcast.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Re-fetch a previewed show's episode list (`r` on the episodes depth). */
|
||||||
|
const refreshEpisodes = async (podcast: Podcast): Promise<void> => {
|
||||||
|
setPreviewErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[podcast.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPreviewEpisodes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[podcast.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await openEpisodes(podcast);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -195,8 +264,14 @@ export function createDiscoverStore() {
|
|||||||
setSelectedCategory,
|
setSelectedCategory,
|
||||||
subscribe,
|
subscribe,
|
||||||
unsubscribe,
|
unsubscribe,
|
||||||
toggleSubscription,
|
|
||||||
refresh,
|
refresh,
|
||||||
|
|
||||||
|
// Episode preview (drill-in, no subscription)
|
||||||
|
episodesForPodcast,
|
||||||
|
isLoadingEpisodesFor,
|
||||||
|
previewError,
|
||||||
|
openEpisodes,
|
||||||
|
refreshEpisodes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
337
tests/discover-episode-preview.test.tsx
Normal file
337
tests/discover-episode-preview.test.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* discover-episode-preview.test.tsx — Discover: `l`/right/enter on a podcast
|
||||||
|
* result must OPEN the show's episode list, NOT subscribe.
|
||||||
|
*
|
||||||
|
* Regression: `open` on a Discover podcast result (bound to `l`/right via
|
||||||
|
* `swipe-next`, and to enter) used to toggle subscription — pressing `l` on a
|
||||||
|
* show you wanted to browse subscribed it instead. `l`/right/enter now drill
|
||||||
|
* into a fetched-on-demand episode list (depth 2, no subscription), and `a`
|
||||||
|
* (the app-wide `subscribe` action) is the dedicated subscribe key.
|
||||||
|
*
|
||||||
|
* Mounts the real app (sandboxed, silent audio, mocked discover store) and
|
||||||
|
* drives the Discover tab with the test renderer's mock keys: drill category
|
||||||
|
* → podcast, `l` opens the episode list WITHOUT subscribing (feed store
|
||||||
|
* untouched, subscribe not called); `h` pops back; `a` subscribes the
|
||||||
|
* focused show; `l` then re-opens the episodes.
|
||||||
|
*
|
||||||
|
* App modules are loaded dynamically (never statically) because the sandbox
|
||||||
|
* config/data dirs must be set BEFORE they evaluate — their module-level init
|
||||||
|
* reads those env vars at import time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, afterAll, beforeAll, mock } from "bun:test";
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { AudioControls } from "../src/hooks/useAudio";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
import type { DepthFrame, NavigationState } from "../src/context/navigation-store";
|
||||||
|
|
||||||
|
// Recording audio stub: `play` pushes what was streamed. Registered FIRST so
|
||||||
|
// a leaked partial useAudio mock from another file in this worker can't break
|
||||||
|
// the app mount (see tests/search-focus.test.tsx for the same hazard).
|
||||||
|
const played: Episode[] = [];
|
||||||
|
const stubAudio: AudioControls = {
|
||||||
|
isPlaying: () => false,
|
||||||
|
position: () => 0,
|
||||||
|
duration: () => 0,
|
||||||
|
volume: () => 1,
|
||||||
|
speed: () => 1,
|
||||||
|
backendName: () => "none",
|
||||||
|
error: () => null,
|
||||||
|
currentEpisode: () => null,
|
||||||
|
availablePlayers: () => [],
|
||||||
|
play: async (episode: Episode) => {
|
||||||
|
played.push(episode);
|
||||||
|
},
|
||||||
|
load: async () => {},
|
||||||
|
pause: async () => {},
|
||||||
|
resume: async () => {},
|
||||||
|
togglePlayback: async () => {},
|
||||||
|
stop: async () => {},
|
||||||
|
seek: async () => {},
|
||||||
|
seekRelative: async () => {},
|
||||||
|
setVolume: async () => {},
|
||||||
|
setSpeed: async () => {},
|
||||||
|
switchBackend: async () => {},
|
||||||
|
prev: async () => {},
|
||||||
|
next: async () => {},
|
||||||
|
};
|
||||||
|
mock.module("../src/hooks/useAudio", () => ({
|
||||||
|
useAudio: () => stubAudio,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Deterministic discover store: `openEpisodes` seeds the episode list
|
||||||
|
// synchronously (no network), `subscribe`/`unsubscribe` flip the show's flag
|
||||||
|
// and are recorded so the test can assert l/enter never subscribed.
|
||||||
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||||
|
const [preview, setPreview] = createSignal<Record<string, Episode[]>>({});
|
||||||
|
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [previewErrors, setPreviewErrors] = createSignal<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const subscribeCalls: string[] = [];
|
||||||
|
const openCalls: string[] = [];
|
||||||
|
const flip = (id: string, subscribed: boolean) =>
|
||||||
|
setPodcasts((prev) =>
|
||||||
|
prev.map((p) => (p.id === id ? { ...p, isSubscribed: subscribed } : p)),
|
||||||
|
);
|
||||||
|
const mockDiscoverStore = {
|
||||||
|
selectedCategory,
|
||||||
|
isLoading,
|
||||||
|
podcasts,
|
||||||
|
categories: [
|
||||||
|
{ id: "all", name: "All", icon: "" },
|
||||||
|
{ id: "technology", name: "Technology", icon: "" },
|
||||||
|
],
|
||||||
|
filteredPodcasts: () => {
|
||||||
|
const cat = selectedCategory();
|
||||||
|
if (cat === "all") return podcasts();
|
||||||
|
return podcasts().filter((p) =>
|
||||||
|
(p.categories ?? []).some((c) =>
|
||||||
|
c.toLowerCase().includes(cat.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setSelectedCategory,
|
||||||
|
subscribe: (id: string) => {
|
||||||
|
subscribeCalls.push(id);
|
||||||
|
flip(id, true);
|
||||||
|
},
|
||||||
|
unsubscribe: (id: string) => {
|
||||||
|
flip(id, false);
|
||||||
|
},
|
||||||
|
refresh: async () => {},
|
||||||
|
episodesForPodcast: (id: string) => preview()[id] ?? [],
|
||||||
|
isLoadingEpisodesFor: (id: string) => previewLoading().has(id),
|
||||||
|
previewError: (id: string) => previewErrors()[id],
|
||||||
|
openEpisodes: async (pod: Podcast) => {
|
||||||
|
openCalls.push(pod.id);
|
||||||
|
setPreview((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[pod.id]: [makeEpisode(1), makeEpisode(2)],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
refreshEpisodes: async () => {},
|
||||||
|
};
|
||||||
|
mock.module("../src/stores/discover", () => ({
|
||||||
|
DISCOVER_CATEGORIES: mockDiscoverStore.categories,
|
||||||
|
useDiscoverStore: () => mockDiscoverStore,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
|
||||||
|
// env vars at import time, so the app modules are loaded dynamically.
|
||||||
|
const SANDBOX = join(process.cwd(), ".harness", "test-discover-preview");
|
||||||
|
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
|
||||||
|
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
|
||||||
|
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
|
||||||
|
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
const { App } = await import("../src/App");
|
||||||
|
const { ThemeProvider } = await import("../src/context/ThemeContext");
|
||||||
|
const toast = await import("../src/ui/toast");
|
||||||
|
const { KeybindProvider, useKeybinds } = await import(
|
||||||
|
"../src/context/KeybindContext"
|
||||||
|
);
|
||||||
|
const { NavigationProvider, useNavigation } = await import(
|
||||||
|
"../src/context/NavigationContext"
|
||||||
|
);
|
||||||
|
const { DialogProvider } = await import("../src/ui/dialog");
|
||||||
|
const { CommandProvider } = await import("../src/ui/command");
|
||||||
|
const { TABS } = await import("../src/utils/navigation");
|
||||||
|
const { useFeedStore } = await import("../src/stores/feed");
|
||||||
|
|
||||||
|
function makePodcast(): Podcast {
|
||||||
|
return {
|
||||||
|
id: "featured-show",
|
||||||
|
title: "Featured Show",
|
||||||
|
description: "A featured show.",
|
||||||
|
feedUrl: "https://example.test/featured.xml",
|
||||||
|
author: "tester",
|
||||||
|
categories: ["Technology"],
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEpisode(n: number): Episode {
|
||||||
|
return {
|
||||||
|
id: `featured-ep-${n}`,
|
||||||
|
podcastId: "featured-show",
|
||||||
|
title: `Featured Episode ${n}`,
|
||||||
|
description: "",
|
||||||
|
audioUrl: "https://example.test/ep.mp3",
|
||||||
|
duration: 0,
|
||||||
|
pubDate: new Date(`2026-08-0${n}T00:00:00Z`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockInput = { pressKey: (key: string) => void; pressEnter: () => void };
|
||||||
|
type Mounted = {
|
||||||
|
renderer: { destroy: () => void };
|
||||||
|
renderOnce: () => Promise<void>;
|
||||||
|
mockInput: MockInput;
|
||||||
|
nav: () => NavigationState;
|
||||||
|
keybindsReady: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mountApp(): Promise<Mounted> {
|
||||||
|
let navRef: NavigationState | null = null;
|
||||||
|
let keybindsRef: { ready: boolean } | null = null;
|
||||||
|
const StateProbe = () => {
|
||||||
|
navRef = useNavigation();
|
||||||
|
keybindsRef = useKeybinds();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const HarnessRoot = () => (
|
||||||
|
<toast.ToastProvider>
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<KeybindProvider>
|
||||||
|
<NavigationProvider>
|
||||||
|
<StateProbe />
|
||||||
|
<DialogProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<App />
|
||||||
|
<toast.Toast />
|
||||||
|
</CommandProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
</KeybindProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</toast.ToastProvider>
|
||||||
|
);
|
||||||
|
const setup = await testRender(() => <HarnessRoot />, {
|
||||||
|
width: 100,
|
||||||
|
height: 30,
|
||||||
|
useThread: false,
|
||||||
|
});
|
||||||
|
// The test renderer intercepts stdout; the app is a TUI that writes frames
|
||||||
|
// asynchronously, so silence that interception (same as search-focus).
|
||||||
|
(
|
||||||
|
setup.renderer as unknown as {
|
||||||
|
disableStdoutInterception?: () => void;
|
||||||
|
}
|
||||||
|
).disableStdoutInterception?.();
|
||||||
|
await setup.renderOnce();
|
||||||
|
await sleep(60);
|
||||||
|
return {
|
||||||
|
renderer: setup.renderer,
|
||||||
|
renderOnce: setup.renderOnce,
|
||||||
|
mockInput: setup.mockInput,
|
||||||
|
nav: () => navRef!,
|
||||||
|
keybindsReady: () => keybindsRef?.ready ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleReady(m: Mounted): Promise<void> {
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
await m.renderOnce();
|
||||||
|
await sleep(60);
|
||||||
|
if (m.keybindsReady()) return;
|
||||||
|
}
|
||||||
|
throw new Error("keybinds never became ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
m: Mounted,
|
||||||
|
cond: () => boolean,
|
||||||
|
what: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (cond()) return;
|
||||||
|
await m.renderOnce();
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
throw new Error(`timed out waiting for: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
setPodcasts([makePodcast()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(SANDBOX, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l on a podcast opens its episode list without subscribing; a subscribes", async () => {
|
||||||
|
const m = await mountApp();
|
||||||
|
try {
|
||||||
|
await settleReady(m);
|
||||||
|
|
||||||
|
// Open the Discover tab (digit press retried until the router attaches).
|
||||||
|
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.DISCOVER; i++) {
|
||||||
|
m.mockInput.pressKey("3");
|
||||||
|
await m.renderOnce();
|
||||||
|
await sleep(40);
|
||||||
|
}
|
||||||
|
expect(m.nav().activeTab()).toBe(TABS.DISCOVER);
|
||||||
|
m.mockInput.pressEnter(); // open the tab's content (category depth)
|
||||||
|
await waitFor(
|
||||||
|
m,
|
||||||
|
() => m.nav().currentDepth() === 0 && !m.nav().atRootTab(),
|
||||||
|
"discover content mounted",
|
||||||
|
);
|
||||||
|
|
||||||
|
// l on the focused category drills to the podcast results (depth 1).
|
||||||
|
m.mockInput.pressKey("l");
|
||||||
|
await waitFor(m, () => m.nav().currentDepth() === 1, "results depth");
|
||||||
|
expect(m.nav().topFrame()?.kind).toBe("results");
|
||||||
|
|
||||||
|
// l on the focused podcast opens its episode list (depth 2) — the
|
||||||
|
// show must NOT be subscribed, the feed store untouched.
|
||||||
|
m.mockInput.pressKey("l");
|
||||||
|
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes depth");
|
||||||
|
expect(m.nav().topFrame()?.kind).toBe("episodes");
|
||||||
|
expect(m.nav().topFrame()?.ctx).toBe("featured-show");
|
||||||
|
expect(openCalls).toEqual(["featured-show"]);
|
||||||
|
expect(subscribeCalls).toHaveLength(0);
|
||||||
|
expect(played).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
useFeedStore()
|
||||||
|
.feeds()
|
||||||
|
.some((f) => f.podcast.id === "featured-show"),
|
||||||
|
).toBe(false);
|
||||||
|
// The seeded episode list is what the page renders at depth 2.
|
||||||
|
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// h pops back to the results (depth 1).
|
||||||
|
m.mockInput.pressKey("h");
|
||||||
|
await waitFor(m, () => m.nav().currentDepth() === 1, "back to results");
|
||||||
|
|
||||||
|
// a subscribes the focused show (the dedicated subscribe key).
|
||||||
|
m.mockInput.pressKey("a");
|
||||||
|
await waitFor(
|
||||||
|
m,
|
||||||
|
() => mockDiscoverStore.podcasts()[0]?.isSubscribed === true,
|
||||||
|
"a subscribes the show",
|
||||||
|
);
|
||||||
|
expect(subscribeCalls).toEqual(["featured-show"]);
|
||||||
|
|
||||||
|
// l still opens the episode list for a subscribed show (no toggle).
|
||||||
|
m.mockInput.pressKey("l");
|
||||||
|
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes re-opened");
|
||||||
|
expect(subscribeCalls).toEqual(["featured-show"]);
|
||||||
|
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
m.renderer.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
129
tests/discover-store-preview.test.ts
Normal file
129
tests/discover-store-preview.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* discover-store-preview.test.ts — the Discover episode-preview store API.
|
||||||
|
*
|
||||||
|
* `openEpisodes` fetches a show's RSS feed WITHOUT subscribing (drill-in from
|
||||||
|
* a podcast result), caches it per podcast id for the session, records a
|
||||||
|
* per-show error on failure, and never refetches while cached or in flight.
|
||||||
|
* `refreshEpisodes` clears the cache/error and refetches. The feed store is
|
||||||
|
* mocked so the network never runs; the cache-hit/in-flight/error contracts
|
||||||
|
* are what this file defends.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, mock } from "bun:test";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
|
||||||
|
const fetchCalls: string[] = [];
|
||||||
|
const mockFeedStore = {
|
||||||
|
fetchEpisodes: async (feedUrl: string, limit: number) => {
|
||||||
|
fetchCalls.push(feedUrl);
|
||||||
|
return {
|
||||||
|
episodes: [makeEpisode("ep-1")] as Episode[] | null,
|
||||||
|
coverUrl: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mock.module("../src/stores/feed", () => ({
|
||||||
|
useFeedStore: () => mockFeedStore,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useDiscoverStore } = await import("../src/stores/discover");
|
||||||
|
|
||||||
|
function makePodcast(overrides: Partial<Podcast> = {}): Podcast {
|
||||||
|
return {
|
||||||
|
id: "show-1",
|
||||||
|
title: "Show 1",
|
||||||
|
description: "",
|
||||||
|
feedUrl: "https://example.test/feed.xml",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEpisode(id: string): Episode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
podcastId: "show-1",
|
||||||
|
title: `Ep ${id}`,
|
||||||
|
description: "",
|
||||||
|
audioUrl: "https://example.test/ep.mp3",
|
||||||
|
duration: 0,
|
||||||
|
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("openEpisodes fetches, caches, and never refetches on cache hit or in flight", async () => {
|
||||||
|
const store = useDiscoverStore();
|
||||||
|
const pod = makePodcast();
|
||||||
|
|
||||||
|
expect(store.episodesForPodcast(pod.id)).toHaveLength(0);
|
||||||
|
|
||||||
|
await store.openEpisodes(pod);
|
||||||
|
expect(fetchCalls).toEqual([pod.feedUrl]);
|
||||||
|
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
|
||||||
|
expect(store.episodesForPodcast(pod.id)[0].id).toBe("ep-1");
|
||||||
|
expect(store.isLoadingEpisodesFor(pod.id)).toBe(false);
|
||||||
|
expect(store.previewError(pod.id)).toBeUndefined();
|
||||||
|
|
||||||
|
// Cache hit: second open must not refetch.
|
||||||
|
await store.openEpisodes(pod);
|
||||||
|
expect(fetchCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
// In-flight guard: a concurrent open during loading must not refetch.
|
||||||
|
const slow = mockFeedStore.fetchEpisodes;
|
||||||
|
const gate = Promise.withResolvers<void>();
|
||||||
|
mockFeedStore.fetchEpisodes = async (feedUrl: string, limit: number) => {
|
||||||
|
fetchCalls.push(feedUrl);
|
||||||
|
await gate.promise;
|
||||||
|
return { episodes: [makeEpisode("ep-2")] as Episode[] | null, coverUrl: undefined };
|
||||||
|
};
|
||||||
|
const pod2 = makePodcast({ id: "show-2", feedUrl: "https://example.test/feed2.xml" });
|
||||||
|
const pending = store.openEpisodes(pod2);
|
||||||
|
// Loading is set synchronously before the fetch resolves.
|
||||||
|
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true);
|
||||||
|
await store.openEpisodes(pod2); // must early-return, not queue a second fetch
|
||||||
|
gate.resolve();
|
||||||
|
await pending;
|
||||||
|
expect(fetchCalls).toEqual([pod.feedUrl, pod2.feedUrl]);
|
||||||
|
expect(store.episodesForPodcast(pod2.id)[0].id).toBe("ep-2");
|
||||||
|
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false);
|
||||||
|
mockFeedStore.fetchEpisodes = slow;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openEpisodes records an error for feedless shows and failed fetches", async () => {
|
||||||
|
const store = useDiscoverStore();
|
||||||
|
const feedless = makePodcast({ id: "show-3", feedUrl: undefined });
|
||||||
|
|
||||||
|
await store.openEpisodes(feedless);
|
||||||
|
expect(fetchCalls).not.toContain(feedless.id);
|
||||||
|
expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show.");
|
||||||
|
expect(store.episodesForPodcast(feedless.id)).toHaveLength(0);
|
||||||
|
|
||||||
|
// Failed fetch (null episodes) → error recorded, nothing cached.
|
||||||
|
const original = mockFeedStore.fetchEpisodes;
|
||||||
|
mockFeedStore.fetchEpisodes = async () => ({
|
||||||
|
episodes: null,
|
||||||
|
coverUrl: undefined,
|
||||||
|
});
|
||||||
|
const failing = makePodcast({ id: "show-4" });
|
||||||
|
await store.openEpisodes(failing);
|
||||||
|
expect(store.previewError(failing.id)).toBe("Couldn't load episodes.");
|
||||||
|
expect(store.episodesForPodcast(failing.id)).toHaveLength(0);
|
||||||
|
expect(store.isLoadingEpisodesFor(failing.id)).toBe(false);
|
||||||
|
mockFeedStore.fetchEpisodes = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshEpisodes clears the cache and error, then refetches", async () => {
|
||||||
|
const store = useDiscoverStore();
|
||||||
|
const pod = makePodcast({ id: "show-5" });
|
||||||
|
|
||||||
|
await store.openEpisodes(pod);
|
||||||
|
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
|
||||||
|
const callsBefore = fetchCalls.length;
|
||||||
|
|
||||||
|
await store.refreshEpisodes(pod);
|
||||||
|
expect(fetchCalls.length).toBe(callsBefore + 1);
|
||||||
|
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
|
||||||
|
expect(store.previewError(pod.id)).toBeUndefined();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user