Files
PodTui/src/pages/Settings/SettingsPage.tsx
Michael Freno d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz
mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode
pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled
the whole remote file even when only minutes were listened to.

- audio-pcm-cache: sliding window around the playback position — the
  decode head caps at maxAheadSec (600s) ahead of the cursor, segments
  older than keepBehindSec (300s) are pruned, and the tail refills as
  playback advances. Steady state ~40 MB regardless of episode length;
  a backward seek past the window restarts a segment there (the existing
  seek-hole mechanism, no new failure mode).
- feed: cap the full-parse episode cache at 1000 episodes/feed so
  archive-heavy subscriptions can't pin their entire history in RAM;
  the visible list stays bounded by the user's cache preference and
  fetch-more keeps working within the ceiling.
- tests: pin the new head-cap and prune contracts (8/8 in
  audio-pcm-cache.test.ts; full suite 193 pass).

Also includes the in-flight cleanup/refactor pass (cover-art resolve
helper, page and comment tightening, ESLint config removal).
2026-08-12 21:02:19 -04:00

547 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SettingsPage — yazi depth-stack settings.
*
* depth 0 — sections list (Sync / Sources / Preferences / Visualizer / ...)
* depth 1 — the focused section's items as a navigable list
* depth 2 — per-item editor (for editor-kind items) or value adjuster
*
* Renders entirely through `<PaneRow>` (parent | current | preview):
* parent = previous depth's list (sections at depth 1, items at depth 2);
* blank placeholder at depth 0 (1/5 slot kept).
* current = the current-depth list (or editor at depth 2); the only
* focusable column.
* preview = help/preview text for the hovered item in current.
*
* All movement comes from the Shell router over `nav.action` (j/k move,
* Enter/l drill, h back). Panels no longer register their own useKeyboard —
* that was the root cause of the old right-pane key conflicts.
*/
import { For, Show, onMount, onCleanup, createMemo } from "solid-js";
import { rgbToHex, type RGBA } from "@opentui/core";
import { useTheme, type ThemeResolved } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel";
import { useVisualizerItems } from "./VisualizerSettings";
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
import { useSourceItems } from "./SourceManager";
import { useDownloadItems } from "./DownloadManager";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const SettingsPaneCount = 1;
const SECTIONS: SettingsSectionDef[] = [
{
id: 0,
label: "Sync",
description: "Import/export subscriptions and sync status.",
icon: NF_ICONS.sync,
},
{
id: 1,
label: "Sources",
description: "Podcast search/RSS sources — add, enable, remove.",
icon: NF_ICONS.sources,
},
{
id: 2,
label: "Preferences",
description: "Theme, font, playback speed, explicit/auto-download.",
icon: NF_ICONS.preferences,
},
{
id: 3,
label: "Visualizer",
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
icon: NF_ICONS.visualizer,
},
{
id: 4,
label: "Downloads",
description: "Manage downloaded episodes — delete by show or individually.",
icon: NF_ICONS.downloads,
},
];
// Static: detection never changes mid-session. Module-level because the Row
// component below (a sibling module function) needs it too.
const nerd = supportsNerdFonts();
/** Resolve the items for a section id at render time. */
function sectionItems(sectionId: number): SettingItem[] {
switch (sectionId) {
case 0:
return useSyncItems();
case 1:
return useSourceItems();
case 2:
return usePreferencesItems();
case 3:
return useVisualizerItems();
case 4:
return useDownloadItems();
default:
return [];
}
}
export function SettingsPage() {
const { theme } = useTheme();
const nav = useNavigation();
const stack = nav.depthStack;
const depth = nav.currentDepth;
// ── depth 0: sections ────────────────────────────────────────────────────
const focusedSectionIdx = () =>
Math.min(nav.depthFocus(0), SECTIONS.length - 1);
const focusedSection = () => SECTIONS[focusedSectionIdx()] ?? SECTIONS[0];
// ── depth ≥1: section items (resolved from the section id stored in the
// depth-0 frame's ctx). The depth-1 frame kind is "settings:<id>". ────
const sectionForDepth1 = (): SettingsSectionDef | undefined => {
const f = stack()[1];
if (!f) return undefined;
const id = Number(f.ctx ?? "0");
return SECTIONS[id];
};
const items = createMemo<SettingItem[]>(() => {
const sec = sectionForDepth1();
if (!sec) return [];
return sectionItems(sec.id);
});
const focusedItemIdx = () =>
items().length === 0 ? 0 : Math.min(nav.depthFocus(1), items().length - 1);
const focusedItem = (): SettingItem | undefined => items()[focusedItemIdx()];
// ── depth 2: the editor item (resolved from depth-1 frame ctx + item id) ─
const editorItem = (): SettingItem | undefined => {
const f1 = stack()[1];
const f2 = stack()[2];
if (!f1 || !f2) return undefined;
const secId = Number(f1.ctx ?? "0");
const list = sectionItems(secId);
return list.find((it) => it.id === f2.ctx);
};
// ── drill / open dispatch ───────────────────────────────────────────────
function open() {
const d = depth();
if (d === 0) {
const id = focusedSection().id;
nav.pushDepth({
kind: `settings:${id}`,
ctx: String(id),
focus: 0,
});
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (d === 1) {
const it = focusedItem();
if (!it) return;
switch (it.kind) {
case "toggle":
it.toggle?.();
return;
case "action":
it.run?.();
return;
case "info":
return;
case "editor":
case "number":
case "select":
nav.pushDepth({
kind: `settings:item:${it.id}`,
ctx: it.id,
focus: 0,
});
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
}
if (d === 2) {
// in an editor: Enter adjusts/cycles a number/select forward, toggles
const it = editorItem();
if (!it) return;
if (it.kind === "number" || it.kind === "select") it.cycle?.(1);
else if (it.kind === "toggle") it.toggle?.();
return;
}
}
// ── movement (j/k etc.) routed by the Shell over nav.action ───────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, len()),
"goto-bottom": () => nav.gotoIndex(len() - 1, len()),
open: () => open(),
};
function len(): number {
const d = depth();
if (d === 0) return SECTIONS.length;
if (d === 1) return items().length;
return 0; // depth 2 editor: no list length; j/k cycles instead
}
function step(delta: number) {
const d = depth();
if (d === 2) {
const it = editorItem();
if (it?.kind === "number" || it?.kind === "select")
it.cycle?.(delta as -1 | 1);
return;
}
nav.move(delta, len());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
const handler = PAGE_ACTIONS[data.action];
if (handler) handler();
};
onMount(() => {
on("nav.action", onAction);
// keep a resolver so visual-mode range selection grows by section/item id
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
const d = depth();
if (d === 0) return SECTIONS[i]?.id.toString();
if (d === 1) return items()[i]?.id;
return undefined;
});
});
onCleanup(() => off("nav.action", onAction));
// when leaving a sync editor (h to pop), close any open dialog overlay
onCleanup(() => closeSyncEditor());
// ── render helpers ───────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Whether the currently-focused settings row is the Theme select — the
// only item whose Detail pane carries a color breakdown below the help text.
const isThemeItem = () => {
const d = depth();
if (d === 1) return focusedItem()?.id === "theme";
if (d === 2) return editorItem()?.id === "theme";
return false;
};
// preview text for the right column
const previewText = createMemo<string>(() => {
const d = depth();
if (d === 0) {
return `${focusedSection().label}\n\n${focusedSection().description}\n\nDrill in (Enter/l) to open this section's settings.`;
}
if (d === 1) {
const it = focusedItem();
return it?.help() ?? "No item.";
}
// editor: same help, plus note
const it = editorItem();
return it
? `${it.help()}\n\n— Editor —\nj/k adjust · h back`
: "No editor.";
});
// ── column label ───────────────────────────────────────────────────────────
const currentLabel = () => {
const d = depth();
if (d === 0) return "Settings";
if (d === 1) return sectionForDepth1()?.label ?? "Items";
return editorItem()?.label ?? "Editor";
};
// ── parent pane: previous-depth list (blank at depth 0) ────────────────
// Sibling <Show> blocks per depth (mirrors the preview pane) so Solid
// mounts every branch once and toggles children on depth change — the
// known-good opentui disposal pattern. A ternary returning different
// roots leaves subtree orphaned on swap; the trick is a STABLE fragment
// root whose inner <Show> children swap instead.
const parentContent = () => (
<>
<Show when={depth() === 0}>
{/* app root: the tab list as the parent (muted) at the lowest depth */}
<TabListPane muted />
</Show>
<Show when={depth() === 1}>
{/* previous depth = sections list (read-only) */}
<For each={SECTIONS}>
{(section, index) => (
<Row
label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()}
active={false}
/>
)}
</For>
</Show>
<Show when={depth() === 2}>
{/* previous depth = items list (read-only) */}
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label} ${it.display()}`}
focused={index() === focusedItemIdx()}
active={false}
/>
)}
</For>
</Show>
</>
);
// ── current pane: current-depth list (or editor at depth 2) ───────────────
const currentContent = () => (
<>
<Show when={depth() === 0}>
<For each={SECTIONS}>
{(section, index) => (
<Row
label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()}
active={isActive()}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
</Show>
<Show when={depth() === 1}>
<box flexDirection="column">
<For each={items()}>
{(it, index) => (
<Row
label={`${it.label}`}
value={it.display()}
focused={index() === focusedItemIdx()}
active={isActive()}
hint={hintFor(it)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
/>
)}
</For>
<Show when={items().length === 0}>
<box padding={1}>
<text fg={theme.muted ?? theme.textMuted}>(No items.)</text>
</box>
</Show>
</box>
</Show>
<Show when={depth() === 2}>
{/* depth 2: editor */}
<Show
when={editorItem()?.renderEditor}
fallback={<GenericEditor item={editorItem()!} />}
>
{editorItem()!.renderEditor!()}
</Show>
</Show>
</>
);
// ── preview pane ──────────────────────────────────────────────────────────
const previewContent = () => (
<box padding={1} flexDirection="column">
{/* Keep everything on a stable root so Solid re-resolves the swap
between plain help text and the theme breakdown on focus move. */}
<Show when={isThemeItem()} fallback={<MultiLine text={previewText()} />}>
<MultiLine text={previewText()} />
<ThemeBreakdown />
</Show>
</box>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
currentLabel={currentLabel}
focused={isActive}
/>
);
}
/** Per-kind hint glyph shown at the right of an item row. */
function hintFor(it: SettingItem): string {
switch (it.kind) {
case "toggle":
return "⏻";
case "number":
case "select":
return "±";
case "action":
return "↵";
case "editor":
return "→";
case "info":
return "·";
}
}
function Row(props: {
label: string;
value?: string;
focused: boolean;
active: boolean;
hint?: string;
icon?: string;
onMouseDown?: () => void;
}) {
const { theme } = useTheme();
const bg = () =>
props.focused && props.active
? theme.primary
: props.focused
? theme.border
: undefined;
const fg = () =>
props.focused && props.active
? theme.surface
: props.focused
? theme.selectedListItemText ?? theme.text
: theme.text;
const ref = useScrollIntoView(() => props.focused);
const marker = useSelectionMarker();
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.focused ? marker() : " "}</text>
{props.icon && nerd && <text fg={fg()}>{props.icon}</text>}
<text fg={fg()}>{props.label}</text>
<Show when={props.value}>
<box flexGrow={1} />
<text fg={props.focused ? fg() : theme.textMuted}>{props.value}</text>
</Show>
<Show when={props.hint}>
<text fg={theme.textMuted}>{props.hint}</text>
</Show>
</box>
);
}
/** Center editor for number/select/toggle items without a bespoke renderer. */
function GenericEditor(props: { item: SettingItem }) {
const { theme } = useTheme();
const it = props.item;
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>{it.label}</strong>
</text>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textMuted}>Value:</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{it.display()}</text>
</box>
</box>
<Show when={it.kind === "number" || it.kind === "select"}>
<text fg={theme.muted ?? theme.textMuted}>
j/k to adjust · Enter to nudge forward · h to go back
</text>
</Show>
<Show when={it.kind === "toggle"}>
<text fg={theme.muted ?? theme.textMuted}>
Enter/Space to toggle · h to go back
</text>
</Show>
</box>
);
}
/** Curated theme color roles shown in the Theme breakdown. */
const THEME_ROLES: Array<{ key: keyof ThemeResolved; label: string }> = [
{ key: "primary", label: "Primary" },
{ key: "secondary", label: "Secondary" },
{ key: "accent", label: "Accent" },
{ key: "text", label: "Text" },
{ key: "textMuted", label: "Muted" },
{ key: "background", label: "Background" },
{ key: "surface", label: "Surface" },
{ key: "border", label: "Border" },
{ key: "error", label: "Error" },
{ key: "warning", label: "Warning" },
{ key: "success", label: "Success" },
{ key: "info", label: "Info" },
];
/** Color swatch breakdown (block <Label> (<HEX>)) of the resolved theme. */
function ThemeBreakdown() {
const { theme, selected } = useTheme();
return (
<box flexDirection="column" paddingTop={1} gap={1}>
<text fg={theme.accent}>Theme · {selected}</text>
<For each={THEME_ROLES}>
{(role) => {
const color = theme[role.key] as RGBA | undefined;
return (
<box flexDirection="row" gap={1} alignItems="center">
<box backgroundColor={color}>
<text>{" "}</text>
</box>
<text fg={theme.text}>{role.label}</text>
<box flexGrow={1} />
<text fg={theme.textMuted}>
{color ? rgbToHex(color).toUpperCase() : "n/a"}
</text>
</box>
);
}}
</For>
</box>
);
}
/** Renders a string with `\n` newlines as stacked <text> lines. */
function MultiLine(props: { text: string }) {
const lines = () => props.text.split("\n");
const { theme } = useTheme();
return (
<For each={lines()}>
{(line, i) => (
<text fg={i() === 0 ? theme.accent : theme.textMuted}>
{line || " "}
</text>
)}
</For>
);
}