diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx
index 19641b8..c436db4 100644
--- a/src/components/Shell.tsx
+++ b/src/components/Shell.tsx
@@ -12,7 +12,7 @@
*/
import { createSignal, Show, For } from "solid-js";
-import { useKeyboard } from "@opentui/solid";
+import { useKeyboard, useRenderer } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext";
@@ -43,6 +43,7 @@ export function Shell() {
const nav = useNavigation();
const k = useKeybinds();
const audio = useAudio();
+ const renderer = useRenderer();
const audioNav = useAudioNavStore();
const toast = useToast();
const feedStore = useFeedStore();
@@ -205,6 +206,11 @@ export function Shell() {
if (evt.name === "escape") {
evt.preventDefault();
nav.setInputFocused(false);
+ // Actually blur the focused renderable too — setting the flag alone
+ // leaves the opentui input owning keys, so nav keys would still be
+ // typed into it. Blurring fires our useInputFocusNav BLURRED handler
+ // (and re-blurs the SearchPage input via its `focused` prop).
+ renderer.currentFocusedRenderable?.blur();
}
return;
}
diff --git a/src/hooks/useInputFocusNav.ts b/src/hooks/useInputFocusNav.ts
new file mode 100644
index 0000000..dc17030
--- /dev/null
+++ b/src/hooks/useInputFocusNav.ts
@@ -0,0 +1,65 @@
+/**
+ * useInputFocusNav — returns a `ref` callback for an `` (or any
+ * focusable renderable) that holds the navigation store's `inputFocused`
+ * flag true while the renderable has focus.
+ *
+ * Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
+ * whatever is focused only when `nav.inputFocused()` is true; otherwise it
+ * dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
+ * depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
+ * flag, so typing into them *also* fired the navigation keybinds. Wiring the
+ * flag to each input's real focus/blur state fixes that.
+ *
+ * A module-level counter guards the blur→focus ordering gap that occurs when
+ * tabbing between two inputs in the same form (the old input blurs before the
+ * new one focuses) so the flag never flickers off mid-handoff.
+ */
+
+import { onCleanup } from "solid-js";
+import { RenderableEvents } from "@opentui/core";
+import { useNavigation } from "@/context/NavigationContext";
+
+// Inputs (managed by this hook) currently holding focus.
+let focusedCount = 0;
+
+export function useInputFocusNav() {
+ const nav = useNavigation();
+ let current: any | undefined;
+
+ const onFocused = () => {
+ focusedCount++;
+ nav.setInputFocused(true);
+ };
+ const onBlurred = () => {
+ focusedCount = Math.max(0, focusedCount - 1);
+ if (focusedCount === 0) nav.setInputFocused(false);
+ };
+
+ const detach = (el: any) => {
+ el.off(RenderableEvents.FOCUSED, onFocused);
+ el.off(RenderableEvents.BLURRED, onBlurred);
+ // Treat a focused element being torn down as a blur so the counter
+ // doesn't leak and leave inputFocused stuck on.
+ if (el.focused) onBlurred();
+ };
+
+ const ref = (el: any) => {
+ if (current && current !== el) detach(current);
+ current = el;
+ if (el) {
+ el.on(RenderableEvents.FOCUSED, onFocused);
+ el.on(RenderableEvents.BLURRED, onBlurred);
+ // If the renderable is already focused when attached, count it.
+ if (el.focused) onFocused();
+ }
+ };
+
+ onCleanup(() => {
+ if (current) {
+ detach(current);
+ current = undefined;
+ }
+ });
+
+ return ref;
+}
diff --git a/src/pages/Settings/ExportDialog.tsx b/src/pages/Settings/ExportDialog.tsx
index 809d10d..6d28cf4 100644
--- a/src/pages/Settings/ExportDialog.tsx
+++ b/src/pages/Settings/ExportDialog.tsx
@@ -1,38 +1,55 @@
const createSignal = (value: T): [() => T, (next: T) => void] => {
- let current = value
- return [() => current, (next) => {
- current = next
- }]
-}
+ let current = value;
+ return [
+ () => current,
+ (next) => {
+ current = next;
+ },
+ ];
+};
-import { SyncStatus } from "./SyncStatus"
-import { useTheme } from "@/context/ThemeContext"
+import { SyncStatus } from "./SyncStatus";
+import { useTheme } from "@/context/ThemeContext";
+import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export function ExportDialog() {
- const { theme } = useTheme();
- const filename = createSignal("podcast-sync.json")
- const format = createSignal<"json" | "xml">("json")
+ const { theme } = useTheme();
+ const filename = createSignal("podcast-sync.json");
+ const format = createSignal<"json" | "xml">("json");
+ // Yield navigation keybinds to the Shell router while the input is focused.
+ const filenameRef = useInputFocusNav();
- return (
-
-
- File:
-
-
-
- Format:
- format[1](index === 0 ? "json" : "xml")}
- />
-
-
- Export {format[0]()} to {filename[0]()}
-
-
-
- )
+ return (
+
+
+ File:
+
+
+
+ Format:
+ format[1](index === 0 ? "json" : "xml")}
+ />
+
+
+
+ Export {format[0]()} to {filename[0]()}
+
+
+
+
+ );
}
diff --git a/src/pages/Settings/FilePicker.tsx b/src/pages/Settings/FilePicker.tsx
index 5caa90d..b126a5c 100644
--- a/src/pages/Settings/FilePicker.tsx
+++ b/src/pages/Settings/FilePicker.tsx
@@ -1,24 +1,28 @@
import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext";
+import { useInputFocusNav } from "@/hooks/useInputFocusNav";
type FilePickerProps = {
- value: string;
- onChange: (value: string) => void;
+ value: string;
+ onChange: (value: string) => void;
};
export function FilePicker(props: FilePickerProps) {
- const { theme } = useTheme();
- const format = detectFormat(props.value);
+ const { theme } = useTheme();
+ // Yield navigation keybinds to the Shell router while the input is focused.
+ const inputRef = useInputFocusNav();
+ const format = detectFormat(props.value);
- return (
-
-
- Format: {format}
-
- );
+ return (
+
+
+ Format: {format}
+
+ );
}
diff --git a/src/pages/Settings/SourceManager.tsx b/src/pages/Settings/SourceManager.tsx
index 948ff69..1cc91be 100644
--- a/src/pages/Settings/SourceManager.tsx
+++ b/src/pages/Settings/SourceManager.tsx
@@ -14,6 +14,7 @@
import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext";
+import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types";
@@ -61,6 +62,9 @@ function AddSourceForm() {
const [name, setName] = createSignal("");
const [url, setUrl] = createSignal("");
const [error, setError] = createSignal(null);
+ // Yield navigation keybinds to the Shell router while either input is focused.
+ const nameRef = useInputFocusNav();
+ const urlRef = useInputFocusNav();
const submit = () => {
const u = url().trim();
@@ -94,6 +98,7 @@ function AddSourceForm() {
Name:
URL:
{
setUrl(v);
diff --git a/src/stores/search.ts b/src/stores/search.ts
index ddf5a9d..38dc862 100644
--- a/src/stores/search.ts
+++ b/src/stores/search.ts
@@ -4,7 +4,7 @@
*/
import { createSignal } from "solid-js";
-import { searchPodcasts } from "../utils/search";
+import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source";
@@ -83,6 +83,15 @@ export function createSearchStore() {
addToHistory(q);
try {
+ // A query that is a direct RSS feed URL (e.g. a private feed that
+ // isn't in any public directory) resolves to that feed directly,
+ // independent of enabled search sources.
+ const urlResults = await searchByFeedUrl(q);
+ if (urlResults.length > 0) {
+ setResults(applySubscribedStatus(urlResults));
+ return;
+ }
+
const sources = feedStore.sources();
const enabledSourceIds = sources
.filter((s) => s.enabled)
diff --git a/src/utils/search.ts b/src/utils/search.ts
index 6bea1fd..4783b12 100644
--- a/src/utils/search.ts
+++ b/src/utils/search.ts
@@ -1,4 +1,6 @@
import { searchSourceByType } from "./source-searcher";
+import { parseRSSFeed } from "../api/rss-parser";
+import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source";
type SearchCacheEntry = {
@@ -55,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => {
return Array.from(map.values());
};
+const FEED_URL_RE = /^https?:\/\/.+/i;
+
+/**
+ * If the query is a direct RSS feed URL (useful for private feeds that aren't
+ * in public directories), fetch and parse it into a single search result.
+ * Returns an empty array when the query is not a URL so normal search proceeds.
+ */
+export const searchByFeedUrl = async (
+ query: string,
+): Promise => {
+ const trimmed = query.trim();
+ if (!FEED_URL_RE.test(trimmed)) return [];
+
+ try {
+ const response = await fetch(trimmed, {
+ headers: {
+ "Accept-Encoding": "identity",
+ Accept: "application/rss+xml, application/xml, text/xml, */*",
+ },
+ });
+ if (!response.ok) return [];
+
+ const xml = await response.text();
+ const podcast = parseRSSFeed(xml, trimmed);
+
+ return [
+ {
+ sourceId: "direct-rss",
+ sourceName: "RSS Feed",
+ sourceType: SourceType.RSS,
+ // parseRSSFeed marks feeds subscribed; a search result should start
+ // unsubscribed so the store can flag it correctly if already added.
+ podcast: { ...podcast, isSubscribed: false },
+ score: 1,
+ },
+ ];
+ } catch {
+ return [];
+ }
+};
+
export const searchPodcasts = async (
query: string,
sourceIds: string[],