503 lines
18 KiB
TypeScript
503 lines
18 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: 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: () => (
|
||
<NumberInputEditor
|
||
label="Feed Refresh Interval (minutes)"
|
||
value={() => 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: () => <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>
|
||
);
|
||
}
|