/** * DiscoverPage — yazi depth-stack view of discoverable podcasts. * * depth 0 (current) — category list. Parent pane shows the muted * placeholder (1/5 slot kept). * depth 1 (current) — podcast results for the drilled category. Parent * pane = the categories list. * 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 ``; no bespoke 3-column flexbox JSX * 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 { useNavigation, NavMode, DEPTH_CENTER_PANE, type PaneId, type DepthFrame, } from "@/context/NavigationContext"; import { on, off } from "@/utils/event-bus"; import { supportsNerdFonts } from "@/utils/nerd-fonts"; 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"; export const DiscoverPaneCount = 1; 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); const categories = () => DISCOVER_CATEGORIES; const podcasts = () => discoverStore.filteredPodcasts(); const focusedCatIdx = () => categories().length === 0 ? 0 : Math.min(focus(0), categories().length - 1); const focusedCategory = createMemo(() => categories()[focusedCatIdx()]); const focusedPodIdx = () => 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(() => { 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 : 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); // Auto-fetch the featured-shows manifest on first mount (network failure is // non-fatal — the list stays empty until the user hits refresh). onMount(() => { discoverStore.refresh().catch(() => {}); }); onMount(() => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { if (depth() === 0) return categories()[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) { const c = focusedCategory(); if (!c) return; discoverStore.setSelectedCategory(c.id); nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame); nav.setActivePane(DEPTH_CENTER_PANE); return; } if (depth() === 1) { const pod = focusedPodcast(); 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); } } // ── nav.action handler ──────────────────────────────────────────────────── const PAGE_ACTIONS: Partial void>> = { "move-down": () => step(1), "move-up": () => step(-1), "jump-down": () => step(5), "jump-up": () => step(-5), "page-down": () => step(10), "page-up": () => step(-10), "goto-top": () => nav.gotoIndex(0, curLen()), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), open: () => open(), "toggle-select": () => { 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(() => {}); }, }; function step(delta: number) { nav.move(delta, curLen()); // keep the store's selected category synced with the focused row at depth 0 if (depth() === 0) { const c = focusedCategory(); if (c) discoverStore.setSelectedCategory(c.id); } } const onAction = (data: { action: KeybindActionName; pane: PaneId; mode: NavMode; }) => { if (data.pane !== DEPTH_CENTER_PANE) return; if (nav.activePane() !== DEPTH_CENTER_PANE) return; ensureFocus(); PAGE_ACTIONS[data.action]?.(); }; onMount(() => { on("nav.action", onAction); onCleanup(() => off("nav.action", onAction)); }); // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const focusBg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.primary : i === lf ? theme.border : undefined; const focusFg = (i: number, lf: number, active: boolean) => i === lf && active ? theme.surface : i === lf ? theme.selectedListItemText ?? theme.text : theme.text; const currentLabel = () => depth() === 0 ? "Categories" : depth() === 1 ? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}` : `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`; // ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // Sibling blocks per depth (the known-good opentui disposal // pattern, mirrors Settings): a STABLE fragment root whose inner // children toggle on depth change, so the old subtree is disposed instead // of left orphaned next to the new one (single and // ternary root swaps both leak the previous root). const parentContent = () => ( <> {(cat, index) => { const lf = () => nav.depthFocus(0); const ref = useScrollIntoView(() => index() === lf()); return ( {index() === nav.depthFocus(0) ? marker() : " "} {nerd && ( {cat.icon} )} {cat.name} ); }} = 2}> {(podcast, index) => { const lf = () => nav.depthFocus(1); const ref = useScrollIntoView(() => index() === lf()); return ( {index() === lf() ? marker() : " "} {podcast.title} [+] ); }} ); // ── current pane ─────────────────────────────────────────────────────────── const currentContent = () => ( <> {/* depth 0: categories */} {(cat, index) => { const lf = () => focusedCatIdx(); const ref = useScrollIntoView(() => index() === lf()); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); discoverStore.setSelectedCategory(cat.id); }} > {index() === lf() ? marker() : " "} {nerd && ( {cat.icon} )} {cat.name} ); }} {/* depth ≥1: results */} 0} fallback={ No podcasts found. :refresh } > } > {(podcast, index) => { const lf = () => focusedPodIdx(); const ref = useScrollIntoView(() => index() === lf()); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} > {index() === lf() ? marker() : " "} {podcast.title} [+] by {podcast.author} ); }} {/* depth ≥2: episodes of the drilled show (preview, no subscription) */} = 2}> {episodesError()} r: retry · h: back No episodes found. :refresh 0 } > {(ep, index) => ( nav.isSelected(ep.id)} downloadLabel={() => downloadLabel(ep.id)} downloadColor={() => downloadColor(ep.id)} marker={marker} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 2); }} /> )} ); // ── preview pane ─────────────────────────────────────────────────────────── const previewContent = () => depth() === 0 ? ( // depth 0 preview: shows for the hovered category No category focused } > {(cat) => ( {cat().name} {(cat() as any).description} 0} fallback={ No shows in this category yet. :refresh } > {(pod) => ( {pod.title} by {pod.author} )} )} ) : depth() === 1 ? ( // depth 1 preview: hovered podcast + episode-list hint No podcast focused } > {(pod) => ( {pod().title} by {pod().author} ✓ Subscribed · x: unsubscribe a: subscribe {pod().description?.slice(0, 400) ?? "No description available."} {(pod().description?.length ?? 0) > 400 ? "…" : ""} 0}> {(cat) => [{cat}]} Feed: {pod().feedUrl} Updated: {formatDate(pod().lastUpdated)} enter/l: episodes · h: back · r: refresh )} ) : ( // depth ≥2 preview: hovered episode (or loading/error/empty) <> {episodesError()} r: retry · h: back No episodes found. 0 && focusedEpisode() } fallback={ No episode focused } > {(ep) => ( 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` } /> )} ); return ( ); } export { DiscoverPage };