Mirrors the Feed tab's '[Fetch More]' row inside a drilled show's episode list (My Shows depth 1): shows only while the show's cache holds episodes beyond its loaded window, and advances just that show's window by 50 on Enter (or automatically at the bottom in auto mode). Same fetchMoreMode preference drives both behaviors. Adds a store-contract test for the per-feed pagination path.
477 lines
17 KiB
TypeScript
477 lines
17 KiB
TypeScript
/**
|
||
* 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: () => (
|
||
<NumberInputEditor
|
||
label="Auto Download Count"
|
||
value={() => 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: manual\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 ?? "manual");
|
||
const next = modes[(idx + dir + modes.length) % modes.length];
|
||
app.updatePreferences({ fetchMoreMode: next });
|
||
},
|
||
},
|
||
];
|
||
|
||
// 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: () => <WhitelistEditor />,
|
||
});
|
||
}
|
||
|
||
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<string | null>(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 (
|
||
<box flexDirection="column" padding={1} gap={1}>
|
||
<text fg={theme.text}>
|
||
<strong>{props.label}</strong>
|
||
</text>
|
||
<box flexDirection="row" gap={1} alignItems="center">
|
||
<text fg={theme.textMuted}>Episodes per show:</text>
|
||
<input
|
||
ref={ref}
|
||
value={draft()}
|
||
onInput={(v) => {
|
||
setDraft(v);
|
||
setError(null);
|
||
}}
|
||
onSubmit={submit}
|
||
focused
|
||
width={8}
|
||
textColor={theme.text}
|
||
focusedTextColor={theme.accent}
|
||
/>
|
||
</box>
|
||
<Show when={error()}>
|
||
<text fg={theme.error}>{error()}</text>
|
||
</Show>
|
||
<text fg={theme.muted ?? theme.textMuted}>
|
||
Type a number, Enter to apply · Esc to browse (j/k ±1) · h back
|
||
</text>
|
||
</box>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<box flexDirection="column" padding={1} gap={1}>
|
||
<text fg={theme.text}>
|
||
<strong>Auto Download Whitelist</strong>
|
||
</text>
|
||
<box flexDirection="row" gap={1} alignItems="center">
|
||
<text fg={theme.textMuted}>Search:</text>
|
||
<input
|
||
ref={inputRef}
|
||
value={wlQuery()}
|
||
onInput={setWlQuery}
|
||
focused={nav.inputFocused()}
|
||
placeholder="Type to filter shows…"
|
||
width={30}
|
||
textColor={theme.text}
|
||
focusedTextColor={theme.accent}
|
||
/>
|
||
</box>
|
||
<Show when={wlSuggestions().length === 0}>
|
||
<text fg={theme.muted ?? theme.textMuted}>
|
||
No subscribed shows match.
|
||
</text>
|
||
</Show>
|
||
<For each={wlSuggestions()}>
|
||
{(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 (
|
||
<box
|
||
ref={ref}
|
||
flexDirection="row"
|
||
gap={1}
|
||
paddingRight={1}
|
||
backgroundColor={bg()}
|
||
onMouseDown={() => {
|
||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||
setWlCursor(index());
|
||
// Click toggles membership directly (works even
|
||
// while typing, where Space is input text).
|
||
wlToggle(feed.id);
|
||
}}
|
||
>
|
||
<text fg={fg()}>{focused() ? marker() : " "}</text>
|
||
<text fg={fg()}>{inList(feed.id) ? "●" : "○"}</text>
|
||
<text fg={fg()}>
|
||
{feed.customName || feed.podcast.title}
|
||
</text>
|
||
</box>
|
||
);
|
||
}}
|
||
</For>
|
||
<text fg={theme.muted ?? theme.textMuted}>
|
||
Type to search · Esc to browse · j/k move · Space toggles · s to
|
||
type · h back
|
||
</text>
|
||
</box>
|
||
);
|
||
}
|