pre-ui-rearch
This commit is contained in:
@@ -1,19 +1,25 @@
|
||||
/**
|
||||
* Shell — yazi-style application chrome.
|
||||
*
|
||||
* Replaces the old left sidebar (vertical TabNavigation) with a horizontal
|
||||
* top tab bar, renders the active page (which owns its own panes), and adds a
|
||||
* bottom status/command bar. A single `useKeyboard` router translates keystrokes
|
||||
* (via the sequence-aware keybind matcher) into actions: global ones (tabs,
|
||||
* modes, audio, quit, help, command) are handled here; pane/list ones are
|
||||
* dispatched to the active page over the `nav.action` event bus.
|
||||
* Renders the tabs as a vertical sidebar on the left (the root pane), the
|
||||
* active page (which owns its own panes) to the right of it, and a bottom
|
||||
* status/command bar spanning the full width. A single `useKeyboard` router
|
||||
* translates keystrokes (via the sequence-aware keybind matcher) into actions:
|
||||
* global ones (tabs, modes, audio, quit, help, command) are handled here;
|
||||
* pane/list ones are dispatched to the active page over the `nav.action`
|
||||
* event bus.
|
||||
*/
|
||||
|
||||
import { createSignal, Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
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 { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
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 {
|
||||
if (action.startsWith("tab-goto-")) {
|
||||
const n = Number(action.slice("tab-goto-".length));
|
||||
@@ -263,15 +282,55 @@ export function Shell() {
|
||||
nav.setActiveTab(dt);
|
||||
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") {
|
||||
evt.preventDefault();
|
||||
if (
|
||||
nav.isDepthTab() &&
|
||||
nav.activePane() === DEPTH_CENTER_PANE &&
|
||||
nav.currentDepth() > 0
|
||||
) {
|
||||
nav.popDepth();
|
||||
} else {
|
||||
nav.swipe(-1, TabPaneCount[tab]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (action === "swipe-next") {
|
||||
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]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// ── audio transport (global) ──
|
||||
@@ -355,12 +414,16 @@ export function Shell() {
|
||||
height="100%"
|
||||
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
|
||||
flexDirection="row"
|
||||
height={1}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
width={14}
|
||||
height="100%"
|
||||
backgroundColor={t.background}
|
||||
border
|
||||
borderColor={t.border}
|
||||
>
|
||||
<For
|
||||
each={Object.values(TABS).filter(
|
||||
@@ -369,14 +432,22 @@ export function Shell() {
|
||||
>
|
||||
{(tab) => {
|
||||
const active = () => nav.activeTab() === tab;
|
||||
const focused = () =>
|
||||
active() && nav.activePane() === SIDEBAR_PANE;
|
||||
return (
|
||||
<box
|
||||
backgroundColor={active() ? t.primary : t.background}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
backgroundColor={
|
||||
focused() ? t.accent : active() ? t.primary : t.background
|
||||
}
|
||||
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]}
|
||||
</text>
|
||||
</box>
|
||||
@@ -384,15 +455,18 @@ export function Shell() {
|
||||
}}
|
||||
</For>
|
||||
<box flexGrow={1} backgroundColor={t.background} />
|
||||
<text fg={t.textMuted} paddingRight={1}>
|
||||
{nowPlaying() ?? ""}
|
||||
</text>
|
||||
<Show when={nowPlaying()}>
|
||||
<box paddingLeft={1} backgroundColor={t.background}>
|
||||
<text fg={t.textMuted}>{nowPlaying()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* ── Active page (owns its panes) ────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={1} width="100%">
|
||||
{/* ── Active page (owns its panes) ────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={1} height="100%">
|
||||
{LayerGraph[nav.activeTab()]()}
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
|
||||
<box
|
||||
@@ -409,8 +483,12 @@ export function Shell() {
|
||||
{modeLabel()}
|
||||
</text>
|
||||
<text fg={t.textMuted} paddingLeft={1}>
|
||||
{TAB_LABEL[nav.activeTab()]} · pane {nav.activePane() + 1}/
|
||||
{TabPaneCount[nav.activeTab()]}
|
||||
{TAB_LABEL[nav.activeTab()]} ·{" "}
|
||||
{nav.activePane() === SIDEBAR_PANE
|
||||
? "tabs"
|
||||
: nav.isDepthTab()
|
||||
? `depth ${nav.currentDepth()}`
|
||||
: `pane ${nav.activePane() + 1}/${TabPaneCount[nav.activeTab()]}`}
|
||||
</text>
|
||||
<Show when={nav.selectedIds().length > 0}>
|
||||
<text fg={t.warning} paddingLeft={1}>
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { createEffect, createSignal, on, batch, createMemo } from "solid-js";
|
||||
import { createSimpleContext } from "./helper";
|
||||
import { TABS, TabsCount } from "@/utils/navigation";
|
||||
import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
|
||||
|
||||
// ── Yazi-style navigation state ──────────────────────────────────────────────
|
||||
// PodTui's interaction model after the yazi redesign. A single source of truth
|
||||
// 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.
|
||||
// Two pane models coexist:
|
||||
//
|
||||
// Panes are addressed by index 0..N-1 within the active tab. Each tab declares
|
||||
// how many panes it has via the PaneSystem registry (see navigation.ts). h/l
|
||||
// (swipe-prev / swipe-next) move pane focus; j/k move within the focused pane's
|
||||
// list (handled per-pane via the focusedIndex accessors below).
|
||||
// • Depth-stack tabs (Feed, MyShows, Discover, Settings) use a yazi-style
|
||||
// depth stack. The three content columns render as:
|
||||
// left = the previous depth's list (empty at depth 0)
|
||||
// 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 {
|
||||
NORMAL = "NORMAL",
|
||||
@@ -20,16 +28,37 @@ export enum NavMode {
|
||||
INPUT = "INPUT",
|
||||
}
|
||||
|
||||
/** Slot semantics mirror yazi's three columns. Slots beyond 2 exist for
|
||||
* tabs that need more panes (e.g. search = query/results/detail). */
|
||||
/** The tab sidebar (chrome) pane. Always the leftmost focus target. */
|
||||
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 {
|
||||
PARENT = 0, // left — the container list (e.g. shows)
|
||||
CURRENT = 1, // middle — the items (e.g. episodes)
|
||||
PREVIEW = 2, // right — detail of the hovered item
|
||||
PARENT = 0, // depth-tabs: center/current; Search: input
|
||||
CURRENT = 1, // Search: results
|
||||
PREVIEW = 2, // Search: detail
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
// 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
|
||||
@@ -44,14 +73,21 @@ export const { use: useNavigation, provider: NavigationProvider } =
|
||||
name: "Navigation",
|
||||
init: () => {
|
||||
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
||||
const [activePane, setActivePane] = createSignal<PaneId>(
|
||||
PaneSlot.CURRENT,
|
||||
);
|
||||
// App focus starts on the left tab sidebar (root pane); tab switches
|
||||
// also return focus there.
|
||||
const [activePane, setActivePane] = createSignal<PaneId>(SIDEBAR_PANE);
|
||||
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
|
||||
const [count, setCount] = createSignal<number | null>(null);
|
||||
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<
|
||||
Record<string, number>
|
||||
>({});
|
||||
@@ -64,11 +100,22 @@ export const { use: useNavigation, provider: NavigationProvider } =
|
||||
const [commandBuffer, setCommandBuffer] = createSignal("");
|
||||
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(
|
||||
on(activeTab, () => {
|
||||
on(activeTab, (tab) => {
|
||||
ensureStack(tab);
|
||||
batch(() => {
|
||||
setActivePane(PaneSlot.CURRENT);
|
||||
setActivePane(SIDEBAR_PANE);
|
||||
setMode(NavMode.NORMAL);
|
||||
setCount(null);
|
||||
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 ──────────────────────────────────────────────────────
|
||||
const gotoTab = (tab: TABS) => {
|
||||
if (tab < 1 || tab > TabsCount) return;
|
||||
@@ -91,12 +183,12 @@ export const { use: useNavigation, provider: NavigationProvider } =
|
||||
// ── pane focus ──────────────────────────────────────────────────────────
|
||||
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||
|
||||
/** Move focus to the adjacent pane. `dir` = -1 (left/parent) or +1
|
||||
* (right/preview). Clamped to [0, paneCount-1]. */
|
||||
/** Move focus to the adjacent pane (fixed-pane tabs only). `dir` =
|
||||
* -1 (left, toward sidebar) or +1 (right, toward preview). Clamped to
|
||||
* [SIDEBAR_PANE, paneCount-1]. */
|
||||
const swipe = (dir: -1 | 1, paneCount: number) => {
|
||||
if (paneCount <= 1) return;
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -104,11 +196,31 @@ export const { use: useNavigation, provider: NavigationProvider } =
|
||||
// ── per-pane focus index ────────────────────────────────────────────────
|
||||
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
|
||||
|
||||
const focusedIndex = (pane: PaneId = activePane()) =>
|
||||
paneIndices()[paneKey(pane)] ?? 0;
|
||||
/** For depth-tabs, pane 0 (center) reads/writes the top frame's
|
||||
* 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) =>
|
||||
setPaneIndices((m) => ({ ...m, [`${activeTab()}:${pane}`]: index }));
|
||||
const setFocusedIndex = (pane: PaneId, index: number) => {
|
||||
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
|
||||
* the new index so callers can update their own scroll state. */
|
||||
@@ -259,6 +371,15 @@ export const { use: useNavigation, provider: NavigationProvider } =
|
||||
visualAnchor,
|
||||
selections,
|
||||
selectedIds,
|
||||
// depth stack
|
||||
depthStack,
|
||||
currentDepth,
|
||||
topFrame,
|
||||
depthFocus,
|
||||
setDepthFocus,
|
||||
pushDepth,
|
||||
popDepth,
|
||||
isDepthTab,
|
||||
// tab
|
||||
setActiveTab: gotoTab,
|
||||
nextTab,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* DiscoverPage — yazi-style 3-pane view.
|
||||
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||
*
|
||||
* pane 0 (parent) — category list (the "containers")
|
||||
* pane 1 (current) — podcast results for the focused category (landing pane)
|
||||
* pane 2 (preview) — detail of the focused podcast + subscribe action
|
||||
* depth 0 (current) — category list. Left pane empty at root.
|
||||
* depth 1 (current) — podcast results for the drilled category.
|
||||
* right (preview) — detail of the hovered item (category summary, or
|
||||
* podcast detail + subscribe action).
|
||||
*
|
||||
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
|
||||
* panes; j/k move within; Enter subscribes to the focused podcast; r refreshes.
|
||||
* Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering.
|
||||
* `l`/Enter drills in (category → results) or subscribes (on a podcast);
|
||||
* `h` pops back (or yields to the sidebar at depth 0). j/k move within the
|
||||
* current column. Moving through categories at depth 0 updates the store's
|
||||
* selected category so the preview follows.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -17,15 +19,15 @@ import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Podcast } from "@/types/podcast";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
export const DiscoverPaneCount = 3;
|
||||
export const DiscoverPaneCount = 1;
|
||||
|
||||
function DiscoverPage() {
|
||||
const discoverStore = useDiscoverStore();
|
||||
@@ -33,83 +35,71 @@ function DiscoverPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const CATS = PaneSlot.PARENT; // 0 — categories (parent)
|
||||
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane)
|
||||
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
const categories = () => DISCOVER_CATEGORIES;
|
||||
const podcasts = () => discoverStore.filteredPodcasts();
|
||||
|
||||
const focusedCategory = createMemo(() => {
|
||||
const list = categories();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
|
||||
});
|
||||
const focusedCatIdx = () =>
|
||||
categories().length === 0 ? 0 : Math.min(focus(0), categories().length - 1);
|
||||
const focusedCategory = createMemo(() => categories()[focusedCatIdx()]);
|
||||
|
||||
const focusedPodIdx = () =>
|
||||
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
||||
|
||||
const curLen = () =>
|
||||
depth() === 0 ? categories().length : podcasts().length;
|
||||
|
||||
// ── keep category + results focus in range ───────────────────────────────
|
||||
const ensureFocus = () => {
|
||||
const cl = categories();
|
||||
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length)
|
||||
nav.setFocusedIndex(CATS, cl.length - 1);
|
||||
const pl = podcasts();
|
||||
if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length)
|
||||
nav.setFocusedIndex(RESULTS, pl.length - 1);
|
||||
if (categories().length > 0 && focus(0) >= categories().length)
|
||||
nav.setDepthFocus(categories().length - 1, 0);
|
||||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
const focusedPodcast = createMemo(() => {
|
||||
const list = podcasts();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)];
|
||||
});
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by podcast id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => podcasts()[i]?.id,
|
||||
);
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${RESULTS}`,
|
||||
(i) => podcasts()[i]?.id,
|
||||
);
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return categories()[i]?.id;
|
||||
return podcasts()[i]?.id;
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
const handleSubscribe = (podcast: Podcast) => {
|
||||
discoverStore.toggleSubscription(podcast.id);
|
||||
};
|
||||
|
||||
// ── nav.action handler ────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === CATS) {
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const c = focusedCategory();
|
||||
if (c) discoverStore.setSelectedCategory(c.id);
|
||||
nav.swipe(1, DiscoverPaneCount); // dive to results
|
||||
if (!c) return;
|
||||
discoverStore.setSelectedCategory(c.id);
|
||||
nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (p === RESULTS) {
|
||||
if (depth() >= 1) {
|
||||
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();
|
||||
if (pod) nav.toggleSelected(pod.id);
|
||||
}
|
||||
@@ -118,28 +108,23 @@ function DiscoverPage() {
|
||||
discoverStore.refresh().catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === CATS) return categories().length;
|
||||
if (pane === RESULTS) return podcasts().length;
|
||||
return 0;
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
if (pane === CATS) {
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
// keep the store's selected category synced with the focused row at depth 0
|
||||
if (depth() === 0) {
|
||||
const c = focusedCategory();
|
||||
if (c) discoverStore.setSelectedCategory(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
action: KeybindActionName;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
@@ -147,33 +132,85 @@ function DiscoverPage() {
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0 (parent, left): categories ───────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Categories</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(CATS)}
|
||||
border
|
||||
borderColor={border(CATS)}
|
||||
borderColor={theme.border}
|
||||
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()}>
|
||||
{(cat, index) => {
|
||||
const lf = focusedCatIdx();
|
||||
const selected = () =>
|
||||
cat.id === discoverStore.selectedCategory();
|
||||
return (
|
||||
@@ -182,29 +219,19 @@ function DiscoverPage() {
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
selected() && !isActive(CATS)
|
||||
? theme.border
|
||||
: focusBg(index(), CATS)
|
||||
}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(CATS);
|
||||
nav.setFocusedIndex(CATS, index());
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
discoverStore.setSelectedCategory(cat.id);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), CATS)}>
|
||||
{index() === nav.focusedIndex(CATS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), CATS)}>{cat.name}</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
|
||||
<Show when={selected()}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(CATS)
|
||||
? theme.surface
|
||||
: theme.accent
|
||||
}
|
||||
>
|
||||
<text fg={index() === lf ? theme.surface : theme.accent}>
|
||||
*
|
||||
</text>
|
||||
</Show>
|
||||
@@ -212,23 +239,10 @@ function DiscoverPage() {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* ── pane 1 (current, center): results ───────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<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}
|
||||
>
|
||||
{/* depth ≥1: results */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
@@ -238,30 +252,30 @@ function DiscoverPage() {
|
||||
}
|
||||
>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => (
|
||||
{(podcast, index) => {
|
||||
const lf = focusedPodIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), RESULTS)}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(RESULTS);
|
||||
nav.setFocusedIndex(RESULTS, index());
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), RESULTS)}>
|
||||
{index() === nav.focusedIndex(RESULTS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: theme.success
|
||||
}
|
||||
fg={index() === lf ? theme.surface : theme.success}
|
||||
>
|
||||
[+]
|
||||
</text>
|
||||
@@ -269,35 +283,66 @@ function DiscoverPage() {
|
||||
</box>
|
||||
<Show when={podcast.author}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(RESULTS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
fg={index() === lf ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── right: preview ────────────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PREVIEW)}
|
||||
border
|
||||
borderColor={border(PREVIEW)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* depth 0 preview: hovered category */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={focusedCategory()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No category focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(cat) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{cat().name}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>
|
||||
{(cat() as any).description ??
|
||||
`Browse top podcasts in ${cat().name}.`}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered podcast + subscribe */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={focusedPodcast()}
|
||||
fallback={
|
||||
@@ -340,10 +385,13 @@ function DiscoverPage() {
|
||||
Updated: {formatDate(pod().lastUpdated)}
|
||||
</text>
|
||||
<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>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* FeedPage — yazi-style 3-pane view of all episodes across subscribed shows.
|
||||
* FeedPage — yazi depth-stack view of episodes across subscribed shows.
|
||||
*
|
||||
* pane 0 (parent) — subscribed feeds list (the "containers"); an implicit
|
||||
* "All Feeds" entry at index 0 shows every episode.
|
||||
* pane 1 (current) — flat episodes list for the focused feed (reverse
|
||||
* chronological). This is the landing pane.
|
||||
* pane 2 (preview) — detail of the focused episode.
|
||||
* depth 0 (current) — subscribed feeds list (containers); index 0 is a
|
||||
* virtual "All Feeds". Left pane empty at root.
|
||||
* depth 1 (current) — flat episodes list for the drilled feed (reverse
|
||||
* chronological). Left pane = the feeds list (prev).
|
||||
* right (preview) — detail of the hovered item in the current column.
|
||||
*
|
||||
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
|
||||
* panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio.
|
||||
* `l`/Enter drills in (feeds → episodes); `h` pops back (or yields to the
|
||||
* sidebar at depth 0). j/k move within the current column. The Shell router
|
||||
* drives everything over nav.action; this page only handles list/preview data.
|
||||
*/
|
||||
|
||||
import {
|
||||
createMemo,
|
||||
For,
|
||||
Show,
|
||||
onMount,
|
||||
onCleanup,
|
||||
createEffect,
|
||||
} from "solid-js";
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
@@ -28,8 +22,9 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
@@ -39,9 +34,10 @@ import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
export const FeedPaneCount = 3;
|
||||
export const FeedPaneCount = 1;
|
||||
|
||||
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
|
||||
function FeedPage() {
|
||||
const feedStore = useFeedStore();
|
||||
@@ -52,72 +48,59 @@ function FeedPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent)
|
||||
const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane)
|
||||
const PREV = PaneSlot.PREVIEW; // 2 — episode detail
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
// ── feeds pane data ──────────────────────────────────────────────────────
|
||||
// Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first).
|
||||
// ── feeds list (depth 0) ─────────────────────────────────────────────────
|
||||
const feedList = createMemo<FeedListItem[]>(() => {
|
||||
const all: FeedListItem[] = [{ kind: "all" }];
|
||||
for (const f of feedStore.getFilteredFeeds())
|
||||
all.push({ kind: "feed", feed: f });
|
||||
return all;
|
||||
});
|
||||
const focusedFeedItem = createMemo(() => {
|
||||
const list = feedList();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)];
|
||||
});
|
||||
const focusedFeedIdx = () =>
|
||||
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
|
||||
const focusedFeedItem = (): FeedListItem | undefined =>
|
||||
feedList()[focusedFeedIdx()];
|
||||
|
||||
// ── episodes pane data (filtered by focused feed, or all) ────────────────
|
||||
type EpItem = { episode: Episode; feed: Feed };
|
||||
// ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
|
||||
const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
|
||||
const episodes = createMemo<EpItem[]>(() => {
|
||||
const item = focusedFeedItem();
|
||||
if (!item || item.kind === "all")
|
||||
if (depth() < 1) return [];
|
||||
const id = drilledFeedId();
|
||||
if (id === "all")
|
||||
return feedStore.getAllEpisodesChronological() as EpItem[];
|
||||
return [...item.feed.episodes]
|
||||
const f = feedStore.getFilteredFeeds().find((x) => x.podcast.id === id);
|
||||
if (!f) return [];
|
||||
return [...f.episodes]
|
||||
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
|
||||
.map((episode) => ({ episode, feed: item.feed }));
|
||||
.map((episode) => ({ episode, feed: f }));
|
||||
});
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
|
||||
|
||||
// Reset episodes focus when the feed filter changes.
|
||||
createEffect(() => {
|
||||
focusedFeedItem();
|
||||
nav.setFocusedIndex(EPS, 0);
|
||||
});
|
||||
|
||||
const focusedItem = createMemo<EpItem | undefined>(() => {
|
||||
const list = episodes();
|
||||
if (list.length === 0) return undefined;
|
||||
return list[Math.min(nav.focusedIndex(EPS), list.length - 1)];
|
||||
});
|
||||
|
||||
// Keep resolvers fresh so visual-mode range selection grows by id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.episode.id,
|
||||
);
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
|
||||
|
||||
const ensureFocus = () => {
|
||||
const eps = episodes();
|
||||
if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length)
|
||||
nav.setFocusedIndex(EPS, eps.length - 1);
|
||||
const fl = feedList();
|
||||
if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length)
|
||||
nav.setFocusedIndex(FEEDS, fl.length - 1);
|
||||
if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
|
||||
nav.setDepthFocus(feedList().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) {
|
||||
const it = feedList()[i];
|
||||
return it?.kind === "feed" ? it.feed.podcast.id : "all";
|
||||
}
|
||||
return episodes()[i]?.episode.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
const formatDuration = (s: number) => {
|
||||
@@ -159,24 +142,34 @@ function FeedPage() {
|
||||
audioNav.setSource(AudioSource.FEED);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const item = focusedFeedItem();
|
||||
if (!item) return;
|
||||
const ctx = item.kind === "all" ? "all" : item.feed.podcast.id;
|
||||
nav.pushDepth({ kind: "episodes", ctx, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
playEpisode(focusedItem());
|
||||
}
|
||||
}
|
||||
|
||||
// ── nav.action handler ────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes
|
||||
if (p === EPS) playEpisode(focusedItem());
|
||||
},
|
||||
"toggle-select": (p) => {
|
||||
if (p === EPS) {
|
||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||
"move-down": () => step(1),
|
||||
"move-up": () => step(-1),
|
||||
"jump-down": () => step(5),
|
||||
"jump-up": () => step(-5),
|
||||
"page-down": () => step(10),
|
||||
"page-up": () => step(-10),
|
||||
"goto-top": () => nav.gotoIndex(0, curLen()),
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() >= 1) {
|
||||
const item = focusedItem();
|
||||
if (item) nav.toggleSelected(item.episode.id);
|
||||
}
|
||||
@@ -188,24 +181,18 @@ function FeedPage() {
|
||||
else feedStore.refreshAllFeeds().catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === FEEDS) return feedList().length;
|
||||
if (pane === EPS) return episodes().length;
|
||||
return 0;
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
action: KeybindActionName;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
@@ -213,31 +200,103 @@ function FeedPage() {
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
: i === listFocus
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||
i === listFocus && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
|
||||
const feedLabel = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? "All Feeds"
|
||||
: item.feed.customName || item.feed.podcast.title;
|
||||
const feedCount = (item: FeedListItem) =>
|
||||
item.kind === "all"
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text>
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
Feeds · {feedList().length - 1}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(FEEDS)}
|
||||
border
|
||||
borderColor={border(FEEDS)}
|
||||
borderColor={theme.border}
|
||||
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
|
||||
when={feedList().length > 1}
|
||||
fallback={
|
||||
@@ -250,66 +309,37 @@ function FeedPage() {
|
||||
>
|
||||
<For each={feedList()}>
|
||||
{(item, index) => {
|
||||
const label = () =>
|
||||
item.kind === "all"
|
||||
? "All Feeds"
|
||||
: item.feed.customName || item.feed.podcast.title;
|
||||
const count = () =>
|
||||
item.kind === "all"
|
||||
? feedStore.getAllEpisodesChronological().length
|
||||
: item.feed.episodes.length;
|
||||
const fi = focusedFeedIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), FEEDS)}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(FEEDS);
|
||||
nav.setFocusedIndex(FEEDS, index());
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), FEEDS)}>
|
||||
{index() === nav.focusedIndex(FEEDS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), FEEDS)}>{label()}</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(FEEDS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
({count()})
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{feedLabel(item)}
|
||||
</text>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
({feedCount(item)})
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* ── pane 1 (current, center): episodes ─────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<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}
|
||||
>
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
@@ -319,23 +349,25 @@ function FeedPage() {
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => (
|
||||
{(item, index) => {
|
||||
const fi = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), EPS)}
|
||||
backgroundColor={focusBg(index(), fi, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(EPS);
|
||||
nav.setFocusedIndex(EPS, index());
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{index() === nav.focusedIndex(EPS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{index() === fi ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
<text fg={focusFg(index(), fi, isActive)}>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
@@ -343,31 +375,13 @@ function FeedPage() {
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: theme.info
|
||||
}
|
||||
>
|
||||
<text fg={index() === fi ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
<text fg={index() === fi ? theme.surface : muted()}>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
@@ -380,7 +394,8 @@ function FeedPage() {
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
@@ -388,21 +403,70 @@ function FeedPage() {
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── right: preview of hovered item ───────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PREV)}
|
||||
border
|
||||
borderColor={border(PREV)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* 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
|
||||
when={focusedItem()}
|
||||
fallback={
|
||||
@@ -411,45 +475,51 @@ function FeedPage() {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
{(item) => {
|
||||
const it = item();
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
{it.episode.episodeNumber
|
||||
? `#${it.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
{it.episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(item().episode.pubDate)}
|
||||
{formatDate(it.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{formatDuration(item().episode.duration)}
|
||||
{formatDuration(it.episode.duration)}
|
||||
</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
<Show when={downloadLabel(it.episode.id)}>
|
||||
<text fg={downloadColor(it.episode.id)}>
|
||||
{downloadLabel(it.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
{it.feed.customName || it.feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
<Show when={it.feed.podcast.author}>
|
||||
<text fg={muted()}>by {it.feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
{it.episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<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>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* MyShowsPage — yazi-style 3-pane view (canonical reference migration).
|
||||
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||
*
|
||||
* pane 0 (parent) — subscribed shows
|
||||
* pane 1 (current) — episodes of the focused show
|
||||
* pane 2 (preview) — detail of the focused episode
|
||||
* depth 0 (current) — subscribed shows. Left pane empty at root.
|
||||
* depth 1 (current) — episodes of the drilled show. Left pane = shows (prev).
|
||||
* right (preview) — detail of the hovered item in the current column.
|
||||
*
|
||||
* Movement (j/k, gg/G, page-jumps) and selection (space, v) are driven by the
|
||||
* Shell router via the `nav.action` event bus; this page only subscribes and
|
||||
* translates actions against its own data. h/l swipe between panes is handled
|
||||
* by the Shell (nav.swipe). The focused row is read from nav.focusedIndex(pane)
|
||||
* so the page is purely reactive.
|
||||
* `l`/Enter drills in (show → episodes); `h` pops back (or yields to the
|
||||
* sidebar at depth 0). j/k move within the current column.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
@@ -22,17 +19,19 @@ import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
type DepthFrame,
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
export const MyShowsPaneCount = 3;
|
||||
export const MyShowsPaneCount = 1;
|
||||
|
||||
export function MyShowsPage() {
|
||||
const feedStore = useFeedStore();
|
||||
@@ -43,65 +42,46 @@ export function MyShowsPage() {
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const SHOWS = PaneSlot.PARENT;
|
||||
const EPS = PaneSlot.CURRENT;
|
||||
const PREV = PaneSlot.PREVIEW;
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
const shows = () => feedStore.getFilteredFeeds();
|
||||
|
||||
// The selected show tracks the focused row of pane 0.
|
||||
const selectedShow = createMemo(() => {
|
||||
const list = shows();
|
||||
if (list.length === 0) return undefined;
|
||||
const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1);
|
||||
return list[idx];
|
||||
});
|
||||
const focusedShowIdx = () =>
|
||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
||||
|
||||
const episodes = createMemo(() => {
|
||||
const show = selectedShow();
|
||||
if (!show) return [] as Episode[];
|
||||
// depth-1 frame ctx = the drilled feed id
|
||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||
const episodes = createMemo<Episode[]>(() => {
|
||||
if (depth() < 1) return [];
|
||||
const id = drilledShowId();
|
||||
const show = shows().find((s) => s.id === id);
|
||||
if (!show) return [];
|
||||
return [...show.episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
});
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||
|
||||
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (shows().length > 0 && focus(0) >= shows().length)
|
||||
nav.setDepthFocus(shows().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by episode id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id);
|
||||
// keep the resolver fresh as the episode list changes
|
||||
const unsub = on("nav.action", () => {
|
||||
nav.registerResolver(
|
||||
`${nav.activeTab()}:${EPS}`,
|
||||
(i) => episodes()[i]?.id,
|
||||
);
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return shows()[i]?.id;
|
||||
return episodes()[i]?.id;
|
||||
});
|
||||
onCleanup(() => unsub());
|
||||
});
|
||||
|
||||
// Keep shows-focus in range after feeds load/change.
|
||||
const ensureShowsFocus = () => {
|
||||
const list = shows();
|
||||
if (list.length === 0) return;
|
||||
const cur = nav.focusedIndex(SHOWS);
|
||||
if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1);
|
||||
};
|
||||
onMount(ensureShowsFocus);
|
||||
|
||||
// When the show changes, reset episode focus + set audio-nav source + show count.
|
||||
const onShowChanged = () => {
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
if (nav.focusedIndex(EPS) > episodes().length - 1)
|
||||
nav.setFocusedIndex(EPS, 0);
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
||||
};
|
||||
onMount(onShowChanged);
|
||||
|
||||
const focusedEpisode = createMemo(() => {
|
||||
const eps = episodes();
|
||||
if (eps.length === 0) return undefined;
|
||||
const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1);
|
||||
return eps[idx];
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -139,35 +119,40 @@ export function MyShowsPage() {
|
||||
return muted();
|
||||
}
|
||||
};
|
||||
|
||||
const playEpisode = (ep: Episode) => {
|
||||
audio.play(ep).catch(() => {});
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === SHOWS) {
|
||||
nav.swipe(1, MyShowsPaneCount);
|
||||
onShowChanged();
|
||||
} else if (p === EPS) {
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) playEpisode(ep);
|
||||
}
|
||||
},
|
||||
"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();
|
||||
if (ep) nav.toggleSelected(ep.id);
|
||||
}
|
||||
@@ -177,69 +162,106 @@ export function MyShowsPage() {
|
||||
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === SHOWS) return shows().length;
|
||||
if (pane === EPS) return episodes().length;
|
||||
return 0;
|
||||
function step(delta: number) {
|
||||
nav.move(delta, curLen());
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
if (pane === SHOWS) {
|
||||
// clamp episode focus + re-resolve after show change
|
||||
nav.setFocusedIndex(
|
||||
EPS,
|
||||
Math.min(nav.focusedIndex(EPS), Math.max(0, episodes().length - 1)),
|
||||
);
|
||||
onShowChanged();
|
||||
}
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
action: KeybindActionName;
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
// Only react when our tab is active.
|
||||
// (Shell always emits; router guarantees our tab is active.)
|
||||
ensureShowsFocus();
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
// visual selection growth is handled inside nav.move/registerResolver
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
ensureFocus();
|
||||
PAGE_ACTIONS[data.action]?.();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
onCleanup(() => off("nav.action", onAction));
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||
i === lf && active ? theme.surface : theme.text;
|
||||
const headerBg = theme.background;
|
||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0: shows ─────────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Show when={depth() >= 1}>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(SHOWS)}
|
||||
border
|
||||
borderColor={border(SHOWS)}
|
||||
borderColor={theme.border}
|
||||
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
|
||||
when={shows().length > 0}
|
||||
fallback={
|
||||
@@ -251,58 +273,38 @@ export function MyShowsPage() {
|
||||
}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => (
|
||||
{(feed, index) => {
|
||||
const lf = focusedShowIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), SHOWS)}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(SHOWS);
|
||||
nav.setFocusedIndex(SHOWS, index());
|
||||
onShowChanged();
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), SHOWS)}>
|
||||
{index() === nav.focusedIndex(SHOWS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), SHOWS)}>
|
||||
{feed.customName || feed.podcast.title}
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(SHOWS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* ── pane 1: episodes ──────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||
<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}
|
||||
>
|
||||
{/* depth ≥1: episodes */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
@@ -312,44 +314,34 @@ export function MyShowsPage() {
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => (
|
||||
{(ep, index) => {
|
||||
const lf = focusedEpIdx();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), EPS)}
|
||||
backgroundColor={focusBg(index(), lf, isActive)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(EPS);
|
||||
nav.setFocusedIndex(EPS, index());
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
{index() === nav.focusedIndex(EPS) ? "❯" : " "}
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{index() === lf ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), EPS)}>
|
||||
<text fg={focusFg(index(), lf, isActive)}>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: theme.info
|
||||
}
|
||||
>
|
||||
<text fg={index() === lf ? theme.surface : theme.info}>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
index() === nav.focusedIndex(EPS)
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
<text fg={index() === lf ? theme.surface : muted()}>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
@@ -362,7 +354,8 @@ export function MyShowsPage() {
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
@@ -370,21 +363,61 @@ export function MyShowsPage() {
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
||||
{/* ── pane 2: preview ───────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
{/* ── right: preview ────────────────────────────────────────────────── */}
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.preview}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>Preview</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(PREV)}
|
||||
border
|
||||
borderColor={border(PREV)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
{/* depth 0 preview: hovered show */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No show focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(show) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{showTitle(show())}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>
|
||||
{show().episodes.length} episodes
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ??
|
||||
"No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter/l: open · h: back</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* depth ≥1 preview: hovered episode */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
@@ -411,7 +444,9 @@ export function MyShowsPage() {
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={selectedShow()?.podcast.author}>
|
||||
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
||||
<text fg={muted()}>
|
||||
by {selectedShow()!.podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
@@ -420,10 +455,13 @@ export function MyShowsPage() {
|
||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<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>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import type { ThemeName } from "@/types/settings";
|
||||
/**
|
||||
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
|
||||
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
|
||||
* driven by the Shell router via nav.action.
|
||||
*/
|
||||
|
||||
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import type { ThemeName } from "@/types/settings";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||
{ value: "system", label: "System" },
|
||||
@@ -15,145 +17,78 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
export function PreferencesPanel() {
|
||||
const appStore = useAppStore();
|
||||
const { theme } = useTheme();
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("theme");
|
||||
export function usePreferencesItems(): SettingItem[] {
|
||||
const app = useAppStore();
|
||||
|
||||
const settings = () => appStore.state().settings;
|
||||
const preferences = () => appStore.state().preferences;
|
||||
const settings = () => app.state().settings;
|
||||
const prefs = () => app.state().preferences;
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const fields: FocusField[] = [
|
||||
"theme",
|
||||
"font",
|
||||
"speed",
|
||||
"explicit",
|
||||
"auto",
|
||||
];
|
||||
const idx = fields.indexOf(focusField());
|
||||
const next = key.shift
|
||||
? (idx - 1 + fields.length) % fields.length
|
||||
: (idx + 1) % fields.length;
|
||||
setFocusField(fields[next]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1);
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1);
|
||||
}
|
||||
if (key.name === "space" || key.name === "return") {
|
||||
toggleValue();
|
||||
}
|
||||
};
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField();
|
||||
if (field === "theme") {
|
||||
return [
|
||||
{
|
||||
id: "theme",
|
||||
label: "Theme",
|
||||
kind: "select",
|
||||
display: () =>
|
||||
THEME_LABELS.find((t) => t.value === settings().theme)?.label ??
|
||||
settings().theme,
|
||||
help: () =>
|
||||
`Color theme.\nType: select\nDefault: system\nCurrent: ${settings().theme}\nCycle with j/k; Enter to apply.`,
|
||||
cycle: (dir) => {
|
||||
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
|
||||
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length;
|
||||
appStore.setTheme(THEME_LABELS[next].value);
|
||||
return;
|
||||
}
|
||||
if (field === "font") {
|
||||
const next = Math.min(20, Math.max(10, settings().fontSize + delta));
|
||||
appStore.updateSettings({ fontSize: next });
|
||||
return;
|
||||
}
|
||||
if (field === "speed") {
|
||||
const next = (idx + dir + THEME_LABELS.length) % THEME_LABELS.length;
|
||||
app.setTheme(THEME_LABELS[next].value);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font Size",
|
||||
kind: "number",
|
||||
display: () => `${settings().fontSize}px`,
|
||||
help: () =>
|
||||
`Terminal font size in pixels.\nType: number (10–20)\nDefault: 14\nCurrent: ${settings().fontSize}\nj/k to −/+1px.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.min(20, Math.max(10, settings().fontSize + dir));
|
||||
app.updateSettings({ fontSize: next });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "playbackSpeed",
|
||||
label: "Playback Speed",
|
||||
kind: "number",
|
||||
display: () => `${settings().playbackSpeed}x`,
|
||||
help: () =>
|
||||
`Default audio playback speed.\nType: number (0.5–2.0)\nDefault: 1.0\nCurrent: ${settings().playbackSpeed}\nj/k to −/+0.1.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.min(
|
||||
2,
|
||||
Math.max(0.5, settings().playbackSpeed + delta * 0.1),
|
||||
);
|
||||
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleValue = () => {
|
||||
const field = focusField();
|
||||
if (field === "explicit") {
|
||||
appStore.updatePreferences({ showExplicit: !preferences().showExplicit });
|
||||
}
|
||||
if (field === "auto") {
|
||||
appStore.updatePreferences({ autoDownload: !preferences().autoDownload });
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard(handleKey);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.textMuted}>Preferences</text>
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
|
||||
Theme:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>
|
||||
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
|
||||
Font Size:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{settings().fontSize}px</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
|
||||
Playback:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{settings().playbackSpeed}x</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={focusField() === "explicit" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
Show Explicit:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text
|
||||
fg={preferences().showExplicit ? theme.success : theme.textMuted}
|
||||
>
|
||||
{preferences().showExplicit ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Space]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
|
||||
Auto Download:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text
|
||||
fg={preferences().autoDownload ? theme.success : theme.textMuted}
|
||||
>
|
||||
{preferences().autoDownload ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Space]</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
Math.max(0.5, settings().playbackSpeed + dir * 0.1),
|
||||
);
|
||||
app.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "showExplicit",
|
||||
label: "Show Explicit",
|
||||
kind: "toggle",
|
||||
display: () => (prefs().showExplicit ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Whether to list explicit episodes.\nType: toggle\nDefault: true\nCurrent: ${prefs().showExplicit}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updatePreferences({
|
||||
showExplicit: !prefs().showExplicit,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "autoDownload",
|
||||
label: "Auto Download",
|
||||
kind: "toggle",
|
||||
display: () => (prefs().autoDownload ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updatePreferences({
|
||||
autoDownload: !prefs().autoDownload,
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,86 +1,199 @@
|
||||
/**
|
||||
* SettingsPage — yazi-style 2-pane view.
|
||||
* SettingsPage — yazi depth-stack settings.
|
||||
*
|
||||
* pane 0 (parent) — section list (Sync, Sources, Preferences, ...)
|
||||
* pane 1 (current) — active panel for the focused section
|
||||
* depth 0 — sections list (Sync / Sources / Preferences / Visualizer / ...)
|
||||
* depth 1 — the focused section's items as a navigable list
|
||||
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
||||
*
|
||||
* Movement (j/k, gg/G, page-jumps) on pane 0 navigates the section list.
|
||||
* The panel (pane 1) reactively shows the focused section's content.
|
||||
* Audio transport and tab/pane swipes are handled by the Shell router.
|
||||
* Columns render as yazi's prev | current | preview:
|
||||
* left = previous depth's list (empty at depth 0)
|
||||
* right = preview/help text for the hovered item in center
|
||||
*
|
||||
* All movement comes from the Shell router over `nav.action` (j/k move,
|
||||
* Enter/l drill, h back). Panels no longer register their own useKeyboard —
|
||||
* that was the root cause of the old right-pane key conflicts.
|
||||
*/
|
||||
|
||||
import { For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { SourceManager } from "./SourceManager";
|
||||
import { PreferencesPanel } from "./PreferencesPanel";
|
||||
import { SyncPanel } from "./SyncPanel";
|
||||
import { VisualizerSettings } from "./VisualizerSettings";
|
||||
import { For, Show, onMount, onCleanup, createMemo } from "solid-js";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
useNavigation,
|
||||
NavMode,
|
||||
PaneSlot,
|
||||
DEPTH_CENTER_PANE,
|
||||
type PaneId,
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||
import { usePreferencesItems } from "./PreferencesPanel";
|
||||
import { useVisualizerItems } from "./VisualizerSettings";
|
||||
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||
import { useSourceItems } from "./SourceManager";
|
||||
|
||||
export const SettingsPaneCount = 2;
|
||||
export const SettingsPaneCount = 1;
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 0, label: "Sync" },
|
||||
{ id: 1, label: "Sources" },
|
||||
{ id: 2, label: "Preferences" },
|
||||
{ id: 3, label: "Visualizer" },
|
||||
{ id: 4, label: "Account" },
|
||||
] as const;
|
||||
const SECTIONS: SettingsSectionDef[] = [
|
||||
{
|
||||
id: 0,
|
||||
label: "Sync",
|
||||
description: "Import/export subscriptions and sync status.",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
label: "Sources",
|
||||
description: "Podcast search/RSS sources — add, enable, remove.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: "Preferences",
|
||||
description: "Theme, font, playback speed, explicit/auto-download.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: "Visualizer",
|
||||
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: "Account",
|
||||
description: "Account login & OAuth (not yet implemented).",
|
||||
},
|
||||
];
|
||||
|
||||
/** Resolve the items for a section id at render time. Section 4 (Account) has
|
||||
* no items yet. */
|
||||
function sectionItems(sectionId: number): SettingItem[] {
|
||||
switch (sectionId) {
|
||||
case 0:
|
||||
return useSyncItems();
|
||||
case 1:
|
||||
return useSourceItems();
|
||||
case 2:
|
||||
return usePreferencesItems();
|
||||
case 3:
|
||||
return useVisualizerItems();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
|
||||
const SECTIONS_PANE = PaneSlot.PARENT; // 0
|
||||
const PANEL = PaneSlot.CURRENT; // 1
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
|
||||
// The focused section tracks pane 0's focused index.
|
||||
const focusedSection = () => {
|
||||
const idx = nav.focusedIndex(SECTIONS_PANE);
|
||||
return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0];
|
||||
// ── depth 0: sections ────────────────────────────────────────────────────
|
||||
const focusedSectionIdx = () =>
|
||||
Math.min(nav.depthFocus(0), SECTIONS.length - 1);
|
||||
const focusedSection = () => SECTIONS[focusedSectionIdx()] ?? SECTIONS[0];
|
||||
|
||||
// ── depth ≥1: section items (resolved from the section id stored in the
|
||||
// depth-0 frame's ctx). The depth-1 frame kind is "settings:<id>". ────
|
||||
const sectionForDepth1 = (): SettingsSectionDef | undefined => {
|
||||
const f = stack()[1];
|
||||
if (!f) return undefined;
|
||||
const id = Number(f.ctx ?? "0");
|
||||
return SECTIONS[id];
|
||||
};
|
||||
|
||||
// Register a resolver so visual-mode range selection grows by section id.
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) =>
|
||||
SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(),
|
||||
);
|
||||
const items = createMemo<SettingItem[]>(() => {
|
||||
const sec = sectionForDepth1();
|
||||
if (!sec) return [];
|
||||
return sectionItems(sec.id);
|
||||
});
|
||||
const focusedItemIdx = () =>
|
||||
items().length === 0 ? 0 : Math.min(nav.depthFocus(1), items().length - 1);
|
||||
const focusedItem = (): SettingItem | undefined => items()[focusedItemIdx()];
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<
|
||||
Record<KeybindActionName, (pane: PaneId) => void>
|
||||
> = {
|
||||
"move-down": (p) => step(p, 1),
|
||||
"move-up": (p) => step(p, -1),
|
||||
"jump-down": (p) => step(p, 5),
|
||||
"jump-up": (p) => step(p, -5),
|
||||
"page-down": (p) => step(p, 10),
|
||||
"page-up": (p) => step(p, -10),
|
||||
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||
open: (p) => {
|
||||
if (p === SECTIONS_PANE) {
|
||||
nav.swipe(1, SettingsPaneCount);
|
||||
}
|
||||
},
|
||||
// ── depth 2: the editor item (resolved from depth-1 frame ctx + item id) ─
|
||||
const editorItem = (): SettingItem | undefined => {
|
||||
const f1 = stack()[1];
|
||||
const f2 = stack()[2];
|
||||
if (!f1 || !f2) return undefined;
|
||||
const secId = Number(f1.ctx ?? "0");
|
||||
const list = sectionItems(secId);
|
||||
return list.find((it) => it.id === f2.ctx);
|
||||
};
|
||||
|
||||
function len(pane: PaneId): number {
|
||||
if (pane === SECTIONS_PANE) return SECTIONS.length;
|
||||
return 0;
|
||||
// ── drill / open dispatch ───────────────────────────────────────────────
|
||||
function open() {
|
||||
const d = depth();
|
||||
if (d === 0) {
|
||||
// drill into the focused section's items
|
||||
const id = focusedSection().id;
|
||||
nav.pushDepth({
|
||||
kind: `settings:${id}`,
|
||||
ctx: String(id),
|
||||
focus: 0,
|
||||
});
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
function step(pane: PaneId, delta: number) {
|
||||
nav.move(delta, len(pane));
|
||||
if (d === 1) {
|
||||
const it = focusedItem();
|
||||
if (!it) return;
|
||||
switch (it.kind) {
|
||||
case "toggle":
|
||||
it.toggle?.();
|
||||
return;
|
||||
case "action":
|
||||
it.run?.();
|
||||
return;
|
||||
case "info":
|
||||
return;
|
||||
case "editor":
|
||||
case "number":
|
||||
case "select":
|
||||
nav.pushDepth({
|
||||
kind: `settings:item:${it.id}`,
|
||||
ctx: it.id,
|
||||
focus: 0,
|
||||
});
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (d === 2) {
|
||||
// in an editor: Enter adjusts/cycles a number/select forward, toggles
|
||||
const it = editorItem();
|
||||
if (!it) return;
|
||||
if (it.kind === "number" || it.kind === "select") it.cycle?.(1);
|
||||
else if (it.kind === "toggle") it.toggle?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── movement (j/k etc.) routed by the Shell over nav.action ───────────────
|
||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||
"move-down": () => step(1),
|
||||
"move-up": () => step(-1),
|
||||
"jump-down": () => step(5),
|
||||
"jump-up": () => step(-5),
|
||||
"page-down": () => step(10),
|
||||
"page-up": () => step(-10),
|
||||
"goto-top": () => nav.gotoIndex(0, len()),
|
||||
"goto-bottom": () => nav.gotoIndex(len() - 1, len()),
|
||||
open: () => open(),
|
||||
};
|
||||
|
||||
function len(): number {
|
||||
const d = depth();
|
||||
if (d === 0) return SECTIONS.length;
|
||||
if (d === 1) return items().length;
|
||||
return 0; // depth 2 editor: no list length; j/k cycles instead
|
||||
}
|
||||
function step(delta: number) {
|
||||
const d = depth();
|
||||
if (d === 2) {
|
||||
// editor: j/k nudges the value
|
||||
const it = editorItem();
|
||||
if (it?.kind === "number" || it?.kind === "select")
|
||||
it.cycle?.(delta as -1 | 1);
|
||||
return;
|
||||
}
|
||||
nav.move(delta, len());
|
||||
}
|
||||
|
||||
const onAction = (data: {
|
||||
@@ -88,98 +201,319 @@ export function SettingsPage() {
|
||||
pane: PaneId;
|
||||
mode: NavMode;
|
||||
}) => {
|
||||
// ignore actions meant for non-center panes
|
||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||
const handler = PAGE_ACTIONS[data.action];
|
||||
if (handler) handler(data.pane);
|
||||
if (handler) handler();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
on("nav.action", onAction);
|
||||
// keep a resolver so visual-mode range selection grows by section/item id
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
const d = depth();
|
||||
if (d === 0) return SECTIONS[i]?.id.toString();
|
||||
if (d === 1) return items()[i]?.id;
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
onCleanup(() => off("nav.action", onAction));
|
||||
|
||||
// when leaving a sync editor (h to pop), close any open dialog overlay
|
||||
onCleanup(() => closeSyncEditor());
|
||||
|
||||
// ── render helpers ───────────────────────────────────────────────────────
|
||||
const isActive = nav.activePane() === DEPTH_CENTER_PANE;
|
||||
const border = (active: boolean) => (active ? theme.accent : theme.border);
|
||||
const headerBg = theme.background;
|
||||
|
||||
// preview text for the right column
|
||||
const previewText = createMemo<string>(() => {
|
||||
const d = depth();
|
||||
if (d === 0) {
|
||||
return `${focusedSection().label}\n\n${focusedSection().description}\n\nDrill in (Enter/l) to open this section's settings.`;
|
||||
}
|
||||
if (d === 1) {
|
||||
const it = focusedItem();
|
||||
return it?.help() ?? "No item.";
|
||||
}
|
||||
// editor: same help, plus note
|
||||
const it = editorItem();
|
||||
return it
|
||||
? `${it.help()}\n\n— Editor —\nj/k adjust · h back`
|
||||
: "No editor.";
|
||||
});
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────────
|
||||
const isActive = (p: PaneId) => nav.activePane() === p;
|
||||
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
|
||||
|
||||
const focusBg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane)
|
||||
? theme.primary
|
||||
: i === nav.focusedIndex(pane)
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (i: number, pane: PaneId) =>
|
||||
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── pane 0: sections ─────────────────────────────────────────────────── */}
|
||||
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||
<text fg={theme.textSecondary}>Settings</text>
|
||||
// ── column content builders ──────────────────────────────────────────────
|
||||
// left = previous depth (read-only list), or empty at depth 0
|
||||
const LeftCol = () => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={PANE_RATIO.parent}
|
||||
flexShrink={1}
|
||||
flexBasis={0}
|
||||
height="100%"
|
||||
style={{ width: depth() === 0 ? 0 : undefined }}
|
||||
overflow="hidden"
|
||||
>
|
||||
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
|
||||
<text fg={theme.textSecondary}>
|
||||
<Show when={depth() >= 1} fallback=" ">
|
||||
{depth() === 1 ? "Sections" : (sectionForDepth1()?.label ?? "")}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={depth() === 1}>
|
||||
<scrollbox
|
||||
height="100%"
|
||||
focused={isActive(SECTIONS_PANE)}
|
||||
border
|
||||
borderColor={border(SECTIONS_PANE)}
|
||||
borderColor={theme.border}
|
||||
backgroundColor={theme.background}
|
||||
>
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<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
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), SECTIONS_PANE)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(SECTIONS_PANE);
|
||||
nav.setFocusedIndex(SECTIONS_PANE, index());
|
||||
}}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<text fg={focusFg(index(), SECTIONS_PANE)}>
|
||||
{index() === nav.focusedIndex(SECTIONS_PANE) ? "❯" : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), SECTIONS_PANE)}>
|
||||
{section.label}
|
||||
</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 />
|
||||
<text fg={fg()}>{props.focused ? "❯" : " "}</text>
|
||||
<text fg={fg()}>{props.label}</text>
|
||||
<Show when={props.value}>
|
||||
<box flexGrow={1} />
|
||||
<text fg={props.focused ? fg() : theme.textMuted}>{props.value}</text>
|
||||
</Show>
|
||||
<Show when={focusedSection().id === 1}>
|
||||
<SourceManager focused />
|
||||
<Show when={props.hint}>
|
||||
<text fg={theme.textMuted}>{props.hint}</text>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Center editor for number/select/toggle items without a bespoke renderer. */
|
||||
function GenericEditor(props: { item: SettingItem }) {
|
||||
const { theme } = useTheme();
|
||||
const it = props.item;
|
||||
return (
|
||||
<box flexDirection="column" padding={1} gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{it.label}</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={theme.textMuted}>Value:</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{it.display()}</text>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={it.kind === "number" || it.kind === "select"}>
|
||||
<text fg={theme.muted ?? theme.textMuted}>
|
||||
j/k to adjust · Enter to nudge forward · h to go back
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={it.kind === "toggle"}>
|
||||
<text fg={theme.muted ?? theme.textMuted}>
|
||||
Enter/Space to toggle · h to go back
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a string with `\n` newlines as stacked <text> lines. */
|
||||
function MultiLine(props: { text: string }) {
|
||||
const lines = () => props.text.split("\n");
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<For each={lines()}>
|
||||
{(line, i) => (
|
||||
<text fg={i() === 0 ? theme.accent : theme.textMuted}>
|
||||
{line || " "}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,317 +1,141 @@
|
||||
/**
|
||||
* Source management component for PodTUI
|
||||
* Add, remove, and configure podcast sources
|
||||
* SourceManager — exposes podcast sources as SettingItems for the depth-stack.
|
||||
*
|
||||
* • "Add Source" — an editor item; drilling in shows a name/URL add form.
|
||||
* • Each source — a toggle item (Space toggles enabled) whose display shows
|
||||
* the source type and on/off state.
|
||||
*
|
||||
* Advanced per-API-source options (country/language/explicit) are flattened to
|
||||
* simple toggles/cycles reachable by drilling into the source's editor.
|
||||
* Movement flows through nav.action — no own useKeyboard (avoids the old
|
||||
* right-pane key conflicts).
|
||||
*/
|
||||
|
||||
import { createSignal, For } from "solid-js";
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { SourceType } from "@/types/source";
|
||||
import type { PodcastSource } from "@/types/source";
|
||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
interface SourceManagerProps {
|
||||
focused?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language";
|
||||
|
||||
export function SourceManager(props: SourceManagerProps) {
|
||||
export function useSourceItems(): SettingItem[] {
|
||||
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 }) => {
|
||||
if (key.name === "escape") {
|
||||
if (focusArea() !== "list") {
|
||||
setFocusArea("list");
|
||||
setError(null);
|
||||
} else if (props.onClose) {
|
||||
props.onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "tab") {
|
||||
const areas: FocusArea[] = [
|
||||
"list",
|
||||
"country",
|
||||
"language",
|
||||
"explicit",
|
||||
"add",
|
||||
"url",
|
||||
const items: SettingItem[] = [
|
||||
{
|
||||
id: "add",
|
||||
label: "Add Source",
|
||||
kind: "editor",
|
||||
display: () => "+",
|
||||
help: () =>
|
||||
`Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`,
|
||||
renderEditor: () => <AddSourceForm />,
|
||||
},
|
||||
];
|
||||
const idx = areas.indexOf(focusArea());
|
||||
const nextIdx = key.shift
|
||||
? (idx - 1 + areas.length) % areas.length
|
||||
: (idx + 1) % areas.length;
|
||||
setFocusArea(areas[nextIdx]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusArea() === "list") {
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
|
||||
} else if (
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source) {
|
||||
feedStore.toggleSource(source.id);
|
||||
}
|
||||
} else if (key.name === "d" || key.name === "delete") {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source) {
|
||||
const removed = feedStore.removeSource(source.id);
|
||||
if (!removed) {
|
||||
setError("Cannot remove default sources");
|
||||
}
|
||||
}
|
||||
} else if (key.name === "a") {
|
||||
setFocusArea("add");
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "country") {
|
||||
if (
|
||||
key.name === "enter" ||
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
const next = source.country === "US" ? "GB" : "US";
|
||||
feedStore.updateSource(source.id, { country: next });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "explicit") {
|
||||
if (
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
feedStore.updateSource(source.id, {
|
||||
allowExplicit: !source.allowExplicit,
|
||||
for (const s of feedStore.sources()) {
|
||||
items.push({
|
||||
id: `src:${s.id}`,
|
||||
label: s.name,
|
||||
kind: "toggle",
|
||||
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
||||
help: () =>
|
||||
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
||||
toggle: () => feedStore.toggleSource(s.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "language") {
|
||||
if (
|
||||
key.name === "return" ||
|
||||
key.name === "space"
|
||||
) {
|
||||
const source = sources()[selectedIndex()];
|
||||
if (source && source.type === SourceType.API) {
|
||||
const next = source.language === "ja_jp" ? "en_us" : "ja_jp";
|
||||
feedStore.updateSource(source.id, { language: next });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return items;
|
||||
}
|
||||
|
||||
const handleAddSource = () => {
|
||||
const url = newSourceUrl().trim();
|
||||
const name = newSourceName().trim() || `Custom Source`;
|
||||
function AddSourceForm() {
|
||||
const feedStore = useFeedStore();
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
new URL(u);
|
||||
} catch {
|
||||
setError("Invalid URL format");
|
||||
return;
|
||||
}
|
||||
|
||||
feedStore.addSource({
|
||||
name,
|
||||
type: "rss" as SourceType,
|
||||
baseUrl: url,
|
||||
name: name().trim() || "Custom Source",
|
||||
type: SourceType.RSS,
|
||||
baseUrl: u,
|
||||
enabled: true,
|
||||
description: `Custom RSS feed: ${url}`,
|
||||
description: `Custom RSS feed: ${u}`,
|
||||
});
|
||||
|
||||
setNewSourceUrl("");
|
||||
setNewSourceName("");
|
||||
setFocusArea("list");
|
||||
setName("");
|
||||
setUrl("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const getSourceIcon = (source: PodcastSource) => {
|
||||
if (source.type === SourceType.API) return "[API]";
|
||||
if (source.type === SourceType.RSS) return "[RSS]";
|
||||
return "[?]";
|
||||
};
|
||||
|
||||
const selectedSource = () => sources()[selectedIndex()];
|
||||
const isApiSource = () => selectedSource()?.type === SourceType.API;
|
||||
const sourceCountry = () => selectedSource()?.country || "US";
|
||||
const sourceExplicit = () => selectedSource()?.allowExplicit !== false;
|
||||
const sourceLanguage = () => selectedSource()?.language || "en_us";
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border borderColor={theme.border} padding={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box flexDirection="column" padding={1} gap={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>Podcast Sources</strong>
|
||||
<strong>Add Source</strong>
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0} onMouseDown={props.onClose}>
|
||||
<text fg={theme.primary}>[Esc] Close</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
||||
|
||||
{/* Source list */}
|
||||
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
||||
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
|
||||
Sources:
|
||||
</text>
|
||||
<scrollbox height={6}>
|
||||
<For each={sources()}>
|
||||
{(source, index) => (
|
||||
<SelectableBox
|
||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
padding={0}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index());
|
||||
setFocusArea("list");
|
||||
feedStore.toggleSource(source.id);
|
||||
}}
|
||||
>
|
||||
<SelectableText
|
||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{focusArea() === "list" && index() === selectedIndex()
|
||||
? ">"
|
||||
: " "}
|
||||
</SelectableText>
|
||||
<SelectableText
|
||||
selected={() => focusArea() === "list" && index() === selectedIndex()}
|
||||
primary
|
||||
>
|
||||
{source.name}
|
||||
</SelectableText>
|
||||
</SelectableBox>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<text fg={theme.textMuted}>
|
||||
Space/Enter to toggle, d to delete, a to add
|
||||
</text>
|
||||
|
||||
{/* API settings */}
|
||||
<box flexDirection="column" gap={1}>
|
||||
<SelectableText selected={() => false} primary={isApiSource()}>
|
||||
{isApiSource()
|
||||
? "API Settings"
|
||||
: "API Settings (select an API source)"}
|
||||
</SelectableText>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
backgroundColor={
|
||||
focusArea() === "country" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "country"}>
|
||||
Country: {sourceCountry()}
|
||||
</SelectableText>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
backgroundColor={
|
||||
focusArea() === "language" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "language"}>
|
||||
Language:{" "}
|
||||
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
|
||||
</SelectableText>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
backgroundColor={
|
||||
focusArea() === "explicit" ? theme.primary : undefined
|
||||
}
|
||||
>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "explicit"}>
|
||||
Explicit: {sourceExplicit() ? "Yes" : "No"}
|
||||
</SelectableText>
|
||||
</box>
|
||||
</box>
|
||||
<SelectableText selected={() => false} tertiary>
|
||||
Enter/Space to toggle focused setting
|
||||
</SelectableText>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Add new source form */}
|
||||
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
|
||||
<SelectableText selected={() => false} primary={focusArea() === "add" || focusArea() === "url"}>
|
||||
Add New Source:
|
||||
</SelectableText>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>Name:</SelectableText>
|
||||
<text fg={theme.textMuted}>Name:</text>
|
||||
<input
|
||||
value={newSourceName()}
|
||||
onInput={setNewSourceName}
|
||||
value={name()}
|
||||
onInput={setName}
|
||||
placeholder="My Custom Feed"
|
||||
focused={props.focused && focusArea() === "add"}
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<SelectableText selected={() => false} tertiary>URL:</SelectableText>
|
||||
<text fg={theme.textMuted}>URL:</text>
|
||||
<input
|
||||
value={newSourceUrl()}
|
||||
value={url()}
|
||||
onInput={(v) => {
|
||||
setNewSourceUrl(v);
|
||||
setUrl(v);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://example.com/feed.rss"
|
||||
focused={props.focused && focusArea() === "url"}
|
||||
width={35}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box border borderColor={theme.border} padding={0} width={15} onMouseDown={handleAddSource}>
|
||||
<SelectableText selected={() => false} primary>[+] Add Source</SelectableText>
|
||||
<box
|
||||
border
|
||||
borderColor={theme.border}
|
||||
padding={0}
|
||||
width={15}
|
||||
onMouseDown={submit}
|
||||
>
|
||||
<text fg={theme.primary}>[+] Add</text>
|
||||
</box>
|
||||
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
|
||||
<Show when={feedStore.sources().length > 0}>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Current sources ({feedStore.sources().length}):
|
||||
</text>
|
||||
<For each={feedStore.sources()}>
|
||||
{(s) => (
|
||||
<text fg={theme.textMuted}>
|
||||
{s.enabled ? "●" : "○"} {s.name}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
{/* Error message */}
|
||||
{error() && <SelectableText selected={() => false} tertiary>{error()}</SelectableText>}
|
||||
|
||||
<SelectableText selected={() => false} tertiary>Tab to switch sections, Esc to close</SelectableText>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
/**
|
||||
* SyncPanel — exposes Import / Export / status as SettingItems. The Import and
|
||||
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
import { SyncStatus } from "./SyncStatus";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
// Module-level state so the action items can open their dialogs as depth-2
|
||||
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show.
|
||||
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>(
|
||||
null,
|
||||
);
|
||||
export { syncEditor };
|
||||
export function closeSyncEditor() {
|
||||
setSyncEditor(null);
|
||||
}
|
||||
|
||||
import { ImportDialog } from "./ImportDialog"
|
||||
import { ExportDialog } from "./ExportDialog"
|
||||
import { SyncStatus } from "./SyncStatus"
|
||||
import { useTheme } from "@/context/ThemeContext"
|
||||
|
||||
export function SyncPanel() {
|
||||
const { theme } = useTheme();
|
||||
const mode = createSignal<"import" | "export" | null>(null)
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<box border borderColor={theme.border} onMouseDown={() => mode[1]("import")}>
|
||||
<text fg={theme.text}>Import</text>
|
||||
</box>
|
||||
<box border borderColor={theme.border} onMouseDown={() => mode[1]("export")}>
|
||||
<text fg={theme.text}>Export</text>
|
||||
</box>
|
||||
</box>
|
||||
<SyncStatus />
|
||||
{mode[0]() === "import" ? <ImportDialog /> : null}
|
||||
{mode[0]() === "export" ? <ExportDialog /> : null}
|
||||
</box>
|
||||
)
|
||||
export function useSyncItems(): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "import",
|
||||
label: "Import",
|
||||
kind: "editor",
|
||||
display: () => "→",
|
||||
help: () =>
|
||||
`Import subscriptions from a sync file (JSON or OPML).\nDrill in (Enter/l) to open the import dialog.\nType: editor`,
|
||||
renderEditor: () => <ImportDialog />,
|
||||
},
|
||||
{
|
||||
id: "export",
|
||||
label: "Export",
|
||||
kind: "editor",
|
||||
display: () => "→",
|
||||
help: () =>
|
||||
`Export subscriptions to a sync file.\nDrill in (Enter/l) to open the export dialog.\nType: editor`,
|
||||
renderEditor: () => <ExportDialog />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
kind: "info",
|
||||
display: () => "Idle",
|
||||
help: () =>
|
||||
`Last sync status. (Sync is run from the import/export dialogs.)\nType: info`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Renders the live sync status block (used by the Settings page header for the
|
||||
* Sync section, when relevant). */
|
||||
export function SyncStatusBlock() {
|
||||
return <SyncStatus />;
|
||||
}
|
||||
|
||||
@@ -1,164 +1,81 @@
|
||||
/**
|
||||
* VisualizerSettings — settings panel for the real-time audio visualizer.
|
||||
*
|
||||
* Allows adjusting bar count, noise reduction, sensitivity, and
|
||||
* frequency cutoffs. All changes persist via the app store.
|
||||
* VisualizerSettings — exposes bars/sensitivity/noise/lowCut/highCut as
|
||||
* SettingItems for the yazi depth-stack. No own useKeyboard.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut";
|
||||
export function useVisualizerItems(): SettingItem[] {
|
||||
const app = useAppStore();
|
||||
const viz = () => app.state().settings.visualizer;
|
||||
|
||||
const FIELDS: FocusField[] = [
|
||||
"bars",
|
||||
"sensitivity",
|
||||
"noise",
|
||||
"lowCut",
|
||||
"highCut",
|
||||
];
|
||||
|
||||
export function VisualizerSettings() {
|
||||
const appStore = useAppStore();
|
||||
const { theme } = useTheme();
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("bars");
|
||||
|
||||
const viz = () => appStore.state().settings.visualizer;
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const idx = FIELDS.indexOf(focusField());
|
||||
const next = key.shift
|
||||
? (idx - 1 + FIELDS.length) % FIELDS.length
|
||||
: (idx + 1) % FIELDS.length;
|
||||
setFocusField(FIELDS[next]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1);
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1);
|
||||
}
|
||||
};
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField();
|
||||
const v = viz();
|
||||
|
||||
switch (field) {
|
||||
case "bars": {
|
||||
// Step by 8: 8, 16, 24, 32, ..., 128
|
||||
const next = Math.min(128, Math.max(8, v.bars + delta * 8));
|
||||
appStore.updateVisualizer({ bars: next });
|
||||
break;
|
||||
}
|
||||
case "sensitivity": {
|
||||
// Toggle: 0 (manual) or 1 (auto)
|
||||
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 });
|
||||
break;
|
||||
}
|
||||
case "noise": {
|
||||
// Step by 0.05: 0.0 – 1.0
|
||||
const next = Math.min(
|
||||
return [
|
||||
{
|
||||
id: "bars",
|
||||
label: "Bars",
|
||||
kind: "number",
|
||||
display: () => String(viz().bars),
|
||||
help: () =>
|
||||
`Number of visualizer bars.\nType: number (8–128, step 8)\nDefault: 64\nCurrent: ${viz().bars}\nj/k to −/+8.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
bars: Math.min(128, Math.max(8, viz().bars + dir * 8)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "sensitivity",
|
||||
label: "Auto Sensitivity",
|
||||
kind: "toggle",
|
||||
display: () => (viz().sensitivity === 1 ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Automatic gain sensitivity.\nType: toggle\nDefault: on\nCurrent: ${viz().sensitivity === 1 ? "on" : "off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () =>
|
||||
app.updateVisualizer({
|
||||
sensitivity: viz().sensitivity === 1 ? 0 : 1,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "noiseReduction",
|
||||
label: "Noise Reduction",
|
||||
kind: "number",
|
||||
display: () => viz().noiseReduction.toFixed(2),
|
||||
help: () =>
|
||||
`FFT noise reduction factor.\nType: number (0.00–1.00, step 0.05)\nDefault: 0.20\nCurrent: ${viz().noiseReduction.toFixed(2)}\nj/k to −/+0.05.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
noiseReduction: Math.min(
|
||||
1,
|
||||
Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))),
|
||||
);
|
||||
appStore.updateVisualizer({ noiseReduction: next });
|
||||
break;
|
||||
}
|
||||
case "lowCut": {
|
||||
// Step by 10: 20 – 500 Hz
|
||||
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10));
|
||||
appStore.updateVisualizer({ lowCutOff: next });
|
||||
break;
|
||||
}
|
||||
case "highCut": {
|
||||
// Step by 500: 1000 – 20000 Hz
|
||||
const next = Math.min(
|
||||
Math.max(0, Number((viz().noiseReduction + dir * 0.05).toFixed(2))),
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "lowCutOff",
|
||||
label: "Low Cutoff",
|
||||
kind: "number",
|
||||
display: () => `${viz().lowCutOff} Hz`,
|
||||
help: () =>
|
||||
`Lower frequency cutoff.\nType: number (20–500 Hz, step 10)\nDefault: 20\nCurrent: ${viz().lowCutOff}\nj/k to −/+10.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
lowCutOff: Math.min(500, Math.max(20, viz().lowCutOff + dir * 10)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "highCutOff",
|
||||
label: "High Cutoff",
|
||||
kind: "number",
|
||||
display: () => `${viz().highCutOff} Hz`,
|
||||
help: () =>
|
||||
`Upper frequency cutoff.\nType: number (1000–20000 Hz, step 500)\nDefault: 20000\nCurrent: ${viz().highCutOff}\nj/k to −/+500.`,
|
||||
cycle: (dir) =>
|
||||
app.updateVisualizer({
|
||||
highCutOff: Math.min(
|
||||
20000,
|
||||
Math.max(1000, v.highCutOff + delta * 500),
|
||||
);
|
||||
appStore.updateVisualizer({ highCutOff: next });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard(handleKey);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={theme.textMuted}>Visualizer</text>
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
|
||||
Bars:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().bars}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={
|
||||
focusField() === "sensitivity" ? theme.primary : theme.textMuted
|
||||
}
|
||||
>
|
||||
Auto Sensitivity:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text
|
||||
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
|
||||
>
|
||||
{viz().sensitivity === 1 ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
|
||||
Noise Reduction:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
Low Cutoff:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text
|
||||
fg={focusField() === "highCut" ? theme.primary : theme.textMuted}
|
||||
>
|
||||
High Cutoff:
|
||||
</text>
|
||||
<box border borderColor={theme.border} padding={0}>
|
||||
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
);
|
||||
Math.max(1000, viz().highCutOff + dir * 500),
|
||||
),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
45
src/pages/Settings/types.ts
Normal file
45
src/pages/Settings/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Settings item model — each settings section exposes a list of items that the
|
||||
* SettingsPage renders through the yazi depth-stack (sections → items → editor).
|
||||
*
|
||||
* All movement flows through the Shell's nav.action router (j/k move, Enter/l
|
||||
* drill, h back), so panels no longer register their own useKeyboard — that was
|
||||
* the root cause of the "right pane ignores keys / double-handled input" bugs.
|
||||
*/
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
export type SettingItemKind =
|
||||
| "toggle"
|
||||
| "number"
|
||||
| "select"
|
||||
| "action"
|
||||
| "editor"
|
||||
| "info";
|
||||
|
||||
export interface SettingItem {
|
||||
/** Stable id within its section. */
|
||||
id: string;
|
||||
/** One-line label shown in the items list. */
|
||||
label: string;
|
||||
/** Category — decides how the item is interacted with. */
|
||||
kind: SettingItemKind;
|
||||
/** Current value as a short string (shown to the right of the label). */
|
||||
display: () => string;
|
||||
/** Help text for the preview pane: description, type, default, current. */
|
||||
help: () => string;
|
||||
/** For number/select: nudge the value by -1 or +1 (j/k at depth 2). */
|
||||
cycle?: (dir: -1 | 1) => void;
|
||||
/** For toggle: flip the value (Space/Enter at depth 1). */
|
||||
toggle?: () => void;
|
||||
/** For action: run immediately (Enter at depth 1). */
|
||||
run?: () => void;
|
||||
/** For editor: a bespoke depth-2 editor component. */
|
||||
renderEditor?: () => JSX.Element;
|
||||
}
|
||||
|
||||
export interface SettingsSectionDef {
|
||||
id: number;
|
||||
label: string;
|
||||
description: string;
|
||||
items?: () => SettingItem[];
|
||||
}
|
||||
@@ -20,6 +20,35 @@ export enum TABS {
|
||||
}
|
||||
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 = {
|
||||
[TABS.FEED]: FeedPage,
|
||||
[TABS.MYSHOWS]: MyShowsPage,
|
||||
@@ -47,14 +76,18 @@ export const PANE_RATIO = {
|
||||
preview: 3,
|
||||
} as const;
|
||||
|
||||
// Number of interactive panes per tab (for the yazi h/l swipe). Slots beyond
|
||||
// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing
|
||||
// the old NavigationContext top-level-init circular deadlock.
|
||||
// Number of interactive panes per tab. Depth-tabs (Feed/MyShows/Discover/
|
||||
// Settings) now have a single focusable content pane (the center/current
|
||||
// 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> = {
|
||||
[TABS.FEED]: 3, // feeds | episodes | preview
|
||||
[TABS.MYSHOWS]: 3, // shows | episodes | preview
|
||||
[TABS.DISCOVER]: 3, // categories | results | detail
|
||||
[TABS.SEARCH]: 3, // query | results | detail
|
||||
[TABS.FEED]: 1, // depth: feeds → episodes → preview
|
||||
[TABS.MYSHOWS]: 1, // depth: shows → episodes → preview
|
||||
[TABS.DISCOVER]: 1, // depth: categories → results → preview
|
||||
[TABS.SEARCH]: 3, // fixed: query | results | detail
|
||||
[TABS.PLAYER]: 1, // single pane
|
||||
[TABS.SETTINGS]: 2, // sections | panel
|
||||
[TABS.SETTINGS]: 1, // depth: sections → items → editor
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user