feat(discover): drill into show episode previews without subscribing

This commit is contained in:
2026-08-13 17:46:30 -04:00
parent badbc6a037
commit 9df8eebf6c
5 changed files with 842 additions and 28 deletions

View File

@@ -5,18 +5,28 @@
* placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list.
* preview — detail of the hovered item (category summary, or
* podcast detail + subscribe action).
* depth 2 (current) — episodes of the drilled show, fetched on demand
* 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
* remains. `l`/Enter drills in (category → results) or subscribes (on a
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
* column. Moving through categories at depth 0 updates the store's selected
* category so the preview follows.
* remains. `l`/Enter drills in (category → results → episodes); `a`
* subscribes the focused show (enter/l never subscribe — they open the
* episode list); `h` pops a depth (noop at 0). j/k move only within the
* 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 { 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 { useTheme } from "@/context/ThemeContext";
import {
@@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
@@ -41,11 +52,16 @@ function DiscoverPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
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);
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 = () =>
depth() === 0 ? categories().length : podcasts().length;
depth() === 0
? categories().length
: depth() === 1
? podcasts().length
: episodes().length;
const ensureFocus = () => {
if (categories().length > 0 && focus(0) >= categories().length)
nav.setDepthFocus(categories().length - 1, 0);
if (podcasts().length > 0 && focus(1) >= podcasts().length)
nav.setDepthFocus(podcasts().length - 1, 1);
if (episodes().length > 0 && focus(2) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 2);
};
onMount(ensureFocus);
@@ -80,13 +119,56 @@ function DiscoverPage() {
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id;
return podcasts()[i]?.id;
if (depth() === 1) return podcasts()[i]?.id;
return episodes()[i]?.id;
});
});
// ── helpers ────────────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
@@ -97,9 +179,19 @@ function DiscoverPage() {
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 1) {
if (depth() === 1) {
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()),
open: () => open(),
"toggle-select": () => {
if (depth() >= 1) {
if (depth() === 1) {
const pod = focusedPodcast();
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: () => {
if (depth() >= 2) {
const pod = drilledPodcast();
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
return;
}
discoverStore.refresh().catch(() => {});
},
};
@@ -161,7 +300,9 @@ function DiscoverPage() {
const currentLabel = () =>
depth() === 0
? "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) ─────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal
@@ -174,7 +315,7 @@ function DiscoverPage() {
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<Show when={depth() === 1}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
@@ -203,6 +344,33 @@ function DiscoverPage() {
}}
</For>
</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>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show when={depth() === 1}>
<Show
when={podcasts().length > 0}
fallback={
@@ -304,11 +472,59 @@ function DiscoverPage() {
</For>
<Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
<LoadingIndicator />
</box>
</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>
)}
</Show>
) : (
// depth 1 preview: hovered podcast + subscribe
) : depth() === 1 ? (
// depth 1 preview: hovered podcast + episode-list hint
<Show
when={focusedPodcast()}
fallback={
@@ -376,10 +592,10 @@ function DiscoverPage() {
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
<text fg={theme.success}> Subscribed · x: unsubscribe</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
<text fg={theme.primary}>a: subscribe</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
@@ -398,10 +614,67 @@ function DiscoverPage() {
</Show>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<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>
)}
</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 (