/** * PreferencesPanel — exposes theme/font/speed/explicit/auto-download as * SettingItems for the yazi depth-stack. No own useKeyboard; all movement is * driven by the Shell router via nav.action. * * Auto-download (global setting, see stores/feed.ts runAutoDownload): * • Auto Download — master toggle (default: off) * • Auto Download Count — X most recent episodes per show (default: 2, * any positive integer — type it in the editor) * • Auto Download Scope — which shows: all / none / whitelist (default: all) * • Auto Download Whitelist — shown only when scope is "whitelist": search * field over subscribed shows; suggestions toggle * in/out with Space (j/k to move, Esc to browse). */ import { createSignal, Show, For, onMount, onCleanup } from "solid-js"; import { RenderableEvents, type InputRenderable } from "@opentui/core"; import { useAppStore } from "@/stores/app"; import { useFeedStore } from "@/stores/feed"; import { useTheme } from "@/context/ThemeContext"; import { useInputFocusNav } from "@/hooks/useInputFocusNav"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; import { NavMode, useNavigation, DEPTH_CENTER_PANE, type PaneId, } from "@/context/NavigationContext"; import { on } from "@/utils/event-bus"; import type { KeybindActionName } from "@/context/KeybindContext"; import { TABS } from "@/utils/navigation"; import type { AutoDownloadScope, ThemeName } from "@/types/settings"; import type { Feed } from "@/types/feed"; import type { SettingItem } from "./types"; const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ { value: "system", label: "System" }, { value: "catppuccin", label: "Catppuccin" }, { value: "gruvbox", label: "Gruvbox" }, { value: "tokyo", label: "Tokyo" }, { value: "nord", label: "Nord" }, { value: "custom", label: "Custom" }, ]; const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [ { value: "all", label: "All" }, { value: "none", label: "None" }, { value: "whitelist", label: "Whitelist" }, ]; function scopeLabel(scope: AutoDownloadScope): string { return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope; } export function usePreferencesItems(): SettingItem[] { const app = useAppStore(); const feedStore = useFeedStore(); const settings = () => app.state().settings; const prefs = () => app.state().preferences; const items: SettingItem[] = [ { id: "theme", label: "Theme", kind: "select", display: () => THEME_LABELS.find((t) => t.value === settings().theme)?.label ?? settings().theme, help: () => `Color theme.\nType: select\nDefault: system\nCurrent: ${settings().theme}\nCycle with j/k; Enter to apply.`, cycle: (dir) => { const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme); const next = (idx + dir + THEME_LABELS.length) % THEME_LABELS.length; app.setTheme(THEME_LABELS[next].value); }, }, { id: "transparentBackground", label: "Transparent Background", kind: "toggle", display: () => settings().transparentBackground ? "On" : "Off", help: () => `Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`, toggle: () => app.updateSettings({ transparentBackground: !settings().transparentBackground, }), }, { id: "showSelectionMarker", label: "Selection Marker", kind: "toggle", display: () => (settings().showSelectionMarker ? "On" : "Off"), help: () => `Show the ❯ marker on the focused row of every list (tabs, shows, episodes, results).\nType: toggle\nDefault: off\nCurrent: ${settings().showSelectionMarker ? "On" : "Off"}\nSpace/Enter to toggle.`, toggle: () => app.updateSettings({ showSelectionMarker: !settings().showSelectionMarker, }), }, { id: "fontSize", label: "Font Size", kind: "number", display: () => `${settings().fontSize}px`, help: () => `Terminal font size in pixels.\nType: number (10–20)\nDefault: 14\nCurrent: ${settings().fontSize}\nj/k to −/+1px.`, cycle: (dir) => { const next = Math.min(20, Math.max(10, settings().fontSize + dir)); app.updateSettings({ fontSize: next }); }, }, { id: "playbackSpeed", label: "Playback Speed", kind: "number", display: () => `${settings().playbackSpeed}x`, help: () => `Default audio playback speed.\nType: number (0.5–2.0)\nDefault: 1.0\nCurrent: ${settings().playbackSpeed}\nj/k to −/+0.1.`, cycle: (dir) => { const next = Math.min( 2, Math.max(0.5, settings().playbackSpeed + dir * 0.1), ); app.updateSettings({ playbackSpeed: Number(next.toFixed(1)) }); }, }, { id: "showExplicit", label: "Show Explicit", kind: "toggle", display: () => (prefs().showExplicit ? "On" : "Off"), help: () => `Whether to list explicit episodes.\nType: toggle\nDefault: true\nCurrent: ${prefs().showExplicit}\nSpace/Enter to toggle.`, toggle: () => app.updatePreferences({ showExplicit: !prefs().showExplicit, }), }, { id: "autoDownload", label: "Auto Download", kind: "toggle", display: () => (prefs().autoDownload ? "On" : "Off"), help: () => `Download the ${prefs().autoDownloadCount} most recent episodes of your shows automatically (see Count/Scope below).\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload ? "On" : "Off"}\nSpace/Enter to toggle.`, toggle: () => { app.updatePreferences({ autoDownload: !prefs().autoDownload }); feedStore.runAutoDownload(); }, }, { id: "autoDownloadCount", label: "Auto Download Count", kind: "number", display: () => `${prefs().autoDownloadCount} per show`, help: () => `How many of the most recent episodes to auto-download per in-scope show.\nType: number (any positive integer)\nDefault: 2\nCurrent: ${prefs().autoDownloadCount}\nj/k to −/+1 · Enter to type a value.`, cycle: (dir) => { const next = Math.max(1, prefs().autoDownloadCount + dir); app.updatePreferences({ autoDownloadCount: next }); feedStore.runAutoDownload(); }, renderEditor: () => ( prefs().autoDownloadCount} commit={(n) => { app.updatePreferences({ autoDownloadCount: n }); feedStore.runAutoDownload(); }} /> ), }, { id: "autoDownloadScope", label: "Auto Download Scope", kind: "select", display: () => scopeLabel(prefs().autoDownloadScope), help: () => `Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on the focused show; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`, cycle: (dir) => { const idx = SCOPE_LABELS.findIndex( (s) => s.value === prefs().autoDownloadScope, ); const next = SCOPE_LABELS[(idx + dir + SCOPE_LABELS.length) % SCOPE_LABELS.length] .value; app.updatePreferences({ autoDownloadScope: next }); feedStore.runAutoDownload(); }, }, { id: "autoJumpToPlayer", label: "Auto Jump to Player", kind: "toggle", display: () => (prefs().autoJumpToPlayer ? "On" : "Off"), help: () => `Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`, toggle: () => app.updatePreferences({ autoJumpToPlayer: !prefs().autoJumpToPlayer, }), }, { id: "fetchMore", label: "Fetch More", kind: "select", display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"), help: () => `How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`, cycle: (dir) => { const modes: Array<"manual" | "auto"> = ["manual", "auto"]; const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto"); const next = modes[(idx + dir + modes.length) % modes.length]; app.updatePreferences({ fetchMoreMode: next }); }, }, { id: "refreshInterval", label: "Feed Refresh Interval", kind: "number", display: () => `${prefs().refreshIntervalMinutes} min`, help: () => `How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1–120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to −/+5 · Enter to type a value.`, cycle: (dir) => { const next = Math.min( 120, Math.max(1, prefs().refreshIntervalMinutes + dir * 5), ); app.updatePreferences({ refreshIntervalMinutes: next }); }, renderEditor: () => ( prefs().refreshIntervalMinutes} commit={(n) => { app.updatePreferences({ refreshIntervalMinutes: Math.min(120, n), }); }} /> ), }, ]; // Whitelist management only appears while scope is set to "whitelist". if (prefs().autoDownloadScope === "whitelist") { items.push({ id: "autoDownloadWhitelist", label: "Auto Download Whitelist", kind: "editor", display: () => `${prefs().autoDownloadWhitelist.length} shows`, help: () => `Shows included in auto-download (scope: whitelist).\nSearch your subscribed shows; suggestions toggle in/out with Space.\nType: editor\nCurrent: ${prefs().autoDownloadWhitelist.length} shows`, renderEditor: () => , }); } return items; } // ── Number editor ──────────────────────────────────────────────────────────── // Lets the user type any positive integer (Enter commits; Esc defocuses and // j/k ±1 cycling takes over — SettingsPage's depth-2 step handler). function NumberInputEditor(props: { label: string; value: () => number; commit: (n: number) => void; }) { const { theme } = useTheme(); const ref = useInputFocusNav(); const [draft, setDraft] = createSignal(String(props.value())); const [error, setError] = createSignal(null); const submit = () => { const n = Number(draft().trim()); if (!Number.isInteger(n) || n < 1) { setError("Enter a whole number ≥ 1"); return; } props.commit(n); setError(null); }; return ( {props.label} Episodes per show: { setDraft(v); setError(null); }} onSubmit={submit} focused width={8} textColor={theme.text} focusedTextColor={theme.accent} /> {error()} Type a number, Enter to apply · Esc to browse (j/k ±1) · h back ); } // ── Whitelist editor ───────────────────────────────────────────────────────── // Search field over subscribed shows + a navigable suggestion list. Space // (toggle-select) toggles the focused show in/out of the whitelist; Enter // does the same. While the input is focused, keys type; Esc (handled in the // Shell) defocuses so j/k move the list. // // Transient UI state lives at module level so preference updates (which // rebuild the item list) never reset the search or yank focus back into the // input mid-browse. // // The nav.action listener is registered ONCE at module level, not per // component instance: toggling a show updates preferences, which remounts // the editor (SettingsPage re-resolves the item's renderEditor), and // re-registering the listener via onMount/onCleanup during a bus emit // mutates the handler set mid-iteration — the event bus then re-delivers to // the fresh listener forever. A single stable listener guarded by an active // flag sidesteps that entirely. const [wlQuery, setWlQuery] = createSignal(""); const [wlCursor, setWlCursor] = createSignal(0); const [wlTyping, setWlTyping] = createSignal(true); let wlEditorActive = false; // Indirection for refocusing the search input from the module-level nav.action // listener (which cannot call useNavigation — that needs the provider). let wlFocusInput: (() => void) | null = null; function wlSuggestions(): Feed[] { const q = wlQuery().trim().toLowerCase(); const all = useFeedStore().getFilteredFeeds(); if (!q) return all; return all.filter((f) => (f.customName || f.podcast.title).toLowerCase().includes(q), ); } /** Keep the cursor inside the (possibly shrinking) suggestion list. */ function wlCursorClamped(): number { return Math.min(wlCursor(), Math.max(wlSuggestions().length - 1, 0)); } function wlToggle(feedId: string): void { const app = useAppStore(); const cur = app.state().preferences.autoDownloadWhitelist ?? []; const next = cur.includes(feedId) ? cur.filter((id) => id !== feedId) : [...cur, feedId]; app.updatePreferences({ autoDownloadWhitelist: next }); useFeedStore().runAutoDownload(); } const wlOnAction = (data: { action: KeybindActionName; tab: TABS; pane: PaneId; mode: NavMode; }) => { // Fire at most once per dispatch: the editor is only ever open inside the // Settings tab's depth-2 pane, so scope on tab + pane and gate on the // mount flag (which flips during remounts without re-registering). if (!wlEditorActive) return; if (data.tab !== TABS.SETTINGS) return; if (data.pane !== DEPTH_CENTER_PANE) return; const list = wlSuggestions(); if (list.length === 0) return; switch (data.action) { case "move-down": setWlCursor((c) => Math.min(c + 1, list.length - 1)); break; case "move-up": setWlCursor((c) => Math.max(c - 1, 0)); break; case "toggle-select": case "open": wlToggle(list[wlCursorClamped()].id); break; case "search": // `s` while browsing re-enters typing mode (mirrors SearchPage). wlFocusInput?.(); break; } }; on("nav.action", wlOnAction); function WhitelistEditor() { const { theme } = useTheme(); const nav = useNavigation(); const feedStore = useFeedStore(); const app = useAppStore(); const whitelist = () => app.state().preferences.autoDownloadWhitelist ?? []; const inList = (feedId: string) => whitelist().includes(feedId); onMount(() => { wlEditorActive = true; // Restore the last typing/browsing mode across the remounts that // preference updates trigger. nav.inputFocused() drives the input's // focused prop (deterministic Esc-to-blur, same as SearchPage), so // keep the store in sync with the persisted module mode. nav.setInputFocused(wlTyping()); wlFocusInput = () => nav.setInputFocused(true); onCleanup(() => { wlEditorActive = false; nav.setInputFocused(false); wlFocusInput = null; }); }); const focusNavRef = useInputFocusNav(); const inputRef = (el: InputRenderable | null | undefined) => { focusNavRef(el); if (el) { // Sync the persisted mode with real focus changes so remounts // (e.g. after a toggle) restore the right state. el.on(RenderableEvents.FOCUSED, () => setWlTyping(true)); el.on(RenderableEvents.BLURRED, () => setWlTyping(false)); } }; return ( Auto Download Whitelist Search: No subscribed shows match. {(feed, index) => { // While the input is focused (typing), no row shows the // accent highlight or `❯` — only the input is "in focus". const focused = () => !nav.inputFocused() && index() === wlCursorClamped(); const ref = useScrollIntoView(focused); const marker = useSelectionMarker(); const bg = () => (focused() ? theme.primary : undefined); const fg = () => (focused() ? theme.surface : theme.text); return ( { nav.setActivePane(DEPTH_CENTER_PANE); setWlCursor(index()); // Click toggles membership directly (works even // while typing, where Space is input text). wlToggle(feed.id); }} > {focused() ? marker() : " "} {inList(feed.id) ? "●" : "○"} {feed.customName || feed.podcast.title} ); }} Type to search · Esc to browse · j/k move · Space toggles · s to type · h back ); }