pre-ui-rearch
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* DiscoverPage — yazi-style 3-pane view.
|
||||
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||
*
|
||||
* pane 0 (parent) — category list (the "containers")
|
||||
* pane 1 (current) — podcast results for the focused category (landing pane)
|
||||
* pane 2 (preview) — detail of the focused podcast + subscribe action
|
||||
* depth 0 (current) — category list. Left pane empty at root.
|
||||
* depth 1 (current) — podcast results for the drilled category.
|
||||
* right (preview) — detail of the hovered item (category summary, or
|
||||
* podcast detail + subscribe action).
|
||||
*
|
||||
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
|
||||
* panes; j/k move within; Enter subscribes to the focused podcast; r refreshes.
|
||||
* Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering.
|
||||
* `l`/Enter drills in (category → results) or subscribes (on a podcast);
|
||||
* `h` pops back (or yields to the sidebar at depth 0). j/k move within the
|
||||
* current column. Moving through categories at depth 0 updates the store's
|
||||
* selected category so the preview follows.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -17,15 +19,15 @@ import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Podcast } from "@/types/podcast";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
export const DiscoverPaneCount = 3;
|
||||
export const DiscoverPaneCount = 1;
|
||||
|
||||
function DiscoverPage() {
|
||||
const discoverStore = useDiscoverStore();
|
||||
@@ -33,83 +35,71 @@ function DiscoverPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const CATS = PaneSlot.PARENT; // 0 — categories (parent)
|
||||
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane)
|
||||
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
const categories = () => DISCOVER_CATEGORIES;
|
||||
const podcasts = () => discoverStore.filteredPodcasts();
|
||||
|
||||
const focusedCategory = createMemo(() => {
|
||||
const list = categories();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
|
||||
});
|
||||
const focusedCatIdx = () =>
|
||||
categories().length === 0 ? 0 : Math.min(focus(0), categories().length - 1);
|
||||
const focusedCategory = createMemo(() => categories()[focusedCatIdx()]);
|
||||
|
||||
const focusedPodIdx = () =>
|
||||
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
||||
|
||||
const curLen = () =>
|
||||
depth() === 0 ? categories().length : podcasts().length;
|
||||
|
||||
// ── keep category + results focus in range ───────────────────────────────
|
||||
const ensureFocus = () => {
|
||||
const cl = categories();
|
||||
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length)
|
||||
nav.setFocusedIndex(CATS, cl.length - 1);
|
||||
const pl = podcasts();
|
||||
if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length)
|
||||
nav.setFocusedIndex(RESULTS, pl.length - 1);
|
||||
if (categories().length > 0 && focus(0) >= categories().length)
|
||||
nav.setDepthFocus(categories().length - 1, 0);
|
||||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
const focusedPodcast = createMemo(() => {
|
||||
const list = podcasts();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)];
|
||||
});
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by podcast id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => podcasts()[i]?.id,
|
||||
);
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => podcasts()[i]?.id,
|
||||
);
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return categories()[i]?.id;
|
||||
return podcasts()[i]?.id;
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
const handleSubscribe = (podcast: Podcast) => {
|
||||
discoverStore.toggleSubscription(podcast.id);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const c = focusedCategory();
|
||||
if (!c) return;
|
||||
discoverStore.setSelectedCategory(c.id);
|
||||
nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
const pod = focusedPodcast();
|
||||
if (pod) discoverStore.toggleSubscription(pod.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── nav.action handler ────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === CATS) {
|
||||
const c = focusedCategory();
|
||||
if (c) discoverStore.setSelectedCategory(c.id);
|
||||
nav.swipe(1, DiscoverPaneCount); // dive to results
|
||||
return;
|
||||
}
|
||||
if (p === RESULTS) {
|
||||
const pod = focusedPodcast();
|
||||
if (pod) handleSubscribe(pod);
|
||||
}
|
||||
},
|
||||
"toggle-select": (p) => {
|
||||
if (p === RESULTS) {
|
||||
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, curLen()),
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() >= 1) {
|
||||
const pod = focusedPodcast();
|
||||
if (pod) nav.toggleSelected(pod.id);
|
||||
}
|
||||
@@ -118,28 +108,23 @@ function DiscoverPage() {
|
||||
discoverStore.refresh().catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === CATS) return categories().length;
|
||||
if (pane === RESULTS) return podcasts().length;
|
||||
return 0;
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
if (pane === CATS) {
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
// keep the store's selected category synced with the focused row at depth 0
|
||||
if (depth() === 0) {
|
||||
const c = focusedCategory();
|
||||
if (c) discoverStore.setSelectedCategory(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
action: KeybindActionName;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
@@ -147,202 +132,265 @@ function DiscoverPage() {
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0 (parent, left): categories ───────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Categories</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(CATS)}
|
||||
border
|
||||
borderColor={border(CATS)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const selected = () =>
|
||||
cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Categories</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
selected() && !isActive(CATS)
|
||||
? theme.border
|
||||
: focusBg(index(), CATS)
|
||||
}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(CATS);
|
||||
nav.setFocusedIndex(CATS, index());
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), CATS)}>
|
||||
{index() === nav.focusedIndex(CATS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{cat.name}
|
||||
</text>
|
||||
<text fg={focusFg(index(), CATS)}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(CATS)
|
||||
? theme.surface
|
||||
: theme.accent
|
||||
}
|
||||
>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── pane 1 (current, center): results ───────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{focusedCategory()?.name ?? "Discover"} · {podcasts().length}
|
||||
{depth() === 0
|
||||
? "Categories"
|
||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(RESULTS)}
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(RESULTS)}
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), RESULTS)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(RESULTS);
|
||||
nav.setFocusedIndex(RESULTS, index());
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), RESULTS)}>
|
||||
{index() === nav.focusedIndex(RESULTS) ? "❯" : " "}
|
||||
{/* depth 0: categories */}
|
||||
<Show when={depth() === 0}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = focusedCatIdx();
|
||||
const selected = () =>
|
||||
cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: theme.success
|
||||
}
|
||||
>
|
||||
[+]
|
||||
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={podcast.author}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1: results */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No podcasts found. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = focusedPodIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text
|
||||
fg={index() === lf ? theme.surface : theme.success}
|
||||
>
|
||||
[+]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={podcast.author}>
|
||||
<text
|
||||
fg={index() === lf ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── right: preview ────────────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PREVIEW)}
|
||||
border
|
||||
borderColor={border(PREVIEW)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={focusedPodcast()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No podcast focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(pod) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{pod().title}</strong>
|
||||
</text>
|
||||
<Show when={pod().author}>
|
||||
<text fg={muted()}>by {pod().author}</text>
|
||||
</Show>
|
||||
<Show when={pod().isSubscribed}>
|
||||
<text fg={theme.success}>✓ Subscribed</text>
|
||||
</Show>
|
||||
<Show when={!pod().isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{pod().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<Show when={(pod().categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(pod().categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={pod().feedUrl}>
|
||||
<text fg={muted()}>Feed: {pod().feedUrl}</text>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(pod().lastUpdated)}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe h/l: panes r: refresh</text>
|
||||
</box>
|
||||
)}
|
||||
{/* depth 0 preview: hovered category */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={focusedCategory()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No category focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(cat) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{cat().name}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>
|
||||
{(cat() as any).description ??
|
||||
`Browse top podcasts in ${cat().name}.`}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered podcast + subscribe */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={focusedPodcast()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No podcast focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(pod) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{pod().title}</strong>
|
||||
</text>
|
||||
<Show when={pod().author}>
|
||||
<text fg={muted()}>by {pod().author}</text>
|
||||
</Show>
|
||||
<Show when={pod().isSubscribed}>
|
||||
<text fg={theme.success}>✓ Subscribed</text>
|
||||
</Show>
|
||||
<Show when={!pod().isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{pod().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<Show when={(pod().categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(pod().categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={pod().feedUrl}>
|
||||
<text fg={muted()}>Feed: {pod().feedUrl}</text>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(pod().lastUpdated)}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: subscribe · h: back · r: refresh
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* FeedPage — yazi-style 3-pane view of all episodes across subscribed shows.
|
||||
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
||||
*
|
||||
* pane 0 (parent) — subscribed feeds list (the "containers"); an implicit
|
||||
* "All Feeds" entry at index 0 shows every episode.
|
||||
* pane 1 (current) — flat episodes list for the focused feed (reverse
|
||||
* chronological). This is the landing pane.
|
||||
* pane 2 (preview) — detail of the focused episode.
|
||||
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
|
||||
* virtual "All Feeds". Left pane empty at root.
|
||||
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
||||
* chronological). Left pane = the feeds list (prev).
|
||||
* right (preview) — detail of the hovered item in the current column.
|
||||
*
|
||||
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
|
||||
* panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio.
|
||||
* `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the
|
||||
* sidebar at depth 0). j/k move within the current column. The Shell router
|
||||
* drives everything over nav.action; this page only handles list/preview data.
|
||||
*/
|
||||
|
||||
import {
|
||||
createMemo,
|
||||
For,
|
||||
Show,
|
||||
onMount,
|
||||
onCleanup,
|
||||
createEffect,
|
||||
} from "solid-js";
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
@@ -28,8 +22,9 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
@@ -39,9 +34,10 @@ import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
export const FeedPaneCount = 3;
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
|
||||
function FeedPage() {
|
||||
const feedStore = useFeedStore();
|
||||
@@ -52,72 +48,59 @@ function FeedPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent)
|
||||
const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane)
|
||||
const PREV = PaneSlot.PREVIEW; // 2 — episode detail
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
// ── feeds pane data ──────────────────────────────────────────────────────
|
||||
// Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first).
|
||||
// ── feeds list (depth 0) ─────────────────────────────────────────────────
|
||||
const feedList = createMemo<FeedListItem[]>(() => {
|
||||
const all: FeedListItem[] = [{ kind: "all" }];
|
||||
for (const f of feedStore.getFilteredFeeds())
|
||||
all.push({ kind: "feed", feed: f });
|
||||
return all;
|
||||
});
|
||||
const focusedFeedItem = createMemo(() => {
|
||||
const list = feedList();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)];
|
||||
});
|
||||
const focusedFeedIdx = () =>
|
||||
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
|
||||
const focusedFeedItem = (): FeedListItem | undefined =>
|
||||
feedList()[focusedFeedIdx()];
|
||||
|
||||
// ── episodes pane data (filtered by focused feed, or all) ────────────────
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
// ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
|
||||
const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
|
||||
const episodes = createMemo<EpItem[]>(() => {
|
||||
const item = focusedFeedItem();
|
||||
if (!item || item.kind === "all")
|
||||
if (depth() < 1) return [];
|
||||
const id = drilledFeedId();
|
||||
if (id === "all")
|
||||
return feedStore.getAllEpisodesChronological() as EpItem[];
|
||||
return [...item.feed.episodes]
|
||||
const f = feedStore.getFilteredFeeds().find((x) => x.podcast.id === id);
|
||||
if (!f) return [];
|
||||
return [...f.episodes]
|
||||
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
|
||||
.map((episode) => ({ episode, feed: item.feed }));
|
||||
.map((episode) => ({ episode, feed: f }));
|
||||
});
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
||||
|
||||
// Reset episodes focus when the feed filter changes.
|
||||
createEffect(() => {
|
||||
focusedFeedItem();
|
||||
nav.setFocusedIndex(EPS, 0);
|
||||
});
|
||||
|
||||
const focusedItem = createMemo<EpItem | undefined>(() => {
|
||||
const list = episodes();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(EPS), list.length - 1)];
|
||||
});
|
||||
|
||||
// Keep resolvers fresh so visual-mode range selection grows by id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
|
||||
|
||||
const ensureFocus = () => {
|
||||
const eps = episodes();
|
||||
if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length)
|
||||
nav.setFocusedIndex(EPS, eps.length - 1);
|
||||
const fl = feedList();
|
||||
if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length)
|
||||
nav.setFocusedIndex(FEEDS, fl.length - 1);
|
||||
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
|
||||
nav.setDepthFocus(feedList().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) {
|
||||
const it = feedList()[i];
|
||||
return it?.kind === "feed" ? it.feed.podcast.id : "all";
|
||||
}
|
||||
return episodes()[i]?.episode.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
const formatDuration = (s: number) => {
|
||||
@@ -159,24 +142,34 @@ function FeedPage() {
|
||||
audioNav.setSource(AudioSource.FEED);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const item = focusedFeedItem();
|
||||
if (!item) return;
|
||||
const ctx = item.kind === "all" ? "all" : item.feed.podcast.id;
|
||||
nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
}
|
||||
|
||||
// ── nav.action handler ────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes
|
||||
if (p === EPS) playEpisode(focusedItem());
|
||||
},
|
||||
"toggle-select": (p) => {
|
||||
if (p === EPS) {
|
||||
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, curLen()),
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() >= 1) {
|
||||
const item = focusedItem();
|
||||
if (item) nav.toggleSelected(item.episode.id);
|
||||
}
|
||||
@@ -188,24 +181,18 @@ function FeedPage() {
|
||||
else feedStore.refreshAllFeeds().catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === FEEDS) return feedList().length;
|
||||
if (pane === EPS) return episodes().length;
|
||||
return 0;
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
action: KeybindActionName;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
@@ -213,243 +200,326 @@ function FeedPage() {
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
: i === listFocus
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
|
||||
const feedLabel = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? "All Feeds"
|
||||
: item.feed.customName || item.feed.podcast.title;
|
||||
const feedCount = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(FEEDS)}
|
||||
border
|
||||
borderColor={border(FEEDS)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
Feeds · {feedList().length - 1}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const label = () =>
|
||||
item.kind === "all"
|
||||
? "All Feeds"
|
||||
: item.feed.customName || item.feed.podcast.title;
|
||||
const count = () =>
|
||||
item.kind === "all"
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), FEEDS)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(FEEDS);
|
||||
nav.setFocusedIndex(FEEDS, index());
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), FEEDS)}>
|
||||
{index() === nav.focusedIndex(FEEDS) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), FEEDS)}>{label()}</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(FEEDS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
({count()})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
{(item, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={muted()}>({feedCount(item)})</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── pane 1 (current, center): episodes ─────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{(() => {
|
||||
const fi = focusedFeedItem();
|
||||
if (fi?.kind === "feed")
|
||||
return fi.feed.customName || fi.feed.podcast.title;
|
||||
return "All Episodes";
|
||||
})()} · {episodes().length}
|
||||
{depth() === 0
|
||||
? `Feeds · ${feedList().length - 1}`
|
||||
: `${(() => {
|
||||
const fi = focusedFeedItem();
|
||||
return fi?.kind === "feed"
|
||||
? fi.feed.customName || fi.feed.podcast.title
|
||||
: "All Episodes";
|
||||
})()} · ${episodes().length}`}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(EPS)}
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(EPS)}
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), EPS)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(EPS);
|
||||
nav.setFocusedIndex(EPS, index());
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{index() === nav.focusedIndex(EPS) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: theme.info
|
||||
}
|
||||
>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
{/* depth 0: feeds */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No feeds. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedFeedIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
({feedCount(item)})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const fi = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── right: preview of hovered item ───────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PREV)}
|
||||
border
|
||||
borderColor={border(PREV)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(item().episode.pubDate)}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatDuration(item().episode.duration)}
|
||||
</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
{/* depth 0 preview: hovered feed */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={focusedFeedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No feed focused</text>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play space: select h/l: panes</text>
|
||||
</box>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{(item) => {
|
||||
const it = item();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{feedLabel(it)}</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{it.kind === "feed"
|
||||
? `by ${it.feed.podcast.author ?? "unknown"}`
|
||||
: ""}
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>
|
||||
{it.kind === "all"
|
||||
? `${feedCount(it)} episodes across all feeds`
|
||||
: `${feedCount(it)} episodes`}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{it.kind === "feed"
|
||||
? (it.feed.podcast.description?.slice(0, 400) ??
|
||||
"No description.")
|
||||
: "Drill in to see episodes across every feed."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered episode */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => {
|
||||
const it = item();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{it.episode.episodeNumber
|
||||
? `#${it.episode.episodeNumber} `
|
||||
: ""}
|
||||
{it.episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(it.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatDuration(it.episode.duration)}
|
||||
</text>
|
||||
<Show when={downloadLabel(it.episode.id)}>
|
||||
<text fg={downloadColor(it.episode.id)}>
|
||||
{downloadLabel(it.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{it.feed.customName || it.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={it.feed.podcast.author}>
|
||||
<text fg={muted()}>by {it.feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{it.episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · space: select · h: back
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* MyShowsPage — yazi-style 3-pane view (canonical reference migration).
|
||||
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||
*
|
||||
* pane 0 (parent) — subscribed shows
|
||||
* pane 1 (current) — episodes of the focused show
|
||||
* pane 2 (preview) — detail of the focused episode
|
||||
* depth 0 (current) — subscribed shows. Left pane empty at root.
|
||||
* depth 1 (current) — episodes of the drilled show. Left pane = shows (prev).
|
||||
* right (preview) — detail of the hovered item in the current column.
|
||||
*
|
||||
* Movement (j/k, gg/G, page-jumps) and selection (space, v) are driven by the
|
||||
* Shell router via the `nav.action` event bus; this page only subscribes and
|
||||
* translates actions against its own data. h/l swipe between panes is handled
|
||||
* by the Shell (nav.swipe). The focused row is read from nav.focusedIndex(pane)
|
||||
* so the page is purely reactive.
|
||||
* `l`/Enter drills in (show → episodes); `h` pops back (or yields to the
|
||||
* sidebar at depth 0). j/k move within the current column.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -22,17 +19,19 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
export const MyShowsPaneCount = 3;
|
||||
export const MyShowsPaneCount = 1;
|
||||
|
||||
export function MyShowsPage() {
|
||||
const feedStore = useFeedStore();
|
||||
@@ -43,65 +42,46 @@ export function MyShowsPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const SHOWS = PaneSlot.PARENT;
|
||||
const EPS = PaneSlot.CURRENT;
|
||||
const PREV = PaneSlot.PREVIEW;
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
const shows = () => feedStore.getFilteredFeeds();
|
||||
|
||||
// The selected show tracks the focused row of pane 0.
|
||||
const selectedShow = createMemo(() => {
|
||||
const list = shows();
|
||||
if (list.length === 0) return undefined;
|
||||
const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1);
|
||||
return list[idx];
|
||||
});
|
||||
const focusedShowIdx = () =>
|
||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
||||
|
||||
const episodes = createMemo(() => {
|
||||
const show = selectedShow();
|
||||
if (!show) return [] as Episode[];
|
||||
// depth-1 frame ctx = the drilled feed id
|
||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||
const episodes = createMemo<Episode[]>(() => {
|
||||
if (depth() < 1) return [];
|
||||
const id = drilledShowId();
|
||||
const show = shows().find((s) => s.id === id);
|
||||
if (!show) return [];
|
||||
return [...show.episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
});
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||
|
||||
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (shows().length > 0 && focus(0) >= shows().length)
|
||||
nav.setDepthFocus(shows().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by episode id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id);
|
||||
// keep the resolver fresh as the episode list changes
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.id,
|
||||
);
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return shows()[i]?.id;
|
||||
return episodes()[i]?.id;
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
|
||||
// Keep shows-focus in range after feeds load/change.
|
||||
const ensureShowsFocus = () => {
|
||||
const list = shows();
|
||||
if (list.length === 0) return;
|
||||
const cur = nav.focusedIndex(SHOWS);
|
||||
if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1);
|
||||
};
|
||||
onMount(ensureShowsFocus);
|
||||
|
||||
// When the show changes, reset episode focus + set audio-nav source + show count.
|
||||
const onShowChanged = () => {
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
if (nav.focusedIndex(EPS) > episodes().length - 1)
|
||||
nav.setFocusedIndex(EPS, 0);
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
||||
};
|
||||
onMount(onShowChanged);
|
||||
|
||||
const focusedEpisode = createMemo(() => {
|
||||
const eps = episodes();
|
||||
if (eps.length === 0) return undefined;
|
||||
const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1);
|
||||
return eps[idx];
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -139,35 +119,40 @@ export function MyShowsPage() {
|
||||
return muted();
|
||||
}
|
||||
};
|
||||
|
||||
const playEpisode = (ep: Episode) => {
|
||||
audio.play(ep).catch(() => {});
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === SHOWS) {
|
||||
nav.swipe(1, MyShowsPaneCount);
|
||||
onShowChanged();
|
||||
} else if (p === EPS) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) playEpisode(ep);
|
||||
}
|
||||
},
|
||||
"toggle-select": (p) => {
|
||||
if (p === EPS) {
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) playEpisode(ep);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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, curLen()),
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() >= 1) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) nav.toggleSelected(ep.id);
|
||||
}
|
||||
@@ -177,252 +162,305 @@ export function MyShowsPage() {
|
||||
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === SHOWS) return shows().length;
|
||||
if (pane === EPS) return episodes().length;
|
||||
return 0;
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
if (pane === SHOWS) {
|
||||
// clamp episode focus + re-resolve after show change
|
||||
nav.setFocusedIndex(
|
||||
EPS,
|
||||
Math.min(nav.focusedIndex(EPS), Math.max(0, episodes().length - 1)),
|
||||
);
|
||||
onShowChanged();
|
||||
}
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
action: KeybindActionName;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
// Only react when our tab is active.
|
||||
// (Shell always emits; router guarantees our tab is active.)
|
||||
ensureShowsFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
// visual selection growth is handled inside nav.move/registerResolver
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
onCleanup(() => off("nav.action", onAction));
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0: shows ─────────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(SHOWS)}
|
||||
border
|
||||
borderColor={border(SHOWS)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={shows().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No shows. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), SHOWS)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(SHOWS);
|
||||
nav.setFocusedIndex(SHOWS, index());
|
||||
onShowChanged();
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), SHOWS)}>
|
||||
{index() === nav.focusedIndex(SHOWS) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), SHOWS)}>
|
||||
{feed.customName || feed.podcast.title}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(SHOWS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
{(feed, index) => {
|
||||
const lf = nav.depthFocus(0);
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, false)}
|
||||
>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, false)}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={muted()}>({feed.episodes.length})</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── pane 1: episodes ──────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── center: current depth ─────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{selectedShow()?.customName ||
|
||||
selectedShow()?.podcast.title ||
|
||||
"Episodes"}{" "}
|
||||
· {episodes().length}
|
||||
{depth() === 0
|
||||
? `Shows (${shows().length})`
|
||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(EPS)}
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(EPS)}
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), EPS)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(EPS);
|
||||
nav.setFocusedIndex(EPS, index());
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{index() === nav.focusedIndex(EPS) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: theme.info
|
||||
}
|
||||
>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(ep.id)}>
|
||||
<text fg={downloadColor(ep.id)}>
|
||||
{downloadLabel(ep.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
{/* depth 0: shows */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={shows().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
No shows. Subscribe from Discover/Search.
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = focusedShowIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes. :refresh</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => {
|
||||
const lf = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(ep.id)}>
|
||||
<text fg={downloadColor(ep.id)}>
|
||||
{downloadLabel(ep.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2: preview ───────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── right: preview ────────────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PREV)}
|
||||
border
|
||||
borderColor={border(PREV)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
||||
{ep().title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
||||
<Show when={downloadLabel(ep().id)}>
|
||||
<text fg={downloadColor(ep().id)}>
|
||||
{downloadLabel(ep().id)}
|
||||
{/* depth 0 preview: hovered show */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No show focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(show) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{showTitle(show())}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>
|
||||
{show().episodes.length} episodes
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ??
|
||||
"No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered episode */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
||||
{ep().title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
||||
<Show when={downloadLabel(ep().id)}>
|
||||
<text fg={downloadColor(ep().id)}>
|
||||
{downloadLabel(ep().id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={selectedShow()?.podcast.author}>
|
||||
<text fg={muted()}>
|
||||
by {selectedShow()!.podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{ep().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · space: select · h: back
|
||||
</text>
|
||||
</box>
|
||||
<Show when={selectedShow()?.podcast.author}>
|
||||
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{ep().description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play space: select h/l: panes</text>
|
||||
</box>
|
||||
)}
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
@@ -1,159 +1,94 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import type { ThemeName } from "@/types/settings";
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import type { ThemeName } from "@/types/settings";
|
||||
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" },
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "catppuccin", label: "Catppuccin" },
|
||||
{ value: "gruvbox", label: "Gruvbox" },
|
||||
{ value: "tokyo", label: "Tokyo" },
|
||||
{ value: "nord", label: "Nord" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
export function PreferencesPanel() {
|
||||
const appStore = useAppStore();
|
||||
const { theme } = useTheme();
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("theme");
|
||||
export function usePreferencesItems(): SettingItem[] {
|
||||
const app = useAppStore();
|
||||
|
||||
const settings = () => appStore.state().settings;
|
||||
const preferences = () => appStore.state().preferences;
|
||||
const settings = () => app.state().settings;
|
||||
const prefs = () => app.state().preferences;
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const fields: FocusField[] = [
|
||||
"theme",
|
||||
"font",
|
||||
"speed",
|
||||
"explicit",
|
||||
"auto",
|
||||
];
|
||||
const idx = fields.indexOf(focusField());
|
||||
const next = key.shift
|
||||
? (idx - 1 + fields.length) % fields.length
|
||||
: (idx + 1) % fields.length;
|
||||
setFocusField(fields[next]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1);
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1);
|
||||
}
|
||||
if (key.name === "space" || key.name === "return") {
|
||||
toggleValue();
|
||||
}
|
||||
};
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField();
|
||||
if (field === "theme") {
|
||||
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
|
||||
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length;
|
||||
appStore.setTheme(THEME_LABELS[next].value);
|
||||
return;
|
||||
}
|
||||
if (field === "font") {
|
||||
const next = Math.min(20, Math.max(10, settings().fontSize + delta));
|
||||
appStore.updateSettings({ fontSize: next });
|
||||
return;
|
||||
}
|
||||
if (field === "speed") {
|
||||
const next = Math.min(
|
||||
2,
|
||||
Math.max(0.5, settings().playbackSpeed + delta * 0.1),
|
||||
);
|
||||
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleValue = () => {
|
||||
const field = focusField();
|
||||
if (field === "explicit") {
|
||||
appStore.updatePreferences({ showExplicit: !preferences().showExplicit });
|
||||
}
|
||||
if (field === "auto") {
|
||||
appStore.updatePreferences({ autoDownload: !preferences().autoDownload });
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard(handleKey);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.textMuted}>Preferences</text>
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
|
||||
Theme:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>
|
||||
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
|
||||
Font Size:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{settings().fontSize}px</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
|
||||
Playback:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{settings().playbackSpeed}x</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={focusField() === "explicit" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
Show Explicit:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text
|
||||
fg={preferences().showExplicit ? theme.success : theme.textMuted}
|
||||
>
|
||||
{preferences().showExplicit ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Space]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
|
||||
Auto Download:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text
|
||||
fg={preferences().autoDownload ? theme.success : theme.textMuted}
|
||||
>
|
||||
{preferences().autoDownload ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Space]</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
);
|
||||
return [
|
||||
{
|
||||
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: "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 new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updatePreferences({
|
||||
autoDownload: !prefs().autoDownload,
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,86 +1,199 @@
|
||||
/**
|
||||
* SettingsPage — yazi-style 2-pane view.
|
||||
* SettingsPage — yazi depth-stack settings.
|
||||
*
|
||||
* pane 0 (parent) — section list (Sync, Sources, Preferences, ...)
|
||||
* pane 1 (current) — active panel for the focused section
|
||||
* 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
|
||||
*
|
||||
* Movement (j/k, gg/G, page-jumps) on pane 0 navigates the section list.
|
||||
* The panel (pane 1) reactively shows the focused section's content.
|
||||
* Audio transport and tab/pane swipes are handled by the Shell router.
|
||||
* Columns render as yazi's prev | current | preview:
|
||||
* left = previous depth's list (empty at depth 0)
|
||||
* right = preview/help text for the hovered item in center
|
||||
*
|
||||
* 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 } from "solid-js";
|
||||
import { SourceManager } from "./SourceManager";
|
||||
import { PreferencesPanel } from "./PreferencesPanel";
|
||||
import { SyncPanel } from "./SyncPanel";
|
||||
import { VisualizerSettings } from "./VisualizerSettings";
|
||||
import { For, Show, onMount, onCleanup, createMemo } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||
import { usePreferencesItems } from "./PreferencesPanel";
|
||||
import { useVisualizerItems } from "./VisualizerSettings";
|
||||
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||
import { useSourceItems } from "./SourceManager";
|
||||
|
||||
export const SettingsPaneCount = 2;
|
||||
export const SettingsPaneCount = 1;
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 0, label: "Sync" },
|
||||
{ id: 1, label: "Sources" },
|
||||
{ id: 2, label: "Preferences" },
|
||||
{ id: 3, label: "Visualizer" },
|
||||
{ id: 4, label: "Account" },
|
||||
] as const;
|
||||
const SECTIONS: SettingsSectionDef[] = [
|
||||
{
|
||||
id: 0,
|
||||
label: "Sync",
|
||||
description: "Import/export subscriptions and sync status.",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
label: "Sources",
|
||||
description: "Podcast search/RSS sources — add, enable, remove.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: "Preferences",
|
||||
description: "Theme, font, playback speed, explicit/auto-download.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: "Visualizer",
|
||||
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: "Account",
|
||||
description: "Account login & OAuth (not yet implemented).",
|
||||
},
|
||||
];
|
||||
|
||||
/** Resolve the items for a section id at render time. Section 4 (Account) has
|
||||
* no items yet. */
|
||||
function sectionItems(sectionId: number): SettingItem[] {
|
||||
switch (sectionId) {
|
||||
case 0:
|
||||
return useSyncItems();
|
||||
case 1:
|
||||
return useSourceItems();
|
||||
case 2:
|
||||
return usePreferencesItems();
|
||||
case 3:
|
||||
return useVisualizerItems();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const SECTIONS_PANE = PaneSlot.PARENT; // 0
|
||||
const PANEL = PaneSlot.CURRENT; // 1
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
|
||||
// The focused section tracks pane 0's focused index.
|
||||
const focusedSection = () => {
|
||||
const idx = nav.focusedIndex(SECTIONS_PANE);
|
||||
return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0];
|
||||
// ── 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];
|
||||
};
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by section id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) =>
|
||||
SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(),
|
||||
);
|
||||
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()];
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === SECTIONS_PANE) {
|
||||
nav.swipe(1, SettingsPaneCount);
|
||||
}
|
||||
},
|
||||
// ── 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);
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === SECTIONS_PANE) return SECTIONS.length;
|
||||
return 0;
|
||||
// ── drill / open dispatch ───────────────────────────────────────────────
|
||||
function open() {
|
||||
const d = depth();
|
||||
if (d === 0) {
|
||||
// drill into the focused section's items
|
||||
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;
|
||||
}
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
|
||||
// ── 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) {
|
||||
// editor: j/k nudges the value
|
||||
const it = editorItem();
|
||||
if (it?.kind === "number" || it?.kind === "select")
|
||||
it.cycle?.(delta as -1 | 1);
|
||||
return;
|
||||
}
|
||||
nav.move(delta, len());
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
@@ -88,98 +201,319 @@ export function SettingsPage() {
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
// ignore actions meant for non-center panes
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
if (handler) handler();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
onCleanup(() => off("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;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const headerBg = theme.background;
|
||||
|
||||
// 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.";
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0: sections ─────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Settings</text>
|
||||
</box>
|
||||
// ── column content builders ──────────────────────────────────────────────
|
||||
// left = previous depth (read-only list), or empty at depth 0
|
||||
const LeftCol = () => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
<Show when={depth() >= 1} fallback=" ">
|
||||
{depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={depth() === 1}>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(SECTIONS_PANE)}
|
||||
border
|
||||
borderColor={border(SECTIONS_PANE)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), SECTIONS_PANE)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(SECTIONS_PANE);
|
||||
nav.setFocusedIndex(SECTIONS_PANE, index());
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), SECTIONS_PANE)}>
|
||||
{index() === nav.focusedIndex(SECTIONS_PANE) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), SECTIONS_PANE)}>
|
||||
{section.label}
|
||||
</text>
|
||||
</box>
|
||||
<Row
|
||||
label={`${section.id + 1}. ${section.label}`}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={false}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 1: panel ─────────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>{focusedSection().label}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={depth() === 2}>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PANEL)}
|
||||
border
|
||||
borderColor={border(PANEL)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show when={focusedSection().id === 0}>
|
||||
<SyncPanel />
|
||||
<For each={items()}>
|
||||
{(it, index) => (
|
||||
<Row
|
||||
label={`${it.label} ${it.display()}`}
|
||||
focused={index() === focusedItemIdx()}
|
||||
active={false}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
|
||||
// center = current depth
|
||||
const CenterCol = () => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.current}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
<Show
|
||||
when={depth() === 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={depth() === 1}
|
||||
fallback={editorItem()?.label ?? "Editor"}
|
||||
>
|
||||
{sectionForDepth1()?.label ?? "Items"}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
Settings
|
||||
</Show>
|
||||
<Show when={focusedSection().id === 1}>
|
||||
<SourceManager focused />
|
||||
</Show>
|
||||
<Show when={focusedSection().id === 2}>
|
||||
<PreferencesPanel />
|
||||
</Show>
|
||||
<Show when={focusedSection().id === 3}>
|
||||
<VisualizerSettings />
|
||||
</Show>
|
||||
<Show when={focusedSection().id === 4}>
|
||||
<box padding={1} flexDirection="column" gap={1}>
|
||||
<text fg={muted()}>Account settings (not yet implemented)</text>
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive}
|
||||
border
|
||||
borderColor={border(isActive)}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<Show when={depth() === 0}>
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<Row
|
||||
label={`${section.id + 1}. ${section.label}`}
|
||||
focused={index() === focusedSectionIdx()}
|
||||
active={isActive}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={depth() === 1}>
|
||||
<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>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
<Show when={depth() === 2}>
|
||||
<Show
|
||||
when={editorItem()?.renderEditor}
|
||||
fallback={<GenericEditor item={editorItem()!} />}
|
||||
>
|
||||
{editorItem()!.renderEditor!()}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
);
|
||||
|
||||
// right = preview / help
|
||||
const RightCol = () => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
border
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<box padding={1}>
|
||||
<MultiLine text={previewText()} />
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
);
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{LeftCol()}
|
||||
{CenterCol()}
|
||||
{RightCol()}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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 : theme.text);
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<text fg={fg()}>{props.focused ? "❯" : " "}</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,317 +1,141 @@
|
||||
/**
|
||||
* Source management component for PodTUI
|
||||
* Add, remove, and configure podcast sources
|
||||
* SourceManager — exposes podcast sources as SettingItems for the depth-stack.
|
||||
*
|
||||
* • "Add Source" — an editor item; drilling in shows a name/URL add form.
|
||||
* • Each source — a toggle item (Space toggles enabled) whose display shows
|
||||
* the source type and on/off state.
|
||||
*
|
||||
* Advanced per-API-source options (country/language/explicit) are flattened to
|
||||
* simple toggles/cycles reachable by drilling into the source's editor.
|
||||
* Movement flows through nav.action — no own useKeyboard (avoids the old
|
||||
* right-pane key conflicts).
|
||||
*/
|
||||
|
||||
import { createSignal, For } from "solid-js";
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { SourceType } from "@/types/source";
|
||||
import type { PodcastSource } from "@/types/source";
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
interface SourceManagerProps {
|
||||
focused?: boolean;
|
||||
onClose?: () => void;
|
||||
export function useSourceItems(): SettingItem[] {
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
const typeBadge = (s: PodcastSource) =>
|
||||
s.type === SourceType.API
|
||||
? "[API]"
|
||||
: s.type === SourceType.RSS
|
||||
? "[RSS]"
|
||||
: "[?]";
|
||||
|
||||
const items: SettingItem[] = [
|
||||
{
|
||||
id: "add",
|
||||
label: "Add Source",
|
||||
kind: "editor",
|
||||
display: () => "+",
|
||||
help: () =>
|
||||
`Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`,
|
||||
renderEditor: () => <AddSourceForm />,
|
||||
},
|
||||
];
|
||||
|
||||
for (const s of feedStore.sources()) {
|
||||
items.push({
|
||||
id: `src:${s.id}`,
|
||||
label: s.name,
|
||||
kind: "toggle",
|
||||
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
||||
help: () =>
|
||||
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
||||
toggle: () => feedStore.toggleSource(s.id),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language";
|
||||
function AddSourceForm() {
|
||||
const feedStore = useFeedStore();
|
||||
const { theme } = useTheme();
|
||||
const [name, setName] = createSignal("");
|
||||
const [url, setUrl] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
export function SourceManager(props: SourceManagerProps) {
|
||||
const feedStore = useFeedStore();
|
||||
const { theme } = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("list");
|
||||
const [newSourceUrl, setNewSourceUrl] = createSignal("");
|
||||
const [newSourceName, setNewSourceName] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const submit = () => {
|
||||
const u = url().trim();
|
||||
if (!u) {
|
||||
setError("URL is required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(u);
|
||||
} catch {
|
||||
setError("Invalid URL format");
|
||||
return;
|
||||
}
|
||||
feedStore.addSource({
|
||||
name: name().trim() || "Custom Source",
|
||||
type: SourceType.RSS,
|
||||
baseUrl: u,
|
||||
enabled: true,
|
||||
description: `Custom RSS feed: ${u}`,
|
||||
});
|
||||
setName("");
|
||||
setUrl("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const sources = () => feedStore.sources();
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "escape") {
|
||||
if (focusArea() !== "list") {
|
||||
setFocusArea("list");
|
||||
setError(null);
|
||||
} else if (props.onClose) {
|
||||
props.onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "tab") {
|
||||
const areas: FocusArea[] = [
|
||||
"list",
|
||||
"country",
|
||||
"language",
|
||||
"explicit",
|
||||
"add",
|
||||
"url",
|
||||
];
|
||||
const idx = areas.indexOf(focusArea());
|
||||
const nextIdx = key.shift
|
||||
? (idx - 1 + areas.length) % areas.length
|
||||
: (idx + 1) % areas.length;
|
||||
setFocusArea(areas[nextIdx]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusArea() === "list") {
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
|
||||
} else if (
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source) {
|
||||
feedStore.toggleSource(source.id);
|
||||
}
|
||||
} else if (key.name === "d" || key.name === "delete") {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source) {
|
||||
const removed = feedStore.removeSource(source.id);
|
||||
if (!removed) {
|
||||
setError("Cannot remove default sources");
|
||||
}
|
||||
}
|
||||
} else if (key.name === "a") {
|
||||
setFocusArea("add");
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "country") {
|
||||
if (
|
||||
key.name === "enter" ||
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
const next = source.country === "US" ? "GB" : "US";
|
||||
feedStore.updateSource(source.id, { country: next });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "explicit") {
|
||||
if (
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
feedStore.updateSource(source.id, {
|
||||
allowExplicit: !source.allowExplicit,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "language") {
|
||||
if (
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
const next = source.language === "ja_jp" ? "en_us" : "ja_jp";
|
||||
feedStore.updateSource(source.id, { language: next });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSource = () => {
|
||||
const url = newSourceUrl().trim();
|
||||
const name = newSourceName().trim() || `Custom Source`;
|
||||
|
||||
if (!url) {
|
||||
setError("URL is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
setError("Invalid URL format");
|
||||
return;
|
||||
}
|
||||
|
||||
feedStore.addSource({
|
||||
name,
|
||||
type: "rss" as SourceType,
|
||||
baseUrl: url,
|
||||
enabled: true,
|
||||
description: `Custom RSS feed: ${url}`,
|
||||
});
|
||||
|
||||
setNewSourceUrl("");
|
||||
setNewSourceName("");
|
||||
setFocusArea("list");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const getSourceIcon = (source: PodcastSource) => {
|
||||
if (source.type === SourceType.API) return "[API]";
|
||||
if (source.type === SourceType.RSS) return "[RSS]";
|
||||
return "[?]";
|
||||
};
|
||||
|
||||
const selectedSource = () => sources()[selectedIndex()];
|
||||
const isApiSource = () => selectedSource()?.type === SourceType.API;
|
||||
const sourceCountry = () => selectedSource()?.country || "US";
|
||||
const sourceExplicit = () => selectedSource()?.allowExplicit !== false;
|
||||
const sourceLanguage = () => selectedSource()?.language || "en_us";
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border borderColor={theme.border} padding={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text}>
|
||||
<strong>Podcast Sources</strong>
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0} onMouseDown={props.onClose}>
|
||||
<text fg={theme.primary}>[Esc] Close</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
||||
|
||||
{/* Source list */}
|
||||
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
||||
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
|
||||
Sources:
|
||||
</text>
|
||||
<scrollbox height={6}>
|
||||
<For each={sources()}>
|
||||
{(source, index) => (
|
||||
<SelectableBox
|
||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
padding={0}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
setFocusArea("list");
|
||||
feedStore.toggleSource(source.id);
|
||||
}}
|
||||
>
|
||||
<SelectableText
|
||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{focusArea() === "list" && index() === selectedIndex()
|
||||
? ">"
|
||||
: " "}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{source.name}
|
||||
</SelectableText>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<text fg={theme.textMuted}>
|
||||
Space/Enter to toggle, d to delete, a to add
|
||||
</text>
|
||||
|
||||
{/* API settings */}
|
||||
<box flexDirection="column" gap={1}>
|
||||
<SelectableText selected={() => false} primary={isApiSource()}>
|
||||
{isApiSource()
|
||||
? "API Settings"
|
||||
: "API Settings (select an API source)"}
|
||||
</SelectableText>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
backgroundColor={
|
||||
focusArea() === "country" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "country"}>
|
||||
Country: {sourceCountry()}
|
||||
</SelectableText>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
backgroundColor={
|
||||
focusArea() === "language" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "language"}>
|
||||
Language:{" "}
|
||||
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
||||
</SelectableText>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
backgroundColor={
|
||||
focusArea() === "explicit" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "explicit"}>
|
||||
Explicit: {sourceExplicit() ? "Yes" : "No"}
|
||||
</SelectableText>
|
||||
</box>
|
||||
</box>
|
||||
<SelectableText selected={() => false} tertiary>
|
||||
Enter/Space to toggle focused setting
|
||||
</SelectableText>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Add new source form */}
|
||||
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "add" || focusArea() === "url"}>
|
||||
Add New Source:
|
||||
</SelectableText>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>Name:</SelectableText>
|
||||
<input
|
||||
value={newSourceName()}
|
||||
onInput={setNewSourceName}
|
||||
placeholder="My Custom Feed"
|
||||
focused={props.focused && focusArea() === "add"}
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>URL:</SelectableText>
|
||||
<input
|
||||
value={newSourceUrl()}
|
||||
onInput={(v) => {
|
||||
setNewSourceUrl(v);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://example.com/feed.rss"
|
||||
focused={props.focused && focusArea() === "url"}
|
||||
width={35}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box border borderColor={theme.border} padding={0} width={15} onMouseDown={handleAddSource}>
|
||||
<SelectableText selected={() => false} primary>[+] Add Source</SelectableText>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Error message */}
|
||||
{error() && <SelectableText selected={() => false} tertiary>{error()}</SelectableText>}
|
||||
|
||||
<SelectableText selected={() => false} tertiary>Tab to switch sections, Esc to close</SelectableText>
|
||||
</box>
|
||||
);
|
||||
return (
|
||||
<box flexDirection="column" padding={1} gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>Add Source</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>Name:</text>
|
||||
<input
|
||||
value={name()}
|
||||
onInput={setName}
|
||||
placeholder="My Custom Feed"
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>URL:</text>
|
||||
<input
|
||||
value={url()}
|
||||
onInput={(v) => {
|
||||
setUrl(v);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://example.com/feed.rss"
|
||||
width={35}
|
||||
/>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
width={15}
|
||||
onMouseDown={submit}
|
||||
>
|
||||
<text fg={theme.primary}>[+] Add</text>
|
||||
</box>
|
||||
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
|
||||
<Show when={feedStore.sources().length > 0}>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Current sources ({feedStore.sources().length}):
|
||||
</text>
|
||||
<For each={feedStore.sources()}>
|
||||
{(s) => (
|
||||
<text fg={theme.textMuted}>
|
||||
{s.enabled ? "●" : "○"} {s.name}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
/**
|
||||
* SyncPanel — exposes Import / Export / status as SettingItems. The Import and
|
||||
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
import { SyncStatus } from "./SyncStatus";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
// Module-level state so the action items can open their dialogs as depth-2
|
||||
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show.
|
||||
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>(
|
||||
null,
|
||||
);
|
||||
export { syncEditor };
|
||||
export function closeSyncEditor() {
|
||||
setSyncEditor(null);
|
||||
}
|
||||
|
||||
import { ImportDialog } from "./ImportDialog"
|
||||
import { ExportDialog } from "./ExportDialog"
|
||||
import { SyncStatus } from "./SyncStatus"
|
||||
import { useTheme } from "@/context/ThemeContext"
|
||||
|
||||
export function SyncPanel() {
|
||||
const { theme } = useTheme();
|
||||
const mode = createSignal<"import" | "export" | null>(null)
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<box border borderColor={theme.border} onMouseDown={() => mode[1]("import")}>
|
||||
<text fg={theme.text}>Import</text>
|
||||
</box>
|
||||
<box border borderColor={theme.border} onMouseDown={() => mode[1]("export")}>
|
||||
<text fg={theme.text}>Export</text>
|
||||
</box>
|
||||
</box>
|
||||
<SyncStatus />
|
||||
{mode[0]() === "import" ? <ImportDialog /> : null}
|
||||
{mode[0]() === "export" ? <ExportDialog /> : null}
|
||||
</box>
|
||||
)
|
||||
export function useSyncItems(): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "import",
|
||||
label: "Import",
|
||||
kind: "editor",
|
||||
display: () => "→",
|
||||
help: () =>
|
||||
`Import subscriptions from a sync file (JSON or OPML).\nDrill in (Enter/l) to open the import dialog.\nType: editor`,
|
||||
renderEditor: () => <ImportDialog />,
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
label: "Export",
|
||||
kind: "editor",
|
||||
display: () => "→",
|
||||
help: () =>
|
||||
`Export subscriptions to a sync file.\nDrill in (Enter/l) to open the export dialog.\nType: editor`,
|
||||
renderEditor: () => <ExportDialog />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
kind: "info",
|
||||
display: () => "Idle",
|
||||
help: () =>
|
||||
`Last sync status. (Sync is run from the import/export dialogs.)\nType: info`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Renders the live sync status block (used by the Settings page header for the
|
||||
* Sync section, when relevant). */
|
||||
export function SyncStatusBlock() {
|
||||
return <SyncStatus />;
|
||||
}
|
||||
|
||||
@@ -1,164 +1,81 @@
|
||||
/**
|
||||
* VisualizerSettings — settings panel for the real-time audio visualizer.
|
||||
*
|
||||
* Allows adjusting bar count, noise reduction, sensitivity, and
|
||||
* frequency cutoffs. All changes persist via the app store.
|
||||
* VisualizerSettings — exposes bars/sensitivity/noise/lowCut/highCut as
|
||||
* SettingItems for the yazi depth-stack. No own useKeyboard.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut";
|
||||
export function useVisualizerItems(): SettingItem[] {
|
||||
const app = useAppStore();
|
||||
const viz = () => app.state().settings.visualizer;
|
||||
|
||||
const FIELDS: FocusField[] = [
|
||||
"bars",
|
||||
"sensitivity",
|
||||
"noise",
|
||||
"lowCut",
|
||||
"highCut",
|
||||
];
|
||||
|
||||
export function VisualizerSettings() {
|
||||
const appStore = useAppStore();
|
||||
const { theme } = useTheme();
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("bars");
|
||||
|
||||
const viz = () => appStore.state().settings.visualizer;
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const idx = FIELDS.indexOf(focusField());
|
||||
const next = key.shift
|
||||
? (idx - 1 + FIELDS.length) % FIELDS.length
|
||||
: (idx + 1) % FIELDS.length;
|
||||
setFocusField(FIELDS[next]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1);
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1);
|
||||
}
|
||||
};
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField();
|
||||
const v = viz();
|
||||
|
||||
switch (field) {
|
||||
case "bars": {
|
||||
// Step by 8: 8, 16, 24, 32, ..., 128
|
||||
const next = Math.min(128, Math.max(8, v.bars + delta * 8));
|
||||
appStore.updateVisualizer({ bars: next });
|
||||
break;
|
||||
}
|
||||
case "sensitivity": {
|
||||
// Toggle: 0 (manual) or 1 (auto)
|
||||
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 });
|
||||
break;
|
||||
}
|
||||
case "noise": {
|
||||
// Step by 0.05: 0.0 – 1.0
|
||||
const next = Math.min(
|
||||
1,
|
||||
Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))),
|
||||
);
|
||||
appStore.updateVisualizer({ noiseReduction: next });
|
||||
break;
|
||||
}
|
||||
case "lowCut": {
|
||||
// Step by 10: 20 – 500 Hz
|
||||
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10));
|
||||
appStore.updateVisualizer({ lowCutOff: next });
|
||||
break;
|
||||
}
|
||||
case "highCut": {
|
||||
// Step by 500: 1000 – 20000 Hz
|
||||
const next = Math.min(
|
||||
20000,
|
||||
Math.max(1000, v.highCutOff + delta * 500),
|
||||
);
|
||||
appStore.updateVisualizer({ highCutOff: next });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard(handleKey);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.textMuted}>Visualizer</text>
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
|
||||
Bars:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().bars}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={
|
||||
focusField() === "sensitivity" ? theme.primary : theme.textMuted
|
||||
}
|
||||
>
|
||||
Auto Sensitivity:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text
|
||||
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
|
||||
>
|
||||
{viz().sensitivity === 1 ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
|
||||
Noise Reduction:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
Low Cutoff:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={focusField() === "highCut" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
High Cutoff:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: "bars",
|
||||
label: "Bars",
|
||||
kind: "number",
|
||||
display: () => String(viz().bars),
|
||||
help: () =>
|
||||
`Number of visualizer bars.\nType: number (8–128, step 8)\nDefault: 64\nCurrent: ${viz().bars}\nj/k to −/+8.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
bars: Math.min(128, Math.max(8, viz().bars + dir * 8)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "sensitivity",
|
||||
label: "Auto Sensitivity",
|
||||
kind: "toggle",
|
||||
display: () => (viz().sensitivity === 1 ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Automatic gain sensitivity.\nType: toggle\nDefault: on\nCurrent: ${viz().sensitivity === 1 ? "on" : "off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updateVisualizer({
|
||||
sensitivity: viz().sensitivity === 1 ? 0 : 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "noiseReduction",
|
||||
label: "Noise Reduction",
|
||||
kind: "number",
|
||||
display: () => viz().noiseReduction.toFixed(2),
|
||||
help: () =>
|
||||
`FFT noise reduction factor.\nType: number (0.00–1.00, step 0.05)\nDefault: 0.20\nCurrent: ${viz().noiseReduction.toFixed(2)}\nj/k to −/+0.05.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
noiseReduction: Math.min(
|
||||
1,
|
||||
Math.max(0, Number((viz().noiseReduction + dir * 0.05).toFixed(2))),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "lowCutOff",
|
||||
label: "Low Cutoff",
|
||||
kind: "number",
|
||||
display: () => `${viz().lowCutOff} Hz`,
|
||||
help: () =>
|
||||
`Lower frequency cutoff.\nType: number (20–500 Hz, step 10)\nDefault: 20\nCurrent: ${viz().lowCutOff}\nj/k to −/+10.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
lowCutOff: Math.min(500, Math.max(20, viz().lowCutOff + dir * 10)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "highCutOff",
|
||||
label: "High Cutoff",
|
||||
kind: "number",
|
||||
display: () => `${viz().highCutOff} Hz`,
|
||||
help: () =>
|
||||
`Upper frequency cutoff.\nType: number (1000–20000 Hz, step 500)\nDefault: 20000\nCurrent: ${viz().highCutOff}\nj/k to −/+500.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
highCutOff: Math.min(
|
||||
20000,
|
||||
Math.max(1000, viz().highCutOff + dir * 500),
|
||||
),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
45
src/pages/Settings/types.ts
Normal file
45
src/pages/Settings/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Settings item model — each settings section exposes a list of items that the
|
||||
* SettingsPage renders through the yazi depth-stack (sections → items → editor).
|
||||
*
|
||||
* All movement flows through the Shell's nav.action router (j/k move, Enter/l
|
||||
* drill, h back), so panels no longer register their own useKeyboard — that was
|
||||
* the root cause of the "right pane ignores keys / double-handled input" bugs.
|
||||
*/
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
export type SettingItemKind =
|
||||
| "toggle"
|
||||
| "number"
|
||||
| "select"
|
||||
| "action"
|
||||
| "editor"
|
||||
| "info";
|
||||
|
||||
export interface SettingItem {
|
||||
/** Stable id within its section. */
|
||||
id: string;
|
||||
/** One-line label shown in the items list. */
|
||||
label: string;
|
||||
/** Category — decides how the item is interacted with. */
|
||||
kind: SettingItemKind;
|
||||
/** Current value as a short string (shown to the right of the label). */
|
||||
display: () => string;
|
||||
/** Help text for the preview pane: description, type, default, current. */
|
||||
help: () => string;
|
||||
/** For number/select: nudge the value by -1 or +1 (j/k at depth 2). */
|
||||
cycle?: (dir: -1 | 1) => void;
|
||||
/** For toggle: flip the value (Space/Enter at depth 1). */
|
||||
toggle?: () => void;
|
||||
/** For action: run immediately (Enter at depth 1). */
|
||||
run?: () => void;
|
||||
/** For editor: a bespoke depth-2 editor component. */
|
||||
renderEditor?: () => JSX.Element;
|
||||
}
|
||||
|
||||
export interface SettingsSectionDef {
|
||||
id: number;
|
||||
label: string;
|
||||
description: string;
|
||||
items?: () => SettingItem[];
|
||||
}
|
||||
Reference in New Issue
Block a user