feat(search): episode search scope with tab toggle
Search now covers individual episodes, not just shows: the iTunes Search API (entity=podcastEpisode) matches episode titles and show notes, so a guest or topic finds the episodes they appear in across shows. Enter on an episode result subscribes to the parent show. - types: SearchResult becomes a kind-discriminated union (podcast | episode); EpisodeSearchResult carries the parent show so existing consumers compile unchanged - source-searcher: searchEpisodesByType (RSS/CUSTOM return []), buildItunesEpisodeUrl, cleanDescription (HTML -> text), mapItunesEpisodeResult (episode id/duration ms->s/audioUrl, reuses mapItunesResult so delisted shows keep a directoryUrl) - search: searchEpisodes with an 'episode' cache/dedupe namespace so shows and episodes for the same query never mix - stores/search: scope signal (podcast | episode) persisted to podtui_search_scope; search() branches on scope - SearchPage: Shows/Episodes pills row with 'tab to toggle', scope- aware placeholder/empty state/result rows (episode row = title + Show · date) and preview; toggling re-runs the current query - keybinds: search-scope-toggle bound to tab (keybinds.jsonc AND the runtime DEFAULT_KEYBINDS merge so the binding exists for users with a pre-existing config file); while the input is focused the Shell router never sees Tab, so the input handles it via onKeyDown + preventDefault (no double-toggle: the router path only fires when the input is defocused) - Shell help overlay documents [tab] shows/episodes
This commit is contained in:
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
|
||||
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
||||
import { useFeedStore } from "./feed";
|
||||
import type { SearchResult } from "../types/source";
|
||||
import type { SearchResult, SearchScope } from "../types/source";
|
||||
|
||||
const STORAGE_KEY = "podtui_search_history";
|
||||
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
export interface SearchState {
|
||||
@@ -41,6 +42,27 @@ function saveHistory(history: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
||||
function loadScope(): SearchScope {
|
||||
if (typeof localStorage === "undefined") return "podcast";
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
|
||||
return stored === "episode" ? "episode" : "podcast";
|
||||
} catch {
|
||||
return "podcast";
|
||||
}
|
||||
}
|
||||
|
||||
/** Save search scope to localStorage */
|
||||
function saveScope(scope: SearchScope): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Create search store */
|
||||
export function createSearchStore() {
|
||||
const feedStore = useFeedStore();
|
||||
@@ -50,6 +72,13 @@ export function createSearchStore() {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
||||
|
||||
/** Set the search scope (shows vs episodes) and persist it. */
|
||||
const setScope = (next: SearchScope) => {
|
||||
setScopeState(next);
|
||||
saveScope(next);
|
||||
};
|
||||
|
||||
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
||||
const feeds = feedStore.feeds();
|
||||
@@ -110,9 +139,14 @@ export function createSearchStore() {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
||||
cacheTtl: CACHE_TTL,
|
||||
});
|
||||
const searchResults =
|
||||
scope() === "episode"
|
||||
? await searchEpisodes(q, sourceIds, sources, {
|
||||
cacheTtl: CACHE_TTL,
|
||||
})
|
||||
: await searchPodcasts(q, sourceIds, sources, {
|
||||
cacheTtl: CACHE_TTL,
|
||||
});
|
||||
|
||||
setResults(applySubscribedStatus(searchResults));
|
||||
} catch (e) {
|
||||
@@ -187,6 +221,7 @@ export function createSearchStore() {
|
||||
error,
|
||||
history,
|
||||
selectedSources,
|
||||
scope,
|
||||
|
||||
// Actions
|
||||
search,
|
||||
@@ -195,6 +230,7 @@ export function createSearchStore() {
|
||||
clearHistory,
|
||||
removeFromHistory,
|
||||
setSelectedSources,
|
||||
setScope,
|
||||
markSubscribed,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user