/** * SearchPage — yazi depth-stack view of podcast search. * * depth 0 (current) — query input row + recent-searches list (navigable * with j/k when the input is defocused). Parent pane * shows the tab list (muted); preview shows a hint. * depth 1 (current) — search results list. Parent pane shows the submitted * query (muted, read-only); preview shows the detail of * the focused result. * * Search scope: `tab` (search-scope-toggle) flips between shows and episodes * (clickable pills on the query depth too); toggling while viewing results * re-runs the current query in the new scope. * * Typed input owns its keys while `nav.inputFocused()` is true (the Shell * router yields). Escape defocuses the input (handled in Shell) so j/k/h * navigation resumes; `s` (the `search` action) refocuses it. Enter on the * input (or on a focused recent at depth 0) submits the query and pushes to * depth 1 (results). `h` pops: results→query, query→tab root. */ import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup, } from "solid-js"; import { useSearchStore } from "@/stores/search"; 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 { useToast } from "@/ui/toast"; 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 type { KeybindActionName } from "@/context/KeybindContext"; import type { SearchResult, SearchScope } from "@/types/source"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; import { useInputFocusNav } from "@/hooks/useInputFocusNav"; export const SearchPaneCount = 1; function SearchPage() { const searchStore = useSearchStore(); const feedStore = useFeedStore(); const downloadStore = useDownloadStore(); const audio = useAudio(); const audioNav = useAudioNavStore(); const toast = useToast(); const [inputValue, setInputValue] = createSignal(""); 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); // depth 1's ctx carries the submitted query string. const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query(); // ── input focusing ──────────────────────────────────────────────────────── // `inputFocused` tells the Shell router to yield keys to the query input. // The input's REAL focus is the source of truth: useInputFocusNav flips // the flag from the input's FOCUSED/BLURRED events, so clicking off the // input drops it and the router resumes j/k/h — no stuck "typing" state. // The depth stack only SEEDS it on transitions (re-entering depth 0 // focuses the input; mounting at depth 1 stays list-nav), gated on the // depth VALUE via a memo because setDepthFocus also writes the stack // signal — without the memo every j/k at query depth re-focuses the input // and strands the recents list. onMount(() => nav.setInputFocused(depth() === 0)); onCleanup(() => nav.setInputFocused(false)); const focusNavRef = useInputFocusNav(); const isQueryDepth = createMemo(() => depth() === 0); createEffect(() => { nav.setInputFocused(isQueryDepth()); }); // ── results (depth 1) ───────────────────────────────────────────────────── const results = () => searchStore.results(); const focusedResultIdx = () => results().length === 0 ? 0 : Math.min(focus(1), results().length - 1); const focusedResult = createMemo(() => { const list = results(); if (list.length === 0) return undefined; return list[focusedResultIdx()]; }); // ── recents (depth 0) ──────────────────────────────────────────────────── const recents = () => searchStore.history(); const curLen = () => (depth() === 0 ? recents().length : results().length); const ensureFocus = () => { if (depth() === 1 && results().length > 0 && focus(1) >= results().length) nav.setDepthFocus(results().length - 1, 1); }; onMount(ensureFocus); // Register a visual-mode resolver for the results list (depth 1). onMount(() => { const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`; nav.registerResolver(key, (i) => { const r = results()[i]; return r?.kind === "episode" ? r.episode.id : r?.podcast.id; }); }); // ── helpers ───────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); 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 runSearch = (query: string) => { const q = query.trim(); if (!q) return; searchStore.search(q).catch(() => {}); nav.pushDepth({ kind: "search:results", ctx: q, focus: 0, } as DepthFrame); nav.setActivePane(DEPTH_CENTER_PANE); }; const handleSubmit = () => runSearch(inputValue()); const selectRecent = (query: string) => { setInputValue(query); runSearch(query); }; /** Set show/episode scope; when viewing results, re-run the current query * so the list switches immediately (the toggle is otherwise invisible on * a list of results). */ const applyScope = (next: SearchScope) => { searchStore.setScope(next); if (depth() >= 1) { const q = submittedQuery() || inputValue().trim(); if (q) searchStore.search(q).catch(() => {}); } }; const toggleScope = () => applyScope(searchStore.scope() === "podcast" ? "episode" : "podcast"); const handleSubscribe = async (result: SearchResult) => { // Actually add the feed to the feed store, then mark the result // subscribed. addFeed returns null when a feedless directory stub // (delisted show) can't be resolved — tell the user why. const feed = await feedStore .addFeed(result.podcast, result.sourceId) .catch(() => null); if (!feed && !result.podcast.feedUrl) { toast.show({ title: "Can't subscribe", message: "No RSS feed is listed for this show and the feed couldn't be resolved. Try adding it by feed URL.", variant: "error", }); return; } if (feed) searchStore.markSubscribed(result.podcast.id); }; /** The subscribed feed backing a search result, if any (matched by * directory id or feed URL). */ const feedForResult = (r: SearchResult) => feedStore.feeds().find( (f) => f.podcast.id === r.podcast.id || (!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl), ); /** Download the focused episode: under its subscribed feed when the show * is subscribed, otherwise as an "unsubscribed show" download (listed * under Unsubscribed Show Downloads in My Shows / the download manager). */ const downloadFocusedEpisode = () => { if (depth() !== 1) return; const r = focusedResult(); if (!r || r.kind !== "episode") return; const feed = feedForResult(r); if (feed) downloadStore.startDownload(r.episode, feed.id); else downloadStore.startUnsubscribedDownload(r.episode, r.podcast); }; const playFocusedEpisode = () => { if (depth() !== 1) return; const r = focusedResult(); if (!r || r.kind !== "episode") return; audio.play(r.episode).catch(() => {}); audioNav.setSource(AudioSource.SEARCH, r.podcast.id); }; const unsubscribeFocused = () => { if (depth() !== 1) return; const r = focusedResult(); if (!r || !r.podcast.isSubscribed) return; const feed = feedForResult(r); if (feed) { feedStore.removeFeed(feed.id); downloadStore .removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined) .catch(() => {}); searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl); } }; /** Subscribe the focused result's show in place (episode or podcast * result). `enter` plays episodes regardless of subscription, so an * unsubscribed show's episode needs this explicit path. */ const subscribeFocused = () => { if (depth() !== 1) return; const r = focusedResult(); if (!r || r.podcast.isSubscribed) return; handleSubscribe(r); }; // ── 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 r = focusedResult(); if (r) nav.toggleSelected( r.kind === "episode" ? r.episode.id : r.podcast.id, ); } }, download: () => downloadFocusedEpisode(), "delete-download": () => { if (depth() !== 1) return; const r = focusedResult(); if (!r || r.kind !== "episode") return; const id = r.episode.id; if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return; downloadStore.cancelDownload(id); downloadStore.removeDownload(id).catch(() => {}); }, unsubscribe: () => unsubscribeFocused(), subscribe: () => subscribeFocused(), search: () => { // `s` refocuses the query input (typing mode) when on the query depth. if (depth() === 0) nav.setInputFocused(true); }, "search-scope-toggle": () => toggleScope(), refresh: () => { const q = submittedQuery() || inputValue().trim(); if (q) searchStore.search(q).catch(() => {}); }, }; function step(delta: number) { nav.move(delta, curLen()); } function open() { if (depth() === 0) { // Enter/l on a focused recent search → submit it and drill to results. const list = recents(); const idx = Math.min(focus(0), list.length - 1); const q = list[idx]; if (q) selectRecent(q); return; } if (depth() === 1) { const r = focusedResult(); if (!r) return; if (r.kind === "episode") { // Any episode result streams directly — subscribed or not // (matches Feed/My Shows). `a` subscribes an unsubscribed // show's episode in place. playFocusedEpisode(); return; } handleSubscribe(r); } } 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 inputActive = () => nav.inputFocused() && depth() === 0; const focusBg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.primary : i === listFocus ? theme.border : undefined; const focusFg = (i: number, listFocus: number, active: boolean) => i === listFocus && active ? theme.surface : i === listFocus ? theme.selectedListItemText ?? theme.text : theme.text; // ── parent pane: previous-depth content (tab list 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 = () => ( <> = 1}> Query {submittedQuery() || "(empty)"} Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"} h: back to query ); // ── current pane ──────────────────────────────────────────────────────────── const currentContent = () => ( <> {/* query input row + recent searches */} Query: handleSubmit()} onMouseDown={(evt) => { // Clicking the input must focus it (typing mode). // preventDefault stops opentui's click auto-focus from // grabbing the pane scrollbox instead; setting the flag // drives the `focused` prop → renderable focus → the // useInputFocusNav FOCUSED handler. evt.preventDefault(); nav.setInputFocused(true); }} onKeyDown={(evt) => { // While the input owns keys the Shell router never sees // Tab, so the scope toggle must be handled here (the // pills and the tab keybind cover the defocused cases). if (evt.name === "tab") { evt.preventDefault(); toggleScope(); } }} placeholder={ searchStore.scope() === "episode" ? "Enter episode, guest, topic..." : "Enter podcast name..." } focused={inputActive()} width={28} textColor={theme.text} focusedTextColor={theme.accent} cursorColor={theme.accent} /> Scope: applyScope("podcast")} > {" "} Shows{" "} applyScope("episode")} > {" "} Episodes{" "} tab to toggle {searchStore.error()} Recent 0} fallback={ {inputActive() ? "Enter to search" : "s to type · Enter to search"} } > {(query, index) => { const lf = () => focus(0); const ref = useScrollIntoView(() => index() === lf()); // While the input is focused (typing), the list is not // in focus: no bg, no accent fg, no `❯` on any entry. const typing = () => inputActive(); return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 0); // A recent is an action, not an item: clicking // it re-runs that search (focus-only would be // invisible — the input still owns the keys). selectRecent(query); }} > {index() === lf() && !typing() ? marker() : " "} {query} ); }} {inputActive() ? "Enter to search · Esc to defocus" : "j/k recents · s to type · tab scope · h back"} = 1}> {/* results list */} 0} fallback={ {searchStore.query() ? "No results found" : searchStore.scope() === "episode" ? "Enter a search term to find episodes" : "Enter a search term to find podcasts"} } > } > {(result, index) => { const fi = () => focusedResultIdx(); const ref = useScrollIntoView(() => index() === fi()); // Episode download status badge ("" when absent). const dlLabel = () => result.kind === "episode" ? downloadLabel(result.episode.id) : ""; const dlEpId = () => result.kind === "episode" ? result.episode.id : ""; return ( { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(index(), 1); }} > {index() === fi() ? marker() : " "} {result.kind === "episode" ? result.episode.title : result.podcast.title} {dlLabel()} [+] {result.kind === "episode" ? ( {result.podcast.title} ·{" "} {formatDate(result.episode.pubDate)} ) : ( by {result.podcast.author} )} ); }} ); // ── preview pane ──────────────────────────────────────────────────────────── const previewContent = () => depth() === 0 ? ( Search Type a query, press Enter to search. Tab toggles Shows ↔ Episodes (episode search finds guests and topics). Esc defocuses the input; h goes back. Recent · {recents().length} {(q) => ‣ {q}} ) : ( No result focused } > {(result) => { const r = result(); if (r.kind === "episode") { return ( {r.episode.title} {r.podcast.title} by {r.podcast.author} {r.episode.description!.slice(0, 400)} {(r.episode.description?.length ?? 0) > 400 ? "…" : ""} Published: {formatDate(r.episode.pubDate)} {downloadLabel(r.episode.id)} 0}> {(cat) => [{cat}]} Source: {r.sourceName} [+] Subscribe (a) Subscribed · x: unsubscribe enter: play · a: subscribe · d: download · h: back to query } > enter: play · d: download · x: unsubscribe {downloadStore.getDownloadStatus(r.episode.id) !== DownloadStatus.NONE ? " · D: delete" : ""}{" "} · h: back to query ); } return ( {r.podcast.title} by {r.podcast.author} {r.podcast.description!.slice(0, 400)} {(r.podcast.description?.length ?? 0) > 400 ? "…" : ""} 0}> {(cat) => [{cat}]} Feed:{" "} {r.podcast.feedUrl || "not listed by source — resolves on subscribe"} Updated: {formatDate(r.podcast.lastUpdated)} Source: {r.sourceName} [+] Subscribe (enter) Subscribed · x: unsubscribe enter: subscribe {r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "} · h: back to query ); }} ); const currentLabel = () => depth() === 0 ? `Search · ${recents().length} recent` : `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`; return ( ); } export { SearchPage };