pre-ui-rearch

This commit is contained in:
2026-07-31 01:05:32 -04:00
parent 89c5ca2f7e
commit 97b2f61e5f
12 changed files with 2136 additions and 1668 deletions

View File

@@ -1,19 +1,25 @@
/** /**
* Shell — yazi-style application chrome. * Shell — yazi-style application chrome.
* *
* Replaces the old left sidebar (vertical TabNavigation) with a horizontal * Renders the tabs as a vertical sidebar on the left (the root pane), the
* top tab bar, renders the active page (which owns its own panes), and adds a * active page (which owns its own panes) to the right of it, and a bottom
* bottom status/command bar. A single `useKeyboard` router translates keystrokes * status/command bar spanning the full width. A single `useKeyboard` router
* (via the sequence-aware keybind matcher) into actions: global ones (tabs, * translates keystrokes (via the sequence-aware keybind matcher) into actions:
* modes, audio, quit, help, command) are handled here; pane/list ones are * global ones (tabs, modes, audio, quit, help, command) are handled here;
* dispatched to the active page over the `nav.action` event bus. * pane/list ones are dispatched to the active page over the `nav.action`
* event bus.
*/ */
import { createSignal, Show, For } from "solid-js"; import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid"; import { useKeyboard } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext"; import {
useNavigation,
NavMode,
SIDEBAR_PANE,
DEPTH_CENTER_PANE,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
@@ -58,6 +64,19 @@ const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>(
], ],
); );
/** Movement actions the sidebar pane handles itself (its list = the tabs,
* length TabsCount). Routed through the standard move/gotoIndex API. */
const SIDEBAR_ACTIONS: ReadonlySet<KeybindActionName> = new Set([
"move-down",
"move-up",
"jump-down",
"jump-up",
"page-down",
"page-up",
"goto-top",
"goto-bottom",
]);
function tabByDigit(action: KeybindActionName): TABS | null { function tabByDigit(action: KeybindActionName): TABS | null {
if (action.startsWith("tab-goto-")) { if (action.startsWith("tab-goto-")) {
const n = Number(action.slice("tab-goto-".length)); const n = Number(action.slice("tab-goto-".length));
@@ -263,15 +282,55 @@ export function Shell() {
nav.setActiveTab(dt); nav.setActiveTab(dt);
break; break;
} }
// ── pane swipe ── // ── sidebar pane: j/k/jump/goto move through the tab list via the
// standard move/gotoIndex API (list length = TabsCount). No
// special-cased nextTab/prevTab — the sidebar is a normal pane.
if (nav.activePane() === SIDEBAR_PANE && SIDEBAR_ACTIONS.has(action)) {
evt.preventDefault();
if (action === "goto-top") nav.gotoIndex(0, TabsCount);
else if (action === "goto-bottom")
nav.gotoIndex(TabsCount - 1, TabsCount);
else {
const dir = action.endsWith("down") ? 1 : -1;
const step = action.startsWith("jump")
? 5
: action.startsWith("page")
? 10
: 1;
nav.move(dir, TabsCount, step);
}
break;
}
// ── pane swipe / depth nav ──
// h/l always uses the unified swipe() (clamped to the sidebar on
// the left). Depth-tabs additionally: l at the center drills in
// (open), h at the center pops a depth (or swipes to sidebar at
// root). Fixed-pane tabs just swipe between their panes.
if (action === "swipe-prev") { if (action === "swipe-prev") {
evt.preventDefault(); evt.preventDefault();
if (
nav.isDepthTab() &&
nav.activePane() === DEPTH_CENTER_PANE &&
nav.currentDepth() > 0
) {
nav.popDepth();
} else {
nav.swipe(-1, TabPaneCount[tab]); nav.swipe(-1, TabPaneCount[tab]);
}
break; break;
} }
if (action === "swipe-next") { if (action === "swipe-next") {
evt.preventDefault(); evt.preventDefault();
if (nav.isDepthTab() && nav.activePane() === DEPTH_CENTER_PANE) {
emit("nav.action", {
action: "open",
tab,
pane: DEPTH_CENTER_PANE,
mode: nav.mode(),
});
} else {
nav.swipe(1, TabPaneCount[tab]); nav.swipe(1, TabPaneCount[tab]);
}
break; break;
} }
// ── audio transport (global) ── // ── audio transport (global) ──
@@ -355,12 +414,16 @@ export function Shell() {
height="100%" height="100%"
backgroundColor={t.surface} backgroundColor={t.surface}
> >
{/* ── Top tab bar ─────────────────────────────────────────────────────── */} {/* ── Middle row: tab sidebar (root pane) + active page ──────────────── */}
<box flexDirection="row" flexGrow={1} width="100%">
{/* ── Left tab sidebar ─────────────────────────────────────────────── */}
<box <box
flexDirection="row" flexDirection="column"
height={1} width={14}
width="100%" height="100%"
backgroundColor={t.background} backgroundColor={t.background}
border
borderColor={t.border}
> >
<For <For
each={Object.values(TABS).filter( each={Object.values(TABS).filter(
@@ -369,14 +432,22 @@ export function Shell() {
> >
{(tab) => { {(tab) => {
const active = () => nav.activeTab() === tab; const active = () => nav.activeTab() === tab;
const focused = () =>
active() && nav.activePane() === SIDEBAR_PANE;
return ( return (
<box <box
backgroundColor={active() ? t.primary : t.background} flexDirection="row"
paddingRight={1} backgroundColor={
focused() ? t.accent : active() ? t.primary : t.background
}
paddingLeft={1} paddingLeft={1}
onMouseDown={() => nav.setActiveTab(tab)} onMouseDown={() => {
nav.setActivePane(SIDEBAR_PANE);
nav.setActiveTab(tab);
}}
> >
<text fg={active() ? t.surface : t.textMuted}> <text fg={focused() || active() ? t.surface : t.textMuted}>
{focused() ? " " : " "}
{tab}. {TAB_LABEL[tab]} {tab}. {TAB_LABEL[tab]}
</text> </text>
</box> </box>
@@ -384,15 +455,18 @@ export function Shell() {
}} }}
</For> </For>
<box flexGrow={1} backgroundColor={t.background} /> <box flexGrow={1} backgroundColor={t.background} />
<text fg={t.textMuted} paddingRight={1}> <Show when={nowPlaying()}>
{nowPlaying() ?? ""} <box paddingLeft={1} backgroundColor={t.background}>
</text> <text fg={t.textMuted}>{nowPlaying()}</text>
</box>
</Show>
</box> </box>
{/* ── Active page (owns its panes) ────────────────────────────────────── */} {/* ── Active page (owns its panes) ────────────────────────────────── */}
<box flexDirection="column" flexGrow={1} width="100%"> <box flexDirection="column" flexGrow={1} height="100%">
{LayerGraph[nav.activeTab()]()} {LayerGraph[nav.activeTab()]()}
</box> </box>
</box>
{/* ── Bottom status / command bar ─────────────────────────────────────── */} {/* ── Bottom status / command bar ─────────────────────────────────────── */}
<box <box
@@ -409,8 +483,12 @@ export function Shell() {
{modeLabel()} {modeLabel()}
</text> </text>
<text fg={t.textMuted} paddingLeft={1}> <text fg={t.textMuted} paddingLeft={1}>
{TAB_LABEL[nav.activeTab()]} · pane {nav.activePane() + 1}/ {TAB_LABEL[nav.activeTab()]} ·{" "}
{TabPaneCount[nav.activeTab()]} {nav.activePane() === SIDEBAR_PANE
? "tabs"
: nav.isDepthTab()
? `depth ${nav.currentDepth()}`
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
</text> </text>
<Show when={nav.selectedIds().length > 0}> <Show when={nav.selectedIds().length > 0}>
<text fg={t.warning} paddingLeft={1}> <text fg={t.warning} paddingLeft={1}>

View File

@@ -1,17 +1,25 @@
import { createEffect, createSignal, on, batch, createMemo } from "solid-js"; import { createEffect, createSignal, on, batch, createMemo } from "solid-js";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { TABS, TabsCount } from "@/utils/navigation"; import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
// ── Yazi-style navigation state ────────────────────────────────────────────── // ── Yazi-style navigation state ──────────────────────────────────────────────
// PodTui's interaction model after the yazi redesign. A single source of truth // Two pane models coexist:
// for: which tab is active, which pane within a tab is focused (parent |
// current | preview), the current mode (normal/visual/command/input), the
// count register (for `5j` style motions), and the command-bar buffer.
// //
// Panes are addressed by index 0..N-1 within the active tab. Each tab declares // • Depth-stack tabs (Feed, MyShows, Discover, Settings) use a yazi-style
// how many panes it has via the PaneSystem registry (see navigation.ts). h/l // depth stack. The three content columns render as:
// (swipe-prev / swipe-next) move pane focus; j/k move within the focused pane's // left = the previous depth's list (empty at depth 0)
// list (handled per-pane via the focusedIndex accessors below). // center = the current depth's list (always where focus lives)
// right = preview of the hovered item in center
// `l`/Enter drills in (push); `h` pops back (or yields to the sidebar at
// depth 0). Depth is unbounded — each page decides per-item whether an
// item is drillable and what child list kind to push.
//
// • Fixed-pane tabs (Search = input/results/detail, Player = single) keep the
// old indexed pane model (`focusedIndex(pane)` + `swipe`).
//
// The Shell's left tab sidebar is a special pane that sits *before* the
// content area. It uses SIDEBAR_PANE (-1) so the h/l chain naturally lands on
// it as the leftmost/root pane.
export enum NavMode { export enum NavMode {
NORMAL = "NORMAL", NORMAL = "NORMAL",
@@ -20,16 +28,37 @@ export enum NavMode {
INPUT = "INPUT", INPUT = "INPUT",
} }
/** Slot semantics mirror yazi's three columns. Slots beyond 2 exist for /** The tab sidebar (chrome) pane. Always the leftmost focus target. */
* tabs that need more panes (e.g. search = query/results/detail). */ export const SIDEBAR_PANE = -1 as PaneId;
/** For depth-tabs, the current-depth (center) pane is the only focusable
* content pane — index 0. The prev/preview columns are derived, not focused. */
export const DEPTH_CENTER_PANE = 0 as PaneId;
/** The sidebar pane's "list" is the tab list itself: its focus cursor is the
* active tab (1-based) minus 1, and moving/setting it switches tabs via the
* standard focusedIndex/move/gotoIndex API — no special-cased nextTab. */
/** Legacy pane-slot enums — still used by the fixed-pane Search tab. */
export enum PaneSlot { export enum PaneSlot {
PARENT = 0, // left — the container list (e.g. shows) PARENT = 0, // depth-tabs: center/current; Search: input
CURRENT = 1, // middle — the items (e.g. episodes) CURRENT = 1, // Search: results
PREVIEW = 2, // right — detail of the hovered item PREVIEW = 2, // Search: detail
} }
export type PaneId = number; // 0-based index into the active tab's pane list export type PaneId = number; // 0-based index into the active tab's pane list
// ── Depth stack ──────────────────────────────────────────────────────────────
/** One frame in a tab's depth stack. `kind` identifies the list (page-defined,
* e.g. "feeds", "episodes:feedId", "settings:sections"); `focus` is the
* focused row index within that list. `ctx` optionally carries an id or
* payload the page needs to derive the list (e.g. a feed id). */
export type DepthFrame = {
kind: string;
ctx?: string;
focus: number;
};
// ── Selection store ─────────────────────────────────────────────────────────── // ── Selection store ───────────────────────────────────────────────────────────
// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace // A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace
// its selection (e.g. "myshows:episodes"). Visual mode toggles into range // its selection (e.g. "myshows:episodes"). Visual mode toggles into range
@@ -44,14 +73,21 @@ export const { use: useNavigation, provider: NavigationProvider } =
name: "Navigation", name: "Navigation",
init: () => { init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED); const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activePane, setActivePane] = createSignal<PaneId>( // App focus starts on the left tab sidebar (root pane); tab switches
PaneSlot.CURRENT, // also return focus there.
); const [activePane, setActivePane] = createSignal<PaneId>(SIDEBAR_PANE);
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL); const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
const [count, setCount] = createSignal<number | null>(null); const [count, setCount] = createSignal<number | null>(null);
const [inputFocused, setInputFocused] = createSignal(false); const [inputFocused, setInputFocused] = createSignal(false);
// per-pane focused index (for j/k movement). Keyed by `${tab}:${pane}`. // per-tab depth stack. Depth-tabs get a root frame on first visit.
const [stacks, setStacks] = createSignal<
Partial<Record<TABS, DepthFrame[]>>
>({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] });
// per-pane focused index (for j/k movement in fixed-pane tabs). Keyed
// by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
// for pane 0 (DEPTH_CENTER_PANE) instead.
const [paneIndices, setPaneIndices] = createSignal< const [paneIndices, setPaneIndices] = createSignal<
Record<string, number> Record<string, number>
>({}); >({});
@@ -64,11 +100,22 @@ export const { use: useNavigation, provider: NavigationProvider } =
const [commandBuffer, setCommandBuffer] = createSignal(""); const [commandBuffer, setCommandBuffer] = createSignal("");
const [commandError, setCommandError] = createSignal<string | null>(null); const [commandError, setCommandError] = createSignal<string | null>(null);
// Reset depth/pane/mode on tab change. /** Depth stack for a tab (empty for fixed-pane tabs). */
const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
const ensureStack = (tab: TABS) => {
if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
}
};
// On tab change: ensure a root frame exists (depth-tabs) + reset
// focus to the sidebar, clear modes/command/visual state.
createEffect( createEffect(
on(activeTab, () => { on(activeTab, (tab) => {
ensureStack(tab);
batch(() => { batch(() => {
setActivePane(PaneSlot.CURRENT); setActivePane(SIDEBAR_PANE);
setMode(NavMode.NORMAL); setMode(NavMode.NORMAL);
setCount(null); setCount(null);
setCommandBuffer(""); setCommandBuffer("");
@@ -78,6 +125,51 @@ export const { use: useNavigation, provider: NavigationProvider } =
}), }),
); );
// ── depth stack accessors ──────────────────────────────────────────────
const depthStack = createMemo<DepthFrame[]>(() =>
depthStackFor(activeTab()),
);
const currentDepth = createMemo(() =>
Math.max(0, depthStack().length - 1),
);
const topFrame = createMemo<DepthFrame | undefined>(
() => depthStack()[depthStack().length - 1],
);
const isDepthTab = () => DEPTH_TABS.has(activeTab());
/** Focus within a given depth's frame (default = current/top). */
const depthFocus = (d: number = currentDepth()) =>
depthStack()[d]?.focus ?? 0;
const setDepthFocus = (i: number, d: number = currentDepth()) =>
setStacks((s) => {
const st = s[activeTab()];
if (!st || d < 0 || d >= st.length) return s;
const next = st.slice();
next[d] = { ...next[d], focus: i };
return { ...s, [activeTab()]: next };
});
/** Push a child frame (drill in). */
const pushDepth = (frame: DepthFrame) =>
setStacks((s) => {
const st = s[activeTab()] ?? [];
return { ...s, [activeTab()]: [...st, frame] };
});
/** Pop the top frame (go back up a depth). No-op at root. Returns
* true if a frame was popped. */
const popDepth = (): boolean => {
let popped = false;
setStacks((s) => {
const st = s[activeTab()] ?? [];
if (st.length <= 1) return s;
popped = true;
return { ...s, [activeTab()]: st.slice(0, -1) };
});
return popped;
};
// ── tab switching ────────────────────────────────────────────────────── // ── tab switching ──────────────────────────────────────────────────────
const gotoTab = (tab: TABS) => { const gotoTab = (tab: TABS) => {
if (tab < 1 || tab > TabsCount) return; if (tab < 1 || tab > TabsCount) return;
@@ -91,12 +183,12 @@ export const { use: useNavigation, provider: NavigationProvider } =
// ── pane focus ────────────────────────────────────────────────────────── // ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane); const setPane = (pane: PaneId) => setActivePane(pane);
/** Move focus to the adjacent pane. `dir` = -1 (left/parent) or +1 /** Move focus to the adjacent pane (fixed-pane tabs only). `dir` =
* (right/preview). Clamped to [0, paneCount-1]. */ * -1 (left, toward sidebar) or +1 (right, toward preview). Clamped to
* [SIDEBAR_PANE, paneCount-1]. */
const swipe = (dir: -1 | 1, paneCount: number) => { const swipe = (dir: -1 | 1, paneCount: number) => {
if (paneCount <= 1) return;
setActivePane((p) => { setActivePane((p) => {
const n = Math.max(0, Math.min(paneCount - 1, p + dir)); const n = Math.max(SIDEBAR_PANE, Math.min(paneCount - 1, p + dir));
return n; return n;
}); });
}; };
@@ -104,11 +196,31 @@ export const { use: useNavigation, provider: NavigationProvider } =
// ── per-pane focus index ──────────────────────────────────────────────── // ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
const focusedIndex = (pane: PaneId = activePane()) => /** For depth-tabs, pane 0 (center) reads/writes the top frame's
paneIndices()[paneKey(pane)] ?? 0; * focus. The sidebar pane's focus IS the active tab. Other panes
* (and fixed-pane tabs) use the per-pane map. */
const focusedIndex = (pane: PaneId = activePane()): number => {
if (pane === SIDEBAR_PANE) return activeTab() - 1;
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
return topFrame()?.focus ?? 0;
}
return paneIndices()[paneKey(pane)] ?? 0;
};
const setFocusedIndex = (pane: PaneId, index: number) => const setFocusedIndex = (pane: PaneId, index: number) => {
setPaneIndices((m) => ({ ...m, [`${activeTab()}:${pane}`]: index })); if (pane === SIDEBAR_PANE) {
gotoTab(((index + TabsCount) % TabsCount) + 1);
return;
}
if (isDepthTab() && pane === DEPTH_CENTER_PANE) {
setDepthFocus(index);
return;
}
setPaneIndices((m) => ({
...m,
[`${activeTab()}:${pane}`]: index,
}));
};
/** Apply a clamped relative motion to the active pane's focus. Returns /** Apply a clamped relative motion to the active pane's focus. Returns
* the new index so callers can update their own scroll state. */ * the new index so callers can update their own scroll state. */
@@ -259,6 +371,15 @@ export const { use: useNavigation, provider: NavigationProvider } =
visualAnchor, visualAnchor,
selections, selections,
selectedIds, selectedIds,
// depth stack
depthStack,
currentDepth,
topFrame,
depthFocus,
setDepthFocus,
pushDepth,
popDepth,
isDepthTab,
// tab // tab
setActiveTab: gotoTab, setActiveTab: gotoTab,
nextTab, nextTab,

View File

@@ -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") * depth 0 (current) — category list. Left pane empty at root.
* pane 1 (current) — podcast results for the focused category (landing pane) * depth 1 (current) — podcast results for the drilled category.
* pane 2 (preview) — detail of the focused podcast + subscribe action * 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 * `l`/Enter drills in (category → results) or subscribes (on a podcast);
* panes; j/k move within; Enter subscribes to the focused podcast; r refreshes. * `h` pops back (or yields to the sidebar at depth 0). j/k move within the
* Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering. * 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"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -17,15 +19,15 @@ import { useTheme } from "@/context/ThemeContext";
import { import {
useNavigation, useNavigation,
NavMode, NavMode,
PaneSlot, DEPTH_CENTER_PANE,
type PaneId, type PaneId,
type DepthFrame,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { Podcast } from "@/types/podcast";
import { PANE_RATIO } from "@/utils/navigation"; import { PANE_RATIO } from "@/utils/navigation";
export const DiscoverPaneCount = 3; export const DiscoverPaneCount = 1;
function DiscoverPage() { function DiscoverPage() {
const discoverStore = useDiscoverStore(); const discoverStore = useDiscoverStore();
@@ -33,83 +35,71 @@ function DiscoverPage() {
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const CATS = PaneSlot.PARENT; // 0 — categories (parent) const stack = nav.depthStack;
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane) const depth = nav.currentDepth;
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe const focus = (d: number = depth()) => nav.depthFocus(d);
const categories = () => DISCOVER_CATEGORIES; const categories = () => DISCOVER_CATEGORIES;
const podcasts = () => discoverStore.filteredPodcasts(); const podcasts = () => discoverStore.filteredPodcasts();
const focusedCategory = createMemo(() => { const focusedCatIdx = () =>
const list = categories(); categories().length === 0 ? 0 : Math.min(focus(0), categories().length - 1);
if (list.length === 0) return undefined; const focusedCategory = createMemo(() => categories()[focusedCatIdx()]);
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
}); 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 ensureFocus = () => {
const cl = categories(); if (categories().length > 0 && focus(0) >= categories().length)
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length) nav.setDepthFocus(categories().length - 1, 0);
nav.setFocusedIndex(CATS, cl.length - 1); if (podcasts().length > 0 && focus(1) >= podcasts().length)
const pl = podcasts(); nav.setDepthFocus(podcasts().length - 1, 1);
if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length)
nav.setFocusedIndex(RESULTS, pl.length - 1);
}; };
onMount(ensureFocus); 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(() => { onMount(() => {
nav.registerResolver( nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
`${nav.activeTab()}:${RESULTS}`, if (depth() === 0) return categories()[i]?.id;
(i) => podcasts()[i]?.id, return podcasts()[i]?.id;
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
}); });
onCleanup(() => unsub());
}); });
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleSubscribe = (podcast: Podcast) => {
discoverStore.toggleSubscription(podcast.id);
};
// ── nav.action handler ──────────────────────────────────────────────────── // ── drill / open ───────────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial< function open() {
Record<KeybindActionName, (pane: PaneId) => void> if (depth() === 0) {
> = {
"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(); const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id); if (!c) return;
nav.swipe(1, DiscoverPaneCount); // dive to results discoverStore.setSelectedCategory(c.id);
nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
return; return;
} }
if (p === RESULTS) { if (depth() >= 1) {
const pod = focusedPodcast(); const pod = focusedPodcast();
if (pod) handleSubscribe(pod); if (pod) discoverStore.toggleSubscription(pod.id);
} }
}, }
"toggle-select": (p) => {
if (p === RESULTS) { // ── nav.action handler ────────────────────────────────────────────────────
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(); const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id); if (pod) nav.toggleSelected(pod.id);
} }
@@ -118,28 +108,23 @@ function DiscoverPage() {
discoverStore.refresh().catch(() => {}); discoverStore.refresh().catch(() => {});
}, },
}; };
function step(delta: number) {
function len(pane: PaneId): number { nav.move(delta, curLen());
if (pane === CATS) return categories().length; // keep the store's selected category synced with the focused row at depth 0
if (pane === RESULTS) return podcasts().length; if (depth() === 0) {
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
if (pane === CATS) {
const c = focusedCategory(); const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id); if (c) discoverStore.setSelectedCategory(c.id);
} }
} }
const onAction = (data: { const onAction = (data: {
action: KeybindActionName; action: KeybindActionName;
pane: PaneId; pane: PaneId;
mode: NavMode; mode: NavMode;
}) => { }) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus(); ensureFocus();
const handler = PAGE_ACTIONS[data.action]; PAGE_ACTIONS[data.action]?.();
if (handler) handler(data.pane);
}; };
onMount(() => { onMount(() => {
on("nav.action", onAction); on("nav.action", onAction);
@@ -147,33 +132,85 @@ function DiscoverPage() {
}); });
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p; const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); const border = (active: boolean) => (active ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) => const focusBg = (i: number, lf: number, active: boolean) =>
i === nav.focusedIndex(pane) && isActive(pane) i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
? theme.primary const focusFg = (i: number, lf: number, active: boolean) =>
: i === nav.focusedIndex(pane) i === lf && active ? theme.surface : theme.text;
? theme.border const headerBg = theme.background;
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): categories ───────────────────────────── */} {/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%"> <box
<box height={1} paddingLeft={1} backgroundColor={theme.background}> 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> <text fg={theme.textSecondary}>Categories</text>
</box> </box>
<scrollbox <scrollbox
height="100%" height="100%"
focused={isActive(CATS)}
border border
borderColor={border(CATS)} borderColor={theme.border}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
<For each={categories()}>
{(cat, 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)}>
{cat.name}
</text>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
{/* ── 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}>
{depth() === 0
? "Categories"
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`}
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
{/* depth 0: categories */}
<Show when={depth() === 0}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => { {(cat, index) => {
const lf = focusedCatIdx();
const selected = () => const selected = () =>
cat.id === discoverStore.selectedCategory(); cat.id === discoverStore.selectedCategory();
return ( return (
@@ -182,29 +219,19 @@ function DiscoverPage() {
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={ backgroundColor={focusBg(index(), lf, isActive)}
selected() && !isActive(CATS)
? theme.border
: focusBg(index(), CATS)
}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(CATS); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setFocusedIndex(CATS, index()); nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id); discoverStore.setSelectedCategory(cat.id);
}} }}
> >
<text fg={focusFg(index(), CATS)}> <text fg={focusFg(index(), lf, isActive)}>
{index() === nav.focusedIndex(CATS) ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), CATS)}>{cat.name}</text> <text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
<Show when={selected()}> <Show when={selected()}>
<text <text fg={index() === lf ? theme.surface : theme.accent}>
fg={
index() === nav.focusedIndex(CATS)
? theme.surface
: theme.accent
}
>
* *
</text> </text>
</Show> </Show>
@@ -212,23 +239,10 @@ function DiscoverPage() {
); );
}} }}
</For> </For>
</scrollbox> </Show>
</box>
{/* ── pane 1 (current, center): results ───────────────────────────── */} {/* depth ≥1: results */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%"> <Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{focusedCategory()?.name ?? "Discover"} · {podcasts().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
<Show <Show
when={podcasts().length > 0} when={podcasts().length > 0}
fallback={ fallback={
@@ -238,30 +252,30 @@ function DiscoverPage() {
} }
> >
<For each={podcasts()}> <For each={podcasts()}>
{(podcast, index) => ( {(podcast, index) => {
const lf = focusedPodIdx();
return (
<box <box
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)} backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(RESULTS); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setFocusedIndex(RESULTS, index()); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}> <text fg={focusFg(index(), lf, isActive)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "} {index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
{podcast.title}
</text> </text>
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
<Show when={podcast.isSubscribed}> <Show when={podcast.isSubscribed}>
<text <text
fg={ fg={index() === lf ? theme.surface : theme.success}
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
> >
[+] [+]
</text> </text>
@@ -269,35 +283,66 @@ function DiscoverPage() {
</box> </box>
<Show when={podcast.author}> <Show when={podcast.author}>
<text <text
fg={ fg={index() === lf ? theme.surface : muted()}
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2} paddingLeft={2}
> >
by {podcast.author} by {podcast.author}
</text> </text>
</Show> </Show>
</box> </box>
)} );
}}
</For> </For>
</Show> </Show>
</Show>
</scrollbox> </scrollbox>
</box> </box>
{/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */} {/* ── right: preview ────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%"> <box
<box height={1} paddingLeft={1} backgroundColor={theme.background}> 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> <text fg={theme.textSecondary}>Preview</text>
</box> </box>
<scrollbox <scrollbox
height="100%" height="100%"
focused={isActive(PREVIEW)}
border border
borderColor={border(PREVIEW)} borderColor={theme.border}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
{/* 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 <Show
when={focusedPodcast()} when={focusedPodcast()}
fallback={ fallback={
@@ -340,10 +385,13 @@ function DiscoverPage() {
Updated: {formatDate(pod().lastUpdated)} Updated: {formatDate(pod().lastUpdated)}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes r: refresh</text> <text fg={muted()}>
enter: subscribe · h: back · r: refresh
</text>
</box> </box>
)} )}
</Show> </Show>
</Show>
</scrollbox> </scrollbox>
</box> </box>
</box> </box>

View File

@@ -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 * depth 0 (current) — subscribed feeds list (containers); index 0 is a
* "All Feeds" entry at index 0 shows every episode. * virtual "All Feeds". Left pane empty at root.
* pane 1 (current) — flat episodes list for the focused feed (reverse * depth 1 (current) — flat episodes list for the drilled feed (reverse
* chronological). This is the landing pane. * chronological). Left pane = the feeds list (prev).
* pane 2 (preview) — detail of the focused episode. * 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 * `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the
* panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio. * 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 { import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
createMemo,
For,
Show,
onMount,
onCleanup,
createEffect,
} from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
@@ -28,8 +22,9 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { import {
useNavigation, useNavigation,
NavMode, NavMode,
PaneSlot, DEPTH_CENTER_PANE,
type PaneId, type PaneId,
type DepthFrame,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
@@ -39,9 +34,10 @@ import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PANE_RATIO } from "@/utils/navigation"; import { PANE_RATIO } from "@/utils/navigation";
export const FeedPaneCount = 3; export const FeedPaneCount = 1;
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed }; type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
type EpItem = { episode: Episode; feed: Feed };
function FeedPage() { function FeedPage() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
@@ -52,72 +48,59 @@ function FeedPage() {
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent) const stack = nav.depthStack;
const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane) const depth = nav.currentDepth;
const PREV = PaneSlot.PREVIEW; // 2 — episode detail const focus = (d: number = depth()) => nav.depthFocus(d);
// ── feeds pane data ────────────────────────────────────────────────────── // ── feeds list (depth 0) ─────────────────────────────────────────────────
// Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first).
const feedList = createMemo<FeedListItem[]>(() => { const feedList = createMemo<FeedListItem[]>(() => {
const all: FeedListItem[] = [{ kind: "all" }]; const all: FeedListItem[] = [{ kind: "all" }];
for (const f of feedStore.getFilteredFeeds()) for (const f of feedStore.getFilteredFeeds())
all.push({ kind: "feed", feed: f }); all.push({ kind: "feed", feed: f });
return all; return all;
}); });
const focusedFeedItem = createMemo(() => { const focusedFeedIdx = () =>
const list = feedList(); feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
if (list.length === 0) return undefined; const focusedFeedItem = (): FeedListItem | undefined =>
return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)]; feedList()[focusedFeedIdx()];
});
// ── episodes pane data (filtered by focused feed, or all) ──────────────── // ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
type EpItem = { episode: Episode; feed: Feed }; const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
const episodes = createMemo<EpItem[]>(() => { const episodes = createMemo<EpItem[]>(() => {
const item = focusedFeedItem(); if (depth() < 1) return [];
if (!item || item.kind === "all") const id = drilledFeedId();
if (id === "all")
return feedStore.getAllEpisodesChronological() as EpItem[]; 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()) .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. const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
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 ensureFocus = () => { const ensureFocus = () => {
const eps = episodes(); if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length) nav.setDepthFocus(feedList().length - 1, 0);
nav.setFocusedIndex(EPS, eps.length - 1); if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
const fl = feedList(); nav.setDepthFocus(episodes().length - 1, 1);
if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length)
nav.setFocusedIndex(FEEDS, fl.length - 1);
}; };
onMount(ensureFocus); 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 ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy"); const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => { const formatDuration = (s: number) => {
@@ -159,24 +142,34 @@ function FeedPage() {
audioNav.setSource(AudioSource.FEED); 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 ──────────────────────────────────────────────────── // ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial< const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
Record<KeybindActionName, (pane: PaneId) => void> "move-down": () => step(1),
> = { "move-up": () => step(-1),
"move-down": (p) => step(p, 1), "jump-down": () => step(5),
"move-up": (p) => step(p, -1), "jump-up": () => step(-5),
"jump-down": (p) => step(p, 5), "page-down": () => step(10),
"jump-up": (p) => step(p, -5), "page-up": () => step(-10),
"page-down": (p) => step(p, 10), "goto-top": () => nav.gotoIndex(0, curLen()),
"page-up": (p) => step(p, -10), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
"goto-top": (p) => nav.gotoIndex(0, len(p)), open: () => open(),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), "toggle-select": () => {
open: (p) => { if (depth() >= 1) {
if (p === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes
if (p === EPS) playEpisode(focusedItem());
},
"toggle-select": (p) => {
if (p === EPS) {
const item = focusedItem(); const item = focusedItem();
if (item) nav.toggleSelected(item.episode.id); if (item) nav.toggleSelected(item.episode.id);
} }
@@ -188,24 +181,18 @@ function FeedPage() {
else feedStore.refreshAllFeeds().catch(() => {}); else feedStore.refreshAllFeeds().catch(() => {});
}, },
}; };
function step(delta: number) {
function len(pane: PaneId): number { nav.move(delta, curLen());
if (pane === FEEDS) return feedList().length;
if (pane === EPS) return episodes().length;
return 0;
} }
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
const onAction = (data: { const onAction = (data: {
action: KeybindActionName; action: KeybindActionName;
pane: PaneId; pane: PaneId;
mode: NavMode; mode: NavMode;
}) => { }) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus(); ensureFocus();
const handler = PAGE_ACTIONS[data.action]; PAGE_ACTIONS[data.action]?.();
if (handler) handler(data.pane);
}; };
onMount(() => { onMount(() => {
on("nav.action", onAction); on("nav.action", onAction);
@@ -213,31 +200,103 @@ function FeedPage() {
}); });
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p; const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); const border = (active: boolean) => (active ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) => const focusBg = (i: number, listFocus: number, active: boolean) =>
i === nav.focusedIndex(pane) && isActive(pane) i === listFocus && active
? theme.primary ? theme.primary
: i === nav.focusedIndex(pane) : i === listFocus
? theme.border ? theme.border
: undefined; : undefined;
const focusFg = (i: number, pane: PaneId) => const focusFg = (i: number, listFocus: number, active: boolean) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; 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 ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */} {/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%"> <box
<box height={1} paddingLeft={1} backgroundColor={theme.background}> flexDirection="column"
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text> 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> </box>
<scrollbox <scrollbox
height="100%" height="100%"
focused={isActive(FEEDS)}
border border
borderColor={border(FEEDS)} borderColor={theme.border}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
<For each={feedList()}>
{(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>
</scrollbox>
</Show>
</box>
{/* ── 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}>
{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}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
{/* depth 0: feeds */}
<Show when={depth() === 0}>
<Show <Show
when={feedList().length > 1} when={feedList().length > 1}
fallback={ fallback={
@@ -250,66 +309,37 @@ function FeedPage() {
> >
<For each={feedList()}> <For each={feedList()}>
{(item, index) => { {(item, index) => {
const label = () => const fi = focusedFeedIdx();
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 ( return (
<box <box
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), FEEDS)} backgroundColor={focusBg(index(), fi, isActive)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(FEEDS); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setFocusedIndex(FEEDS, index()); nav.setDepthFocus(index(), 0);
}} }}
> >
<text fg={focusFg(index(), FEEDS)}> <text fg={focusFg(index(), fi, isActive)}>
{index() === nav.focusedIndex(FEEDS) ? "" : " "} {index() === fi ? "" : " "}
</text> </text>
<text fg={focusFg(index(), FEEDS)}>{label()}</text> <text fg={focusFg(index(), fi, isActive)}>
<text {feedLabel(item)}
fg={ </text>
index() === nav.focusedIndex(FEEDS) <text fg={index() === fi ? theme.surface : muted()}>
? theme.surface ({feedCount(item)})
: muted()
}
>
({count()})
</text> </text>
</box> </box>
); );
}} }}
</For> </For>
</Show> </Show>
</scrollbox> </Show>
</box>
{/* ── pane 1 (current, center): episodes ─────────────────────────────── */} {/* depth ≥1: episodes */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%"> <Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{(() => {
const fi = focusedFeedItem();
if (fi?.kind === "feed")
return fi.feed.customName || fi.feed.podcast.title;
return "All Episodes";
})()} · {episodes().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(EPS)}
border
borderColor={border(EPS)}
backgroundColor={theme.background}
>
<Show <Show
when={episodes().length > 0} when={episodes().length > 0}
fallback={ fallback={
@@ -319,23 +349,25 @@ function FeedPage() {
} }
> >
<For each={episodes()}> <For each={episodes()}>
{(item, index) => ( {(item, index) => {
const fi = focusedEpIdx();
return (
<box <box
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), EPS)} backgroundColor={focusBg(index(), fi, isActive)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(EPS); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setFocusedIndex(EPS, index()); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), EPS)}> <text fg={focusFg(index(), fi, isActive)}>
{index() === nav.focusedIndex(EPS) ? "" : " "} {index() === fi ? "" : " "}
</text> </text>
<text fg={focusFg(index(), EPS)}> <text fg={focusFg(index(), fi, isActive)}>
{item.episode.episodeNumber {item.episode.episodeNumber
? `#${item.episode.episodeNumber} ` ? `#${item.episode.episodeNumber} `
: ""} : ""}
@@ -343,31 +375,13 @@ function FeedPage() {
</text> </text>
</box> </box>
<box flexDirection="row" gap={2} paddingLeft={2}> <box flexDirection="row" gap={2} paddingLeft={2}>
<text <text fg={index() === fi ? theme.surface : theme.info}>
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: theme.info
}
>
{formatDate(item.episode.pubDate)} {formatDate(item.episode.pubDate)}
</text> </text>
<text <text fg={index() === fi ? theme.surface : muted()}>
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{formatDuration(item.episode.duration)} {formatDuration(item.episode.duration)}
</text> </text>
<text <text fg={index() === fi ? theme.surface : muted()}>
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{item.feed.customName || item.feed.podcast.title} {item.feed.customName || item.feed.podcast.title}
</text> </text>
<Show when={nav.isSelected(item.episode.id)}> <Show when={nav.isSelected(item.episode.id)}>
@@ -380,7 +394,8 @@ function FeedPage() {
</Show> </Show>
</box> </box>
</box> </box>
)} );
}}
</For> </For>
<Show when={feedStore.isLoadingFeeds()}> <Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}> <box paddingLeft={2} paddingTop={1}>
@@ -388,21 +403,70 @@ function FeedPage() {
</box> </box>
</Show> </Show>
</Show> </Show>
</Show>
</scrollbox> </scrollbox>
</box> </box>
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */} {/* ── right: preview of hovered item ───────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%"> <box
<box height={1} paddingLeft={1} backgroundColor={theme.background}> 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> <text fg={theme.textSecondary}>Preview</text>
</box> </box>
<scrollbox <scrollbox
height="100%" height="100%"
focused={isActive(PREV)}
border border
borderColor={border(PREV)} borderColor={theme.border}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
{/* depth 0 preview: hovered feed */}
<Show when={depth() === 0}>
<Show
when={focusedFeedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No feed focused</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 <Show
when={focusedItem()} when={focusedItem()}
fallback={ fallback={
@@ -411,45 +475,51 @@ function FeedPage() {
</box> </box>
} }
> >
{(item) => ( {(item) => {
const it = item();
return (
<box flexDirection="column" gap={1} padding={1}> <box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}> <text fg={theme.textPrimary ?? theme.text}>
<strong> <strong>
{item().episode.episodeNumber {it.episode.episodeNumber
? `#${item().episode.episodeNumber} ` ? `#${it.episode.episodeNumber} `
: ""} : ""}
{item().episode.title} {it.episode.title}
</strong> </strong>
</text> </text>
<box flexDirection="row" gap={2}> <box flexDirection="row" gap={2}>
<text fg={theme.info}> <text fg={theme.info}>
{formatDate(item().episode.pubDate)} {formatDate(it.episode.pubDate)}
</text> </text>
<text fg={muted()}> <text fg={muted()}>
{formatDuration(item().episode.duration)} {formatDuration(it.episode.duration)}
</text> </text>
<Show when={downloadLabel(item().episode.id)}> <Show when={downloadLabel(it.episode.id)}>
<text fg={downloadColor(item().episode.id)}> <text fg={downloadColor(it.episode.id)}>
{downloadLabel(item().episode.id)} {downloadLabel(it.episode.id)}
</text> </text>
</Show> </Show>
</box> </box>
<text fg={muted()}> <text fg={muted()}>
{item().feed.customName || item().feed.podcast.title} {it.feed.customName || it.feed.podcast.title}
</text> </text>
<Show when={item().feed.podcast.author}> <Show when={it.feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text> <text fg={muted()}>by {it.feed.podcast.author}</text>
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ?? {it.episode.description?.slice(0, 400) ??
"No description available."} "No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""} {(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter: play space: select h/l: panes</text> <text fg={muted()}>
enter: play · space: select · h: back
</text>
</box> </box>
)} );
}}
</Show>
</Show> </Show>
</scrollbox> </scrollbox>
</box> </box>

View File

@@ -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 * depth 0 (current) — subscribed shows. Left pane empty at root.
* pane 1 (current) — episodes of the focused show * depth 1 (current) — episodes of the drilled show. Left pane = shows (prev).
* pane 2 (preview) — detail of the focused episode * 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 * `l`/Enter drills in (show → episodes); `h` pops back (or yields to the
* Shell router via the `nav.action` event bus; this page only subscribes and * sidebar at depth 0). j/k move within the current column.
* 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.
*/ */
import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
@@ -22,17 +19,19 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { import {
useNavigation, useNavigation,
NavMode, NavMode,
PaneSlot, DEPTH_CENTER_PANE,
type PaneId, type PaneId,
type DepthFrame,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode"; import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PANE_RATIO } from "@/utils/navigation"; import { PANE_RATIO } from "@/utils/navigation";
export const MyShowsPaneCount = 3; export const MyShowsPaneCount = 1;
export function MyShowsPage() { export function MyShowsPage() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
@@ -43,65 +42,46 @@ export function MyShowsPage() {
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const SHOWS = PaneSlot.PARENT; const stack = nav.depthStack;
const EPS = PaneSlot.CURRENT; const depth = nav.currentDepth;
const PREV = PaneSlot.PREVIEW; const focus = (d: number = depth()) => nav.depthFocus(d);
const shows = () => feedStore.getFilteredFeeds(); const shows = () => feedStore.getFilteredFeeds();
// The selected show tracks the focused row of pane 0. const focusedShowIdx = () =>
const selectedShow = createMemo(() => { shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
const list = shows(); const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
if (list.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1);
return list[idx];
});
const episodes = createMemo(() => { // depth-1 frame ctx = the drilled feed id
const show = selectedShow(); const drilledShowId = (): string => stack()[1]?.ctx ?? "";
if (!show) return [] as Episode[]; 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( return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(), (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(() => { onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id); nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
// keep the resolver fresh as the episode list changes if (depth() === 0) return shows()[i]?.id;
const unsub = on("nav.action", () => { return episodes()[i]?.id;
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => 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 ───────────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────────
@@ -139,35 +119,40 @@ export function MyShowsPage() {
return muted(); return muted();
} }
}; };
const playEpisode = (ep: Episode) => { const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {}); audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id); audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
}; };
// ── nav.action handler ────────────────────────────────────────────────────── // ── drill / open ───────────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial< function open() {
Record<KeybindActionName, (pane: PaneId) => void> if (depth() === 0) {
> = { const show = selectedShow();
"move-down": (p) => step(p, 1), if (!show) return;
"move-up": (p) => step(p, -1), nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
"jump-down": (p) => step(p, 5), nav.setActivePane(DEPTH_CENTER_PANE);
"jump-up": (p) => step(p, -5), audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
"page-down": (p) => step(p, 10), return;
"page-up": (p) => step(p, -10), }
"goto-top": (p) => nav.gotoIndex(0, len(p)), if (depth() >= 1) {
"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(); const ep = focusedEpisode();
if (ep) playEpisode(ep); if (ep) playEpisode(ep);
} }
}, }
"toggle-select": (p) => {
if (p === EPS) { // ── 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(); const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id); if (ep) nav.toggleSelected(ep.id);
} }
@@ -177,69 +162,106 @@ export function MyShowsPage() {
if (show) feedStore.refreshFeed(show.id).catch(() => {}); if (show) feedStore.refreshFeed(show.id).catch(() => {});
}, },
}; };
function step(delta: number) {
function len(pane: PaneId): number { nav.move(delta, curLen());
if (pane === SHOWS) return shows().length;
if (pane === EPS) return episodes().length;
return 0;
} }
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: { const onAction = (data: {
action: KeybindActionName; action: KeybindActionName;
pane: PaneId; pane: PaneId;
mode: NavMode; mode: NavMode;
}) => { }) => {
// Only react when our tab is active. if (data.pane !== DEPTH_CENTER_PANE) return;
// (Shell always emits; router guarantees our tab is active.) if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureShowsFocus(); ensureFocus();
const handler = PAGE_ACTIONS[data.action]; PAGE_ACTIONS[data.action]?.();
if (handler) handler(data.pane);
// visual selection growth is handled inside nav.move/registerResolver
}; };
onMount(() => { onMount(() => {
on("nav.action", onAction); on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction)); onCleanup(() => off("nav.action", onAction));
}); });
// ── render ────────────────────────────────────────────────────────────────── // ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p; const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); const border = (active: boolean) => (active ? theme.accent : theme.border);
const focusBg = (i: number, lf: number, active: boolean) =>
const focusBg = (i: number, pane: PaneId) => i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
i === nav.focusedIndex(pane) && isActive(pane) const focusFg = (i: number, lf: number, active: boolean) =>
? theme.primary i === lf && active ? theme.surface : theme.text;
: i === nav.focusedIndex(pane) const headerBg = theme.background;
? theme.border const showTitle = (f: Feed) => f.customName || f.podcast.title;
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: shows ─────────────────────────────────────────────────────── */} {/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%"> <box
<box height={1} paddingLeft={1} backgroundColor={theme.background}> 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> <text fg={theme.textSecondary}>Shows ({shows().length})</text>
</box> </box>
<scrollbox <scrollbox
height="100%" height="100%"
focused={isActive(SHOWS)}
border border
borderColor={border(SHOWS)} borderColor={theme.border}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
<For each={shows()}>
{(feed, index) => {
const lf = nav.depthFocus(0);
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, false)}
>
<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>
</scrollbox>
</Show>
</box>
{/* ── 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}>
{depth() === 0
? `Shows (${shows().length})`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`}
</text>
</box>
<scrollbox
height="100%"
focused={isActive}
border
borderColor={border(isActive)}
backgroundColor={theme.background}
>
{/* depth 0: shows */}
<Show when={depth() === 0}>
<Show <Show
when={shows().length > 0} when={shows().length > 0}
fallback={ fallback={
@@ -251,58 +273,38 @@ export function MyShowsPage() {
} }
> >
<For each={shows()}> <For each={shows()}>
{(feed, index) => ( {(feed, index) => {
const lf = focusedShowIdx();
return (
<box <box
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), SHOWS)} backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(SHOWS); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setFocusedIndex(SHOWS, index()); nav.setDepthFocus(index(), 0);
onShowChanged();
}} }}
> >
<text fg={focusFg(index(), SHOWS)}> <text fg={focusFg(index(), lf, isActive)}>
{index() === nav.focusedIndex(SHOWS) ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), SHOWS)}> <text fg={focusFg(index(), lf, isActive)}>
{feed.customName || feed.podcast.title} {showTitle(feed)}
</text> </text>
<text <text fg={index() === lf ? theme.surface : muted()}>
fg={
index() === nav.focusedIndex(SHOWS)
? theme.surface
: muted()
}
>
({feed.episodes.length}) ({feed.episodes.length})
</text> </text>
</box> </box>
)} );
}}
</For> </For>
</Show> </Show>
</scrollbox> </Show>
</box>
{/* ── pane 1: episodes ──────────────────────────────────────────────────── */} {/* depth ≥1: episodes */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%"> <Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{selectedShow()?.customName ||
selectedShow()?.podcast.title ||
"Episodes"}{" "}
· {episodes().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(EPS)}
border
borderColor={border(EPS)}
backgroundColor={theme.background}
>
<Show <Show
when={episodes().length > 0} when={episodes().length > 0}
fallback={ fallback={
@@ -312,44 +314,34 @@ export function MyShowsPage() {
} }
> >
<For each={episodes()}> <For each={episodes()}>
{(ep, index) => ( {(ep, index) => {
const lf = focusedEpIdx();
return (
<box <box
flexDirection="column" flexDirection="column"
gap={0} gap={0}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), EPS)} backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(EPS); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setFocusedIndex(EPS, index()); nav.setDepthFocus(index(), 1);
}} }}
> >
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={focusFg(index(), EPS)}> <text fg={focusFg(index(), lf, isActive)}>
{index() === nav.focusedIndex(EPS) ? "" : " "} {index() === lf ? "" : " "}
</text> </text>
<text fg={focusFg(index(), EPS)}> <text fg={focusFg(index(), lf, isActive)}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title} {ep.title}
</text> </text>
</box> </box>
<box flexDirection="row" gap={2} paddingLeft={2}> <box flexDirection="row" gap={2} paddingLeft={2}>
<text <text fg={index() === lf ? theme.surface : theme.info}>
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: theme.info
}
>
{formatDate(ep.pubDate)} {formatDate(ep.pubDate)}
</text> </text>
<text <text fg={index() === lf ? theme.surface : muted()}>
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{formatDuration(ep.duration)} {formatDuration(ep.duration)}
</text> </text>
<Show when={nav.isSelected(ep.id)}> <Show when={nav.isSelected(ep.id)}>
@@ -362,7 +354,8 @@ export function MyShowsPage() {
</Show> </Show>
</box> </box>
</box> </box>
)} );
}}
</For> </For>
<Show when={feedStore.isLoadingMore()}> <Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}> <box paddingLeft={2} paddingTop={1}>
@@ -370,21 +363,61 @@ export function MyShowsPage() {
</box> </box>
</Show> </Show>
</Show> </Show>
</Show>
</scrollbox> </scrollbox>
</box> </box>
{/* ── pane 2: preview ───────────────────────────────────────────────────── */} {/* ── right: preview ────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%"> <box
<box height={1} paddingLeft={1} backgroundColor={theme.background}> 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> <text fg={theme.textSecondary}>Preview</text>
</box> </box>
<scrollbox <scrollbox
height="100%" height="100%"
focused={isActive(PREV)}
border border
borderColor={border(PREV)} borderColor={theme.border}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
{/* 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 <Show
when={focusedEpisode()} when={focusedEpisode()}
fallback={ fallback={
@@ -411,7 +444,9 @@ export function MyShowsPage() {
</Show> </Show>
</box> </box>
<Show when={selectedShow()?.podcast.author}> <Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text> <text fg={muted()}>
by {selectedShow()!.podcast.author}
</text>
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
@@ -420,10 +455,13 @@ export function MyShowsPage() {
{(ep().description?.length ?? 0) > 400 ? "…" : ""} {(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter: play space: select h/l: panes</text> <text fg={muted()}>
enter: play · space: select · h: back
</text>
</box> </box>
)} )}
</Show> </Show>
</Show>
</scrollbox> </scrollbox>
</box> </box>
</box> </box>

View File

@@ -1,10 +1,12 @@
import { createSignal } from "solid-js"; /**
import { useKeyboard } from "@opentui/solid"; * PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
import { useAppStore } from "@/stores/app"; * SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
import { useTheme } from "@/context/ThemeContext"; * driven by the Shell router via nav.action.
import type { ThemeName } from "@/types/settings"; */
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 }> = [ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "system", label: "System" }, { value: "system", label: "System" },
@@ -15,145 +17,78 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "custom", label: "Custom" }, { value: "custom", label: "Custom" },
]; ];
export function PreferencesPanel() { export function usePreferencesItems(): SettingItem[] {
const appStore = useAppStore(); const app = useAppStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("theme");
const settings = () => appStore.state().settings; const settings = () => app.state().settings;
const preferences = () => appStore.state().preferences; const prefs = () => app.state().preferences;
const handleKey = (key: { name: string; shift?: boolean }) => { return [
if (key.name === "tab") { {
const fields: FocusField[] = [ id: "theme",
"theme", label: "Theme",
"font", kind: "select",
"speed", display: () =>
"explicit", THEME_LABELS.find((t) => t.value === settings().theme)?.label ??
"auto", settings().theme,
]; help: () =>
const idx = fields.indexOf(focusField()); `Color theme.\nType: select\nDefault: system\nCurrent: ${settings().theme}\nCycle with j/k; Enter to apply.`,
const next = key.shift cycle: (dir) => {
? (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 idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length; const next = (idx + dir + THEME_LABELS.length) % THEME_LABELS.length;
appStore.setTheme(THEME_LABELS[next].value); app.setTheme(THEME_LABELS[next].value);
return; },
} },
if (field === "font") { {
const next = Math.min(20, Math.max(10, settings().fontSize + delta)); id: "fontSize",
appStore.updateSettings({ fontSize: next }); label: "Font Size",
return; kind: "number",
} display: () => `${settings().fontSize}px`,
if (field === "speed") { help: () =>
`Terminal font size in pixels.\nType: number (1020)\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.52.0)\nDefault: 1.0\nCurrent: ${settings().playbackSpeed}\nj/k to /+0.1.`,
cycle: (dir) => {
const next = Math.min( const next = Math.min(
2, 2,
Math.max(0.5, settings().playbackSpeed + delta * 0.1), Math.max(0.5, settings().playbackSpeed + dir * 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>
); );
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,
}),
},
];
} }

View File

@@ -1,86 +1,199 @@
/** /**
* SettingsPage — yazi-style 2-pane view. * SettingsPage — yazi depth-stack settings.
* *
* pane 0 (parent) — section list (Sync, Sources, Preferences, ...) * depth 0 — sections list (Sync / Sources / Preferences / Visualizer / ...)
* pane 1 (current) — active panel for the focused section * 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. * Columns render as yazi's prev | current | preview:
* The panel (pane 1) reactively shows the focused section's content. * left = previous depth's list (empty at depth 0)
* Audio transport and tab/pane swipes are handled by the Shell router. * 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 { For, Show, onMount, onCleanup, createMemo } from "solid-js";
import { SourceManager } from "./SourceManager";
import { PreferencesPanel } from "./PreferencesPanel";
import { SyncPanel } from "./SyncPanel";
import { VisualizerSettings } from "./VisualizerSettings";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { import {
useNavigation, useNavigation,
NavMode, NavMode,
PaneSlot, DEPTH_CENTER_PANE,
type PaneId, type PaneId,
} from "@/context/NavigationContext"; } from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus"; import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext"; import type { KeybindActionName } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation"; 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 = [ const SECTIONS: SettingsSectionDef[] = [
{ id: 0, label: "Sync" }, {
{ id: 1, label: "Sources" }, id: 0,
{ id: 2, label: "Preferences" }, label: "Sync",
{ id: 3, label: "Visualizer" }, description: "Import/export subscriptions and sync status.",
{ id: 4, label: "Account" }, },
] as const; {
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() { export function SettingsPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const SECTIONS_PANE = PaneSlot.PARENT; // 0 const stack = nav.depthStack;
const PANEL = PaneSlot.CURRENT; // 1 const depth = nav.currentDepth;
// The focused section tracks pane 0's focused index. // ── depth 0: sections ────────────────────────────────────────────────────
const focusedSection = () => { const focusedSectionIdx = () =>
const idx = nav.focusedIndex(SECTIONS_PANE); Math.min(nav.depthFocus(0), SECTIONS.length - 1);
return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0]; const focusedSection = () => SECTIONS[focusedSectionIdx()] ?? SECTIONS[0];
// ── depth ≥1: section items (resolved from the section id stored in the
// depth-0 frame's ctx). The depth-1 frame kind is "settings:<id>". ────
const sectionForDepth1 = (): SettingsSectionDef | undefined => {
const f = stack()[1];
if (!f) return undefined;
const id = Number(f.ctx ?? "0");
return SECTIONS[id];
}; };
const items = createMemo<SettingItem[]>(() => {
// Register a resolver so visual-mode range selection grows by section id. const sec = sectionForDepth1();
onMount(() => { if (!sec) return [];
nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) => return sectionItems(sec.id);
SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(),
);
}); });
const focusedItemIdx = () =>
items().length === 0 ? 0 : Math.min(nav.depthFocus(1), items().length - 1);
const focusedItem = (): SettingItem | undefined => items()[focusedItemIdx()];
// ── nav.action handler ───────────────────────────────────────────────────── // ── depth 2: the editor item (resolved from depth-1 frame ctx + item id)
const PAGE_ACTIONS: Partial< const editorItem = (): SettingItem | undefined => {
Record<KeybindActionName, (pane: PaneId) => void> const f1 = stack()[1];
> = { const f2 = stack()[2];
"move-down": (p) => step(p, 1), if (!f1 || !f2) return undefined;
"move-up": (p) => step(p, -1), const secId = Number(f1.ctx ?? "0");
"jump-down": (p) => step(p, 5), const list = sectionItems(secId);
"jump-up": (p) => step(p, -5), return list.find((it) => it.id === f2.ctx);
"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);
}
},
}; };
function len(pane: PaneId): number { // ── drill / open dispatch ───────────────────────────────────────────────
if (pane === SECTIONS_PANE) return SECTIONS.length; function open() {
return 0; 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;
} }
function step(pane: PaneId, delta: number) { if (d === 1) {
nav.move(delta, len(pane)); const it = focusedItem();
if (!it) return;
switch (it.kind) {
case "toggle":
it.toggle?.();
return;
case "action":
it.run?.();
return;
case "info":
return;
case "editor":
case "number":
case "select":
nav.pushDepth({
kind: `settings:item:${it.id}`,
ctx: it.id,
focus: 0,
});
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
}
if (d === 2) {
// in an editor: Enter adjusts/cycles a number/select forward, toggles
const it = editorItem();
if (!it) return;
if (it.kind === "number" || it.kind === "select") it.cycle?.(1);
else if (it.kind === "toggle") it.toggle?.();
return;
}
}
// ── movement (j/k etc.) routed by the Shell over nav.action ───────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, len()),
"goto-bottom": () => nav.gotoIndex(len() - 1, len()),
open: () => open(),
};
function len(): number {
const d = depth();
if (d === 0) return SECTIONS.length;
if (d === 1) return items().length;
return 0; // depth 2 editor: no list length; j/k cycles instead
}
function step(delta: number) {
const d = depth();
if (d === 2) {
// 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: { const onAction = (data: {
@@ -88,98 +201,319 @@ export function SettingsPage() {
pane: PaneId; pane: PaneId;
mode: NavMode; 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]; const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane); if (handler) handler();
}; };
onMount(() => { onMount(() => {
on("nav.action", onAction); on("nav.action", onAction);
// keep a resolver so visual-mode range selection grows by section/item id
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
const d = depth();
if (d === 0) return SECTIONS[i]?.id.toString();
if (d === 1) return items()[i]?.id;
return undefined;
});
});
onCleanup(() => off("nav.action", onAction)); 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 ────────────────────────────────────────────────────────────────── // ── column content builders ──────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p; // left = previous depth (read-only list), or empty at depth 0
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); const LeftCol = () => (
<box
const focusBg = (i: number, pane: PaneId) => flexDirection="column"
i === nav.focusedIndex(pane) && isActive(pane) flexGrow={PANE_RATIO.parent}
? theme.primary flexShrink={1}
: i === nav.focusedIndex(pane) flexBasis={0}
? theme.border height="100%"
: undefined; style={{ width: depth() === 0 ? 0 : undefined }}
const focusFg = (i: number, pane: PaneId) => overflow="hidden"
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; >
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
return ( <text fg={theme.textSecondary}>
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <Show when={depth() >= 1} fallback=" ">
{/* ── pane 0: sections ─────────────────────────────────────────────────── */} {depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%"> </Show>
<box height={1} paddingLeft={1} backgroundColor={theme.background}> </text>
<text fg={theme.textSecondary}>Settings</text>
</box> </box>
<Show when={depth() === 1}>
<scrollbox <scrollbox
height="100%" height="100%"
focused={isActive(SECTIONS_PANE)}
border border
borderColor={border(SECTIONS_PANE)} borderColor={theme.border}
backgroundColor={theme.background} backgroundColor={theme.background}
> >
<For each={SECTIONS}> <For each={SECTIONS}>
{(section, index) => ( {(section, index) => (
<Row
label={`${section.id + 1}. ${section.label}`}
focused={index() === focusedSectionIdx()}
active={false}
/>
)}
</For>
</scrollbox>
</Show>
<Show when={depth() === 2}>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<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>
</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>
</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 <box
flexDirection="row" flexDirection="row"
gap={1} gap={1}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={focusBg(index(), SECTIONS_PANE)} backgroundColor={bg()}
onMouseDown={() => { onMouseDown={props.onMouseDown}
nav.setActivePane(SECTIONS_PANE);
nav.setFocusedIndex(SECTIONS_PANE, index());
}}
> >
<text fg={focusFg(index(), SECTIONS_PANE)}> <text fg={fg()}>{props.focused ? "" : " "}</text>
{index() === nav.focusedIndex(SECTIONS_PANE) ? "" : " "} <text fg={fg()}>{props.label}</text>
</text> <Show when={props.value}>
<text fg={focusFg(index(), SECTIONS_PANE)}> <box flexGrow={1} />
{section.label} <text fg={props.focused ? fg() : theme.textMuted}>{props.value}</text>
</text>
</box>
)}
</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>
<scrollbox
height="100%"
focused={isActive(PANEL)}
border
borderColor={border(PANEL)}
backgroundColor={theme.background}
>
<Show when={focusedSection().id === 0}>
<SyncPanel />
</Show> </Show>
<Show when={focusedSection().id === 1}> <Show when={props.hint}>
<SourceManager focused /> <text fg={theme.textMuted}>{props.hint}</text>
</Show> </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>
</box>
</Show>
</scrollbox>
</box>
</box> </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>
);
}

View File

@@ -1,317 +1,141 @@
/** /**
* Source management component for PodTUI * SourceManager — exposes podcast sources as SettingItems for the depth-stack.
* Add, remove, and configure podcast sources *
* • "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 { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { SourceType } from "@/types/source"; import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source"; import type { PodcastSource } from "@/types/source";
import { SelectableBox, SelectableText } from "@/components/Selectable"; import type { SettingItem } from "./types";
interface SourceManagerProps { export function useSourceItems(): SettingItem[] {
focused?: boolean;
onClose?: () => void;
}
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language";
export function SourceManager(props: SourceManagerProps) {
const feedStore = useFeedStore(); 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 sources = () => feedStore.sources(); const typeBadge = (s: PodcastSource) =>
s.type === SourceType.API
? "[API]"
: s.type === SourceType.RSS
? "[RSS]"
: "[?]";
const handleKeyPress = (key: { name: string; shift?: boolean }) => { const items: SettingItem[] = [
if (key.name === "escape") { {
if (focusArea() !== "list") { id: "add",
setFocusArea("list"); label: "Add Source",
setError(null); kind: "editor",
} else if (props.onClose) { display: () => "+",
props.onClose(); help: () =>
} `Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`,
return; renderEditor: () => <AddSourceForm />,
} },
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") { for (const s of feedStore.sources()) {
if (key.name === "up" || key.name === "k") { items.push({
setSelectedIndex((i) => Math.max(0, i - 1)); id: `src:${s.id}`,
} else if (key.name === "down" || key.name === "j") { label: s.name,
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1)); kind: "toggle",
} else if ( display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
key.name === "return" || help: () =>
key.name === "space" `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
) { toggle: () => feedStore.toggleSource(s.id),
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") { return items;
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 = () => { function AddSourceForm() {
const url = newSourceUrl().trim(); const feedStore = useFeedStore();
const name = newSourceName().trim() || `Custom Source`; const { theme } = useTheme();
const [name, setName] = createSignal("");
const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
if (!url) { const submit = () => {
const u = url().trim();
if (!u) {
setError("URL is required"); setError("URL is required");
return; return;
} }
try { try {
new URL(url); new URL(u);
} catch { } catch {
setError("Invalid URL format"); setError("Invalid URL format");
return; return;
} }
feedStore.addSource({ feedStore.addSource({
name, name: name().trim() || "Custom Source",
type: "rss" as SourceType, type: SourceType.RSS,
baseUrl: url, baseUrl: u,
enabled: true, enabled: true,
description: `Custom RSS feed: ${url}`, description: `Custom RSS feed: ${u}`,
}); });
setName("");
setNewSourceUrl(""); setUrl("");
setNewSourceName("");
setFocusArea("list");
setError(null); 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 ( return (
<box flexDirection="column" border borderColor={theme.border} padding={1} gap={1}> <box flexDirection="column" padding={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}> <text fg={theme.text}>
<strong>Podcast Sources</strong> <strong>Add Source</strong>
</text> </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}> <box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Name:</SelectableText> <text fg={theme.textMuted}>Name:</text>
<input <input
value={newSourceName()} value={name()}
onInput={setNewSourceName} onInput={setName}
placeholder="My Custom Feed" placeholder="My Custom Feed"
focused={props.focused && focusArea() === "add"}
width={25} width={25}
/> />
</box> </box>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>URL:</SelectableText> <text fg={theme.textMuted}>URL:</text>
<input <input
value={newSourceUrl()} value={url()}
onInput={(v) => { onInput={(v) => {
setNewSourceUrl(v); setUrl(v);
setError(null); setError(null);
}} }}
placeholder="https://example.com/feed.rss" placeholder="https://example.com/feed.rss"
focused={props.focused && focusArea() === "url"}
width={35} width={35}
/> />
</box> </box>
<box
<box border borderColor={theme.border} padding={0} width={15} onMouseDown={handleAddSource}> border
<SelectableText selected={() => false} primary>[+] Add Source</SelectableText> borderColor={theme.border}
padding={0}
width={15}
onMouseDown={submit}
>
<text fg={theme.primary}>[+] Add</text>
</box> </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> </box>
</Show>
{/* Error message */}
{error() && <SelectableText selected={() => false} tertiary>{error()}</SelectableText>}
<SelectableText selected={() => false} tertiary>Tab to switch sections, Esc to close</SelectableText>
</box> </box>
); );
} }

View File

@@ -1,32 +1,57 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => { /**
let current = value * SyncPanel — exposes Import / Export / status as SettingItems. The Import and
return [() => current, (next) => { * Export dialogs render as depth-2 editors. No own useKeyboard.
current = next */
}]
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" export function useSyncItems(): SettingItem[] {
import { ExportDialog } from "./ExportDialog" return [
import { SyncStatus } from "./SyncStatus" {
import { useTheme } from "@/context/ThemeContext" id: "import",
label: "Import",
export function SyncPanel() { kind: "editor",
const { theme } = useTheme(); display: () => "→",
const mode = createSignal<"import" | "export" | null>(null) help: () =>
`Import subscriptions from a sync file (JSON or OPML).\nDrill in (Enter/l) to open the import dialog.\nType: editor`,
return ( renderEditor: () => <ImportDialog />,
<box style={{ flexDirection: "column", gap: 1 }}> },
<box style={{ flexDirection: "row", gap: 1 }}> {
<box border borderColor={theme.border} onMouseDown={() => mode[1]("import")}> id: "export",
<text fg={theme.text}>Import</text> label: "Export",
</box> kind: "editor",
<box border borderColor={theme.border} onMouseDown={() => mode[1]("export")}> display: () => "→",
<text fg={theme.text}>Export</text> help: () =>
</box> `Export subscriptions to a sync file.\nDrill in (Enter/l) to open the export dialog.\nType: editor`,
</box> renderEditor: () => <ExportDialog />,
<SyncStatus /> },
{mode[0]() === "import" ? <ImportDialog /> : null} {
{mode[0]() === "export" ? <ExportDialog /> : null} id: "status",
</box> 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 />;
} }

View File

@@ -1,164 +1,81 @@
/** /**
* VisualizerSettings — settings panel for the real-time audio visualizer. * VisualizerSettings — exposes bars/sensitivity/noise/lowCut/highCut as
* * SettingItems for the yazi depth-stack. No own useKeyboard.
* Allows adjusting bar count, noise reduction, sensitivity, and
* frequency cutoffs. All changes persist via the app store.
*/ */
import { createSignal } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useAppStore } from "@/stores/app"; 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[] = [ return [
"bars", {
"sensitivity", id: "bars",
"noise", label: "Bars",
"lowCut", kind: "number",
"highCut", display: () => String(viz().bars),
]; help: () =>
`Number of visualizer bars.\nType: number (8128, step 8)\nDefault: 64\nCurrent: ${viz().bars}\nj/k to /+8.`,
export function VisualizerSettings() { cycle: (dir) =>
const appStore = useAppStore(); app.updateVisualizer({
const { theme } = useTheme(); bars: Math.min(128, Math.max(8, viz().bars + dir * 8)),
const [focusField, setFocusField] = createSignal<FocusField>("bars"); }),
},
const viz = () => appStore.state().settings.visualizer; {
id: "sensitivity",
const handleKey = (key: { name: string; shift?: boolean }) => { label: "Auto Sensitivity",
if (key.name === "tab") { kind: "toggle",
const idx = FIELDS.indexOf(focusField()); display: () => (viz().sensitivity === 1 ? "On" : "Off"),
const next = key.shift help: () =>
? (idx - 1 + FIELDS.length) % FIELDS.length `Automatic gain sensitivity.\nType: toggle\nDefault: on\nCurrent: ${viz().sensitivity === 1 ? "on" : "off"}\nSpace/Enter to toggle.`,
: (idx + 1) % FIELDS.length; toggle: () =>
setFocusField(FIELDS[next]); app.updateVisualizer({
return; sensitivity: viz().sensitivity === 1 ? 0 : 1,
} }),
},
if (key.name === "left" || key.name === "h") { {
stepValue(-1); id: "noiseReduction",
} label: "Noise Reduction",
if (key.name === "right" || key.name === "l") { kind: "number",
stepValue(1); display: () => viz().noiseReduction.toFixed(2),
} help: () =>
}; `FFT noise reduction factor.\nType: number (0.001.00, step 0.05)\nDefault: 0.20\nCurrent: ${viz().noiseReduction.toFixed(2)}\nj/k to /+0.05.`,
cycle: (dir) =>
const stepValue = (delta: number) => { app.updateVisualizer({
const field = focusField(); noiseReduction: Math.min(
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, 1,
Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))), Math.max(0, Number((viz().noiseReduction + dir * 0.05).toFixed(2))),
); ),
appStore.updateVisualizer({ noiseReduction: next }); }),
break; },
} {
case "lowCut": { id: "lowCutOff",
// Step by 10: 20 500 Hz label: "Low Cutoff",
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10)); kind: "number",
appStore.updateVisualizer({ lowCutOff: next }); display: () => `${viz().lowCutOff} Hz`,
break; help: () =>
} `Lower frequency cutoff.\nType: number (20500 Hz, step 10)\nDefault: 20\nCurrent: ${viz().lowCutOff}\nj/k to /+10.`,
case "highCut": { cycle: (dir) =>
// Step by 500: 1000 20000 Hz app.updateVisualizer({
const next = Math.min( 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 (100020000 Hz, step 500)\nDefault: 20000\nCurrent: ${viz().highCutOff}\nj/k to /+500.`,
cycle: (dir) =>
app.updateVisualizer({
highCutOff: Math.min(
20000, 20000,
Math.max(1000, v.highCutOff + delta * 500), Math.max(1000, viz().highCutOff + dir * 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>
);
} }

View 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[];
}

View File

@@ -20,6 +20,35 @@ export enum TABS {
} }
export const TabsCount = 6; export const TabsCount = 6;
/** Tabs that use the yazi depth-stack model (prev | current | preview
* columns, infinite drill via push/pop). Search and Player keep the legacy
* fixed-pane model. */
export const DEPTH_TABS: ReadonlySet<TABS> = new Set([
TABS.FEED,
TABS.MYSHOWS,
TABS.DISCOVER,
TABS.SETTINGS,
]);
/** Root (depth-0) frame for a depth-tab — identifies the top-level list each
* page renders at root. Pages interpret the `kind` to derive their list. */
export function rootFrameFor(
tab: TABS,
): import("@/context/NavigationContext").DepthFrame {
switch (tab) {
case TABS.FEED:
return { kind: "feeds", focus: 0 };
case TABS.MYSHOWS:
return { kind: "shows", focus: 0 };
case TABS.DISCOVER:
return { kind: "discover:categories", focus: 0 };
case TABS.SETTINGS:
return { kind: "settings:sections", focus: 0 };
default:
return { kind: "root", focus: 0 };
}
}
export const LayerGraph = { export const LayerGraph = {
[TABS.FEED]: FeedPage, [TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage, [TABS.MYSHOWS]: MyShowsPage,
@@ -47,14 +76,18 @@ export const PANE_RATIO = {
preview: 3, preview: 3,
} as const; } as const;
// Number of interactive panes per tab (for the yazi h/l swipe). Slots beyond // Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/
// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing // Settings) now have a single focusable content pane (the center/current
// the old NavigationContext top-level-init circular deadlock. // column at depth 0..N); prev and preview are derived, not focusable. Search
// keeps its 3 fixed panes; Player is single-pane. The Shell's h/l dispatch
// routes depth-tabs to push/pop instead of pane swipe. Defined here (after
// TABS) to avoid re-introducing the old NavigationContext top-level-init
// circular deadlock.
export const TabPaneCount: Record<TABS, number> = { export const TabPaneCount: Record<TABS, number> = {
[TABS.FEED]: 3, // feeds | episodes | preview [TABS.FEED]: 1, // depth: feeds episodes preview
[TABS.MYSHOWS]: 3, // shows | episodes | preview [TABS.MYSHOWS]: 1, // depth: shows episodes preview
[TABS.DISCOVER]: 3, // categories | results | detail [TABS.DISCOVER]: 1, // depth: categories results → preview
[TABS.SEARCH]: 3, // query | results | detail [TABS.SEARCH]: 3, // fixed: query | results | detail
[TABS.PLAYER]: 1, // single pane [TABS.PLAYER]: 1, // single pane
[TABS.SETTINGS]: 2, // sections | panel [TABS.SETTINGS]: 1, // depth: sections → items → editor
}; };