Compare commits

...

4 Commits

Author SHA1 Message Date
21b088b5a9 redesign tasks 2026-07-31 09:27:57 -04:00
97b2f61e5f pre-ui-rearch 2026-07-31 01:05:32 -04:00
89c5ca2f7e harness improvements 2026-07-30 21:30:37 -04:00
d8f11040bc start revive 2026-07-30 21:25:03 -04:00
38 changed files with 6927 additions and 3196 deletions

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
.harness/

BIN
bun.lockb Executable file

Binary file not shown.

619
scripts/tui-harness.tsx Normal file
View File

@@ -0,0 +1,619 @@
#!/usr/bin/env bun
/**
* PodTUI LLM-interactive harness — stateless per-turn snapshot bridge.
*
* Each invocation:
* 1. Points XDG_CONFIG_HOME / XDG_DATA_HOME / PODTUI_AUDIO_BACKEND at a
* sandbox dir under .harness so your real ~/.config/podtui is never touched.
* 2. Replays the saved action log (.harness/actions.json) from scratch.
* 3. Appends + executes the new action passed on the CLI.
* 4. Renders, captures structured spans, and prints: plain frame + distinct
* style summary (colors/attrs) + selected store state + captured issues
* (stderr / uncaught rejections). Full structured spans are dumped to
* .harness/last-frame.json every turn.
*
* Audio is silent (Noop) by default during the snapshot model so replaying the
* log each turn doesn't re-trigger real playback. Pass --audio (or set
* PODTUI_AUDIO_BACKEND) to flip to a real backend for the new action only.
*
* Import order mirrors src/index.tsx (lazy) to avoid a NavigationContext cycle.
*
* Usage:
* bun scripts/tui-harness.tsx init [--size 100x30] [--seed]
* bun scripts/tui-harness.tsx key <key> [mods...] # mods: ctrl shift meta
* bun scripts/tui-harness.tsx arrow <up|down|left|right> [mods...]
* bun scripts/tui-harness.tsx enter|escape|tab|space|backspace
* bun scripts/tui-harness.tsx type "<text>"
* bun scripts/tui-harness.tsx wait <ms>
* bun scripts/tui-harness.tsx resize <w> <h>
* bun scripts/tui-harness.tsx frame # re-render, no new action
* bun scripts/tui-harness.tsx state [all|nav|audio|feed|app]
* bun scripts/tui-harness.tsx actions # print action log
* bun scripts/tui-harness.tsx reset
* bun scripts/tui-harness.tsx seed [--from ~/.config/podtui]
*
* Flags (after the subcommand):
* --size WxH terminal size (default 100x30)
* --audio enable real audio backend for the new action only
* --no-settle skip the extra render-settle loops
* --styles print the distinct-styles sample block (off by default)
* --verbose restore the original multi-line pretty output
*/
import { testRender } from "@opentui/solid";
import {
existsSync,
mkdirSync,
cpSync,
writeFileSync,
readFileSync,
} from "node:fs";
import { join } from "node:path";
// ── Paths & sandbox ────────────────────────────────────────────────────────
const HANDLES_DIR = ".harness";
const CONFIG_HOME = join(HANDLES_DIR, "config-home");
const DATA_HOME = join(HANDLES_DIR, "data-home");
const ACTIONS_FILE = join(HANDLES_DIR, "actions.json");
const FRAME_JSON = join(HANDLES_DIR, "last-frame.json");
const FRAME_TXT = join(HANDLES_DIR, "last-frame.txt");
const STATE_JSON = join(HANDLES_DIR, "state.json");
// Sandbox must be active BEFORE any app module is imported, so the app's
// config-dir / persistence reads resolve into .harness/*.
function activateSandbox(): void {
mkdirSync(CONFIG_HOME, { recursive: true });
mkdirSync(DATA_HOME, { recursive: true });
process.env.XDG_CONFIG_HOME = join(process.cwd(), CONFIG_HOME);
process.env.XDG_DATA_HOME = join(process.cwd(), DATA_HOME);
// Silent audio during replay by default; --audio flips this after import.
if (!process.env.PODTUI_AUDIO_BACKEND)
process.env.PODTUI_AUDIO_BACKEND = "none";
}
// ── Action log ─────────────────────────────────────────────────────────────
type Mod = "ctrl" | "shift" | "meta" | "super" | "hyper";
type Action =
| { t: "key"; k: string; mods?: Mod[] }
| { t: "arrow"; d: "up" | "down" | "left" | "right"; mods?: Mod[] }
| { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] }
| { t: "type"; s: string }
| { t: "wait"; ms: number }
| { t: "resize"; w: number; h: number };
function loadActions(): Action[] {
try {
return JSON.parse(readFileSync(ACTIONS_FILE, "utf8") || "[]");
} catch {
return [];
}
}
function saveActions(a: Action[]): void {
writeFileSync(ACTIONS_FILE, JSON.stringify(a, null, 2));
}
// ── Issue capture ──────────────────────────────────────────────────────────
const issues: string[] = [];
// Captured by the StateProbe component rendered inside the provider tree —
// Solid contexts can only be read from within the tree, not from outside.
let navRef: any = null;
function captureIssues(): void {
const origErr = console.error;
const origWarn = console.warn;
console.error = (...args: unknown[]) => {
issues.push("stderr: " + args.map(String).join(" "));
origErr(...(args as any[]));
};
console.warn = (...args: unknown[]) => {
issues.push("warn: " + args.map(String).join(" "));
origWarn(...(args as any[]));
};
process.on("uncaughtException", (e) =>
issues.push("uncaught: " + ((e as Error)?.stack || String(e))),
);
process.on("unhandledRejection", (e) =>
issues.push("unhandledRejection: " + ((e as Error)?.stack || String(e))),
);
}
// ── Span rendering ─────────────────────────────────────────────────────────
type RGBA = { r: number; g: number; b: number; a: number };
type Span = {
text: string;
fg: RGBA | null;
bg: RGBA | null;
attributes: number;
width: number;
};
const ATTR_NAMES: Record<string, number> = {
BOLD: 1,
DIM: 2,
ITALIC: 4,
UNDERLINE: 8,
BLINK: 16,
INVERSE: 32,
HIDDEN: 64,
STRIKETHROUGH: 128,
};
function hex(c: RGBA | null): string | null {
if (!c) return null;
if (c.a === 0) return null; // transparent → "default"
const [r, g, b] = [c.r, c.g, c.b].map((v) =>
Math.max(0, Math.min(255, Math.round(v))),
);
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
}
function attrLabels(attr: number): string[] {
const out: string[] = [];
for (const [name, bit] of Object.entries(ATTR_NAMES))
if (attr & bit) out.push(name.toLowerCase());
return out;
}
// (Plain frame text comes from captureCharFrame instead of span reconstruction.)
function distinctStyles(spans: {
lines: { spans: Span[] }[];
}): { tag: string; sample: string; n: number }[] {
const map = new Map<string, { tag: string; sample: string; n: number }>();
for (const line of spans.lines) {
for (const s of line.spans) {
if (!s.text || s.text.trim() === "") continue;
const fg = hex(s.fg as any);
const bg = hex(s.bg as any);
if (!fg && !bg && s.attributes === 0) continue; // default — skip
const tags = attrLabels(s.attributes);
const tag = `[fg=${fg ?? "·"} bg=${bg ?? "·"}${tags.length ? " " + tags.join("+") : ""}]`;
const ex = map.get(tag);
const sample = s.text.replace(/\n/g, "\\n").slice(0, 28);
if (ex) {
ex.n++;
if (ex.sample.length < 14 && sample.length > ex.sample.length)
ex.sample = sample;
} else {
map.set(tag, { tag, sample, n: 1 });
}
}
}
return [...map.values()].sort((a, b) => b.n - a.n).slice(0, 20);
}
// ── Arg parsing ────────────────────────────────────────────────────────────
function parseFlags(rest: string[]): {
flags: Record<string, string | boolean>;
positional: string[];
} {
const flags: Record<string, string | boolean> = {};
const positional: string[] = [];
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith("--")) {
if (a === "--audio") flags.audio = true;
else if (a === "--no-settle") flags["no-settle"] = true;
else if (a === "--styles") flags.styles = true;
else if (a === "--verbose") flags.verbose = true;
else if (a === "--size") {
flags.size = rest[++i];
const m = /(\d+)x(\d+)/.exec(String(flags.size));
if (m) {
flags.w = m[1];
flags.h = m[2];
}
} else if (a === "--from") {
flags.from = rest[++i];
} else {
flags[a.slice(2)] = rest[++i] ?? true;
}
} else {
positional.push(a);
}
}
return { flags, positional };
}
function parseMods(positional: string[]): Mod[] {
const mods: Mod[] = [];
for (const p of positional)
if (["ctrl", "shift", "meta", "super", "hyper"].includes(p))
mods.push(p as Mod);
return mods;
}
function buildAction(cmd: string, positional: string[]): Action | null {
const mods = parseMods(positional);
const first = positional[0];
switch (cmd) {
case "key":
if (!first) throw new Error("key requires a <key> argument");
return { t: "key", k: first, mods: mods.length ? mods : undefined };
case "arrow":
if (!first || !["up", "down", "left", "right"].includes(first))
throw new Error("arrow requires up|down|left|right");
return {
t: "arrow",
d: first as any,
mods: mods.length ? mods : undefined,
};
case "enter":
case "escape":
case "tab":
case "space":
case "backspace":
return { t: cmd, mods: mods.length ? mods : undefined };
case "type":
if (first === undefined) throw new Error("type requires <text>");
// Re-join the rest in case text had spaces; positional[0] already is first token,
// caller should quote. We join all positional as the text.
return { t: "type", s: positional.join(" ") };
case "wait":
if (!first) throw new Error("wait requires <ms>");
return { t: "wait", ms: parseInt(first, 10) || 0 };
case "resize":
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
return {
t: "resize",
w: parseInt(first, 10) || 100,
h: parseInt(positional[1], 10) || 30,
};
case "frame":
case "state":
case "reset":
case "actions":
case "init":
case "seed":
return null;
default:
throw new Error(`unknown command: ${cmd}`);
}
}
// ── Execute one action against a mounted setup ──────────────────────────────
function fmtMods(mods?: Mod[]): Record<string, boolean> | undefined {
if (!mods || !mods.length) return undefined;
const o: Record<string, boolean> = {};
for (const m of mods) o[m] = true;
return o;
}
async function execAction(setup: any, a: Action): Promise<void> {
const mi = setup.mockInput;
switch (a.t) {
case "key":
mi.pressKey(a.k, fmtMods(a.mods));
break;
case "arrow":
mi.pressArrow(a.d, fmtMods(a.mods));
break;
case "enter":
mi.pressEnter(fmtMods(a.mods));
break;
case "escape":
mi.pressEscape(fmtMods(a.mods));
break;
case "tab":
mi.pressTab(fmtMods(a.mods));
break;
case "space":
mi.pressKey("space");
break;
case "backspace":
mi.pressBackspace(fmtMods(a.mods));
break;
case "type":
await mi.typeText(a.s, 0);
break;
case "wait":
await new Promise((r) => setTimeout(r, a.ms));
break;
case "resize":
setup.resize(a.w, a.h);
break;
}
await setup.renderOnce();
// tiny settle for reactive updates
await new Promise((r) => setTimeout(r, 40));
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
activateSandbox();
captureIssues();
const argv = process.argv.slice(2);
const cmd = argv[0] ?? "frame";
const { flags, positional } = parseFlags(argv.slice(1));
// Local-only commands that don't mount.
if (cmd === "reset") {
saveActions([]);
console.log("✔ actions log cleared.");
return;
}
if (cmd === "actions") {
const a = loadActions();
console.log(`Action log (${a.length}):`);
console.log(JSON.stringify(a, null, 2));
return;
}
if (cmd === "seed") {
const from = String(
flags.from || join(process.env.HOME || "~", ".config", "podtui"),
);
if (!existsSync(from)) {
console.error(`seed source not found: ${from}`);
process.exit(1);
}
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
cpSync(from, dest, { recursive: true });
console.log(`✔ seeded sandbox config from ${from}${dest}`);
return;
}
// Size settings.
let width = 100;
let height = 30;
if (flags.w) width = parseInt(String(flags.w), 10);
if (flags.h) height = parseInt(String(flags.h), 10);
let newAction: Action | null = null;
let actions: Action[] = [];
if (cmd !== "init" && cmd !== "frame" && cmd !== "state") {
newAction = buildAction(cmd, positional);
}
if (cmd === "init") {
saveActions([]);
actions = [];
} else {
actions = loadActions();
}
// Mount the real app. Lazy imports (order matters — see NavigationContext cycle).
const { App } = await import("../src/App");
const { ThemeProvider } = await import("../src/context/ThemeContext");
const toast = await import("../src/ui/toast");
const { KeybindProvider } = await import("../src/context/KeybindContext");
const { NavigationProvider, useNavigation } = await import(
"../src/context/NavigationContext"
);
const { DialogProvider } = await import("../src/ui/dialog");
const { CommandProvider } = await import("../src/ui/command");
// Probe rendered inside the provider tree so context hooks resolve.
const StateProbe = () => {
try {
navRef = useNavigation();
} catch (e) {
issues.push("StateProbe: " + String(e));
}
return null;
};
const HarnessRoot = () => (
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<StateProbe />
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
);
const setup = await testRender(() => <HarnessRoot />, {
width,
height,
useThread: false,
});
(setup.renderer as any).disableStdoutInterception?.();
// Wait for providers (keybinds/theme/feeds) to settle.
const settleLoops = flags["no-settle"] ? 2 : 12;
for (let i = 0; i < settleLoops; i++) {
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
}
// Replay history silently (audio already Noop via env).
for (const a of actions) await execAction(setup, a);
// For the *new* action: if --audio, flip to a real backend just for it.
let audioControls: any = null;
try {
const { useAudio } = await import("../src/hooks/useAudio");
audioControls = useAudio();
} catch (e) {
issues.push("useAudio import: " + String(e));
}
if (newAction) {
if (flags.audio && audioControls?.switchBackend) {
// Re-detect: clear env so detection picks the best real backend.
delete process.env.PODTUI_AUDIO_BACKEND;
// Force (re)creation of a real backend; useAudio caches, switchBackend resets.
await audioControls.switchBackend("mpv").catch(() => {});
if (
!audioControls.backendName() ||
audioControls.backendName() === "none"
) {
await audioControls.switchBackend("afplay").catch(() => {});
}
}
actions.push(newAction);
saveActions(actions);
await execAction(setup, newAction);
}
// Final settle + capture.
await setup.renderOnce();
await new Promise((r) => setTimeout(r, 60));
const spans = setup.captureSpans() as {
lines: { spans: Span[] }[];
cols: number;
rows: number;
cursor: [number, number];
};
const plainFrame = setup.captureCharFrame();
// Dump structured spans + plain frame.
try {
writeFileSync(FRAME_JSON, JSON.stringify(spans));
writeFileSync(FRAME_TXT, plainFrame);
} catch {}
// Store state snapshot.
const state: Record<string, unknown> = {};
try {
const nav = navRef;
if (nav) {
state.nav = {
tab: nav.activeTab?.(),
pane: nav.activePane?.(),
mode: nav.mode?.(),
input: nav.inputFocused?.(),
sel: nav.selectedIds?.()?.length ?? 0,
ready: nav.ready,
};
} else {
state.nav = "NAV_REF not captured (probe did not run)";
}
} catch (e) {
state.nav = "ERR: " + String(e);
}
try {
if (audioControls) {
state.audio = {
backend: audioControls.backendName ? audioControls.backendName() : null,
playing: audioControls.isPlaying ? audioControls.isPlaying() : null,
pos: audioControls.position ? audioControls.position() : null,
dur: audioControls.duration ? audioControls.duration() : null,
vol: audioControls.volume ? audioControls.volume() : null,
err: audioControls.error ? audioControls.error() : null,
ep: audioControls.currentEpisode
? audioControls.currentEpisode()?.title
: null,
};
}
} catch (e) {
state.audio = "ERR: " + String(e);
}
try {
const { useFeedStore } = await import("../src/stores/feed");
const fs_ = useFeedStore();
const feeds = fs_.feeds ? fs_.feeds() : [];
state.feed = {
count: feeds?.length ?? 0,
sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
};
} catch (e) {
state.feed = "ERR: " + String(e);
}
try {
writeFileSync(STATE_JSON, JSON.stringify(state));
} catch {}
// ── Output ──────────────────────────────────────────────────────────────
// Compact by default: trimmed frame, one-line state per section, no styles
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
const verbose = !!flags.verbose;
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
// A line is "visually empty" if it's either fully blank OR contains only
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
// 24-row pane costs 1 line, not 18.
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
const isVisuallyEmpty = (l: string): boolean =>
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
const frameTrimmed = (() => {
const lines = plainFrame
.replace(/\n+$/, "")
.split("\n")
.map((l) => l.replace(/\s+$/, ""));
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
lines.pop();
const out: string[] = [];
let blank = 0;
const flushBlanks = () => {
if (blank >= 3) out.push(`${blank} empty`);
else for (let i = 0; i < blank; i++) out.push("");
blank = 0;
};
for (const l of lines) {
if (isVisuallyEmpty(l)) {
blank++;
} else {
flushBlanks();
out.push(l);
}
}
flushBlanks();
return out.join("\n");
})();
console.log(
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`,
);
console.log(frameTrimmed);
// ── distinct styles: opt-in only (--styles OR --verbose) ──
if (scope === "all" && (flags.styles || verbose)) {
const styles = distinctStyles(spans);
if (styles.length) {
console.log("-- styles (top 20) --");
for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`);
}
}
// ── state: one compact line per requested section ──
const want = (k: string) => scope === "all" || scope === k;
const compact = (obj: unknown): string =>
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
if (want("nav")) console.log("nav " + compact(state.nav));
if (want("audio")) console.log("audio " + compact(state.audio));
if (want("feed")) console.log("feed " + compact(state.feed));
if (want("app")) console.log("app (not dumped in v1)");
// ── issues: terse ──
if (issues.length) {
console.log(`issues:${issues.length}`);
for (const i of issues.slice(0, 20)) console.log(" ! " + i);
} else {
console.log("issues:none");
}
// Footer is identical every run — only print on init or --verbose.
if (cmd === "init" || verbose) {
console.log(
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
);
}
// Tear down child processes (audio backend) before exit to avoid orphans.
try {
if (audioControls?.stop) await audioControls.stop().catch(() => {});
} catch (e) {
issues.push("teardown audio: " + String(e));
}
try {
setup.renderer.destroy();
} catch (e) {
issues.push("teardown renderer: " + String(e));
}
process.exit(0);
}
main().catch((err) => {
console.error("HARNESS FAILED:", err?.stack || err);
process.exit(1);
});

View File

@@ -1,194 +1,95 @@
import { createMemo, ErrorBoundary, Accessor } from "solid-js"; import { ErrorBoundary } from "solid-js";
import { useKeyboard, useSelectionHandler } from "@opentui/solid"; import { useSelectionHandler, useRenderer } from "@opentui/solid";
import { TabNavigation } from "./components/TabNavigation";
import { CodeValidation } from "@/components/CodeValidation";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useFeedStore } from "@/stores/feed";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys"; import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { FeedVisibility } from "@/types/feed";
import { Clipboard } from "@/utils/clipboard"; import { Clipboard } from "@/utils/clipboard";
import { useToast } from "@/ui/toast"; import { useToast } from "@/ui/toast";
import { useRenderer } from "@opentui/solid";
import type { AuthScreen } from "@/types/auth";
import type { Episode } from "@/types/episode";
import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation";
import { useTheme, ThemeProvider } from "./context/ThemeContext"; import { useTheme, ThemeProvider } from "./context/ThemeContext";
import { KeybindProvider, useKeybinds } from "./context/KeybindContext"; import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
import { NavigationProvider, useNavigation } from "./context/NavigationContext"; import {
import { useAudioNavStore, AudioSource } from "./stores/audio-nav"; NavigationProvider,
useNavigation,
NavMode,
} from "./context/NavigationContext";
import { TABS } from "./utils/navigation";
import { Shell } from "./components/Shell";
const DEBUG = import.meta.env.DEBUG; const DEBUG = import.meta.env.DEBUG;
export function App() { export function App() {
const nav = useNavigation(); const nav = useNavigation();
const auth = useAuthStore(); const auth = useAuthStore();
const feedStore = useFeedStore(); const audio = useAudio();
const audio = useAudio(); const toast = useToast();
const toast = useToast(); const renderer = useRenderer();
const renderer = useRenderer(); const themeContext = useTheme();
const themeContext = useTheme(); const theme = themeContext.theme;
const theme = themeContext.theme; const keybind = useKeybinds();
// Create a reactive expression for background color // Multimedia keys (physical play/seek keys) still feed the audio backend
const backgroundColor = () => { // regardless of the on-screen yazi keybinds.
return themeContext.selected === "system" useMultimediaKeys({
? "transparent" playerFocused: () =>
: themeContext.theme.surface; nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
}; ? true
const keybind = useKeybinds(); : false,
const audioNav = useAudioNavStore(); inputFocused: () => nav.inputFocused(),
hasEpisode: () => !!audio.currentEpisode(),
});
useMultimediaKeys({ // Mouse text-selection → clipboard (unchanged from the old shell).
playerFocused: () => useSelectionHandler((selection: any) => {
nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0, if (!selection) return;
inputFocused: () => nav.inputFocused(), const text = selection.getSelectedText?.();
hasEpisode: () => !!audio.currentEpisode(), if (!text || text.trim().length === 0) return;
}); Clipboard.copy(text)
.then(() =>
toast.show({ message: "Copied to Clipboard!", variant: "info" }),
)
.catch(toast.error)
.finally(() => renderer.clearSelection());
});
const handlePlayEpisode = (episode: Episode) => { const backgroundColor = () =>
audio.play(episode); themeContext.selected === "system"
nav.setActiveTab(TABS.PLAYER); ? "transparent"
nav.setActiveDepth(1); : themeContext.theme.surface;
audioNav.setSource(AudioSource.FEED);
};
useSelectionHandler((selection: any) => { return (
if (!selection) return; <ErrorBoundary
const text = selection.getSelectedText?.(); fallback={(err) => (
if (!text || text.trim().length === 0) return; <box border padding={2} borderColor={theme.error}>
<text fg={theme.error}>
Clipboard.copy(text) Error: {err?.message ?? String(err)}
.then(() => { {"\n"}
toast.show({ message: "Copied to Clipboard!", variant: "info" }); Press 1-6 to switch tabs, or : to open the command bar.
}) </text>
.catch(toast.error) </box>
.finally(() => { )}
renderer.clearSelection(); >
}); <box
}); flexDirection="column"
width="100%"
useKeyboard( height="100%"
(keyEvent) => { backgroundColor={backgroundColor()}
const isCycle = keybind.match("cycle", keyEvent); >
const isUp = keybind.match("up", keyEvent); {DEBUG && (
const isDown = keybind.match("down", keyEvent); <box flexDirection="row" width="100%" height={1}>
const isLeft = keybind.match("left", keyEvent); <text fg={theme.primary}></text>
const isRight = keybind.match("right", keyEvent); <text fg={theme.secondary}></text>
const isDive = keybind.match("dive", keyEvent); <text fg={theme.accent}></text>
const isOut = keybind.match("out", keyEvent); <text fg={theme.error}></text>
const isToggle = keybind.match("audio-toggle", keyEvent); <text fg={theme.warning}></text>
const isNext = keybind.match("audio-next", keyEvent); <text fg={theme.success}></text>
const isPrev = keybind.match("audio-prev", keyEvent); <text fg={theme.info}></text>
const isSeekForward = keybind.match("audio-seek-forward", keyEvent); <text fg={theme.text}></text>
const isSeekBackward = keybind.match("audio-seek-backward", keyEvent); <text fg={theme.textMuted}></text>
const isQuit = keybind.match("quit", keyEvent); <text fg={theme.surface}></text>
const isInverting = keybind.isInverting(keyEvent); </box>
)}
// unified navigation: left->right, top->bottom across all tabs <Shell />
if (nav.activeDepth() == 0) { </box>
// at top level: cycle through tabs </ErrorBoundary>
if ( );
(isCycle && !isInverting) ||
(isDown && !isInverting) ||
(isUp && isInverting)
) {
nav.nextTab();
return;
}
if (
(isCycle && isInverting) ||
(isDown && isInverting) ||
(isUp && !isInverting)
) {
nav.prevTab();
return;
}
// dive out to first pane
if (
(isDive && !isInverting) ||
(isOut && isInverting) ||
(isRight && !isInverting) ||
(isLeft && isInverting)
) {
nav.setActiveDepth(1);
}
} else {
// in panes: navigate between them
if (
(isDive && isInverting) ||
(isOut && !isInverting) ||
(isRight && isInverting) ||
(isLeft && !isInverting)
) {
nav.setActiveDepth(0);
} else if (isDown && !isInverting) {
nav.nextPane();
} else if (isUp && isInverting) {
nav.prevPane();
}
}
},
{ release: false },
);
return (
<ErrorBoundary
fallback={(err) => (
<box border padding={2} borderColor={theme.error}>
<text fg={theme.error}>
Error: {err?.message ?? String(err)}
{"\n"}
Press a number key (1-6) to switch tabs.
</text>
</box>
)}
>
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface
}
>
<LoadingIndicator />
{DEBUG && (
<box flexDirection="row" width="100%" height={1}>
<text fg={theme.primary}></text>
<text fg={theme.secondary}></text>
<text fg={theme.accent}></text>
<text fg={theme.error}></text>
<text fg={theme.warning}></text>
<text fg={theme.success}></text>
<text fg={theme.info}></text>
<text fg={theme.text}></text>
<text fg={theme.textMuted}></text>
<text fg={theme.surface}></text>
<text fg={theme.background}></text>
<text fg={theme.border}></text>
<text fg={theme.borderActive}></text>
<text fg={theme.diffAdded}></text>
<text fg={theme.diffRemoved}></text>
<text fg={theme.diffContext}></text>
<text fg={theme.markdownText}></text>
<text fg={theme.markdownHeading}></text>
<text fg={theme.markdownLink}></text>
<text fg={theme.markdownCode}></text>
<text fg={theme.syntaxKeyword}></text>
<text fg={theme.syntaxString}></text>
<text fg={theme.syntaxNumber}></text>
<text fg={theme.syntaxFunction}></text>
</box>
)}
<box flexDirection="row" width="100%" height="100%">
<TabNavigation />
{LayerGraph[nav.activeTab()]()}
</box>
</box>
</ErrorBoundary>
);
} }

664
src/components/Shell.tsx Normal file
View File

@@ -0,0 +1,664 @@
/**
* Shell — yazi-style application chrome.
*
* 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,
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";
import type { Episode } from "@/types/episode";
import { useToast } from "@/ui/toast";
import { emit } from "@/utils/event-bus";
import { TABS, TabsCount, TabPaneCount, LayerGraph } from "@/utils/navigation";
const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed",
[TABS.MYSHOWS]: "My Shows",
[TABS.DISCOVER]: "Discover",
[TABS.SEARCH]: "Search",
[TABS.PLAYER]: "Player",
[TABS.SETTINGS]: "Settings",
};
/** Actions the active page is responsible for (pane/list-local). */
const PAGE_ACTIONS: ReadonlySet<KeybindActionName> = new Set<KeybindActionName>(
[
"move-down",
"move-up",
"page-down",
"page-up",
"full-down",
"full-up",
"jump-down",
"jump-up",
"goto-top",
"goto-bottom",
"toggle-select",
"visual-mode",
"toggle-all",
"invert-all",
"open",
"open-interactive",
"search",
"filter",
"sort",
"toggle-hidden",
"refresh",
],
);
/** 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));
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
}
return null;
}
export function Shell() {
const theme = useTheme();
const t = theme.theme;
const nav = useNavigation();
const k = useKeybinds();
const audio = useAudio();
const audioNav = useAudioNavStore();
const toast = useToast();
const feedStore = useFeedStore();
const [showHelp, setShowHelp] = createSignal(false);
/** Play the episode adjacent (offset ±1) to the currently-playing one,
* within its feed's episode list. Updates audio-nav context accordingly. */
function advanceEpisode(offset: number) {
const cur = audio.currentEpisode();
if (!cur) {
toast.show({ message: "Nothing playing", variant: "warning" });
return;
}
const pid = audioNav.getPodcastId();
const feeds = feedStore.getFilteredFeeds();
const feed =
feeds.find((f) => f.podcast.id === pid) ??
feeds.find((f) => f.episodes.some((e) => e.id === cur.id));
if (!feed) {
toast.show({ message: "Show not found", variant: "warning" });
return;
}
const eps = [...feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
const idx = eps.findIndex((e) => e.id === cur.id);
const next = eps[idx + offset];
if (!next) {
toast.show({
message: offset > 0 ? "No next episode" : "No previous episode",
variant: "warning",
});
return;
}
audio.play(next).catch(() => {});
audioNav.next(eps.length - 1 - (idx + offset) >= 0 ? idx + offset : idx);
toast.show({ message: `${next.title}`.slice(0, 60), variant: "info" });
}
// ── Command bar dispatch ────────────────────────────────────────────────────
function runCommand(raw: string) {
const cmd = raw.trim();
if (!cmd) return;
const name = cmd.split(/\s+/)[0].toLowerCase();
const arg = cmd.slice(name.length).trim();
switch (name) {
case "q":
case "quit":
case "exit":
process.exit(0);
case "refresh":
case "r":
emit("nav.action", {
action: "refresh",
tab: nav.activeTab(),
pane: nav.activePane(),
mode: nav.mode(),
});
break;
case "play":
case "pause":
case "p":
audio.togglePlayback().catch(() => {});
break;
case "next":
case "n":
advanceEpisode(1);
break;
case "prev":
advanceEpisode(-1);
break;
case "seek": {
const n = Number(arg) || 0;
audio.seek(n).catch(() => {});
break;
}
case "feed":
case "f":
nav.setActiveTab(TABS.FEED);
break;
case "shows":
case "myshows":
nav.setActiveTab(TABS.MYSHOWS);
break;
case "discover":
case "d":
nav.setActiveTab(TABS.DISCOVER);
break;
case "search":
nav.setActiveTab(TABS.SEARCH);
break;
case "player":
nav.setActiveTab(TABS.PLAYER);
break;
case "settings":
case "set":
nav.setActiveTab(TABS.SETTINGS);
break;
case "help":
case "h":
setShowHelp((v) => !v);
break;
default:
nav.setCommandError(`unknown command: ${name}`);
// re-enter command mode so the user sees the error + can correct
nav.enterCommand();
nav.setCommandBuffer(cmd);
}
}
// ── Command-mode key handling ───────────────────────────────────────────────
function handleCommandKey(evt: any) {
if (k.match("escape", evt) || evt.name === "ctrl-c") {
evt.preventDefault();
nav.exitCommand();
return;
}
if (evt.name === "return" || evt.name === "enter") {
evt.preventDefault();
const cmd = nav.commandBuffer();
runCommand(cmd);
nav.exitCommand();
return;
}
if (evt.name === "backspace") {
evt.preventDefault();
const buf = nav.commandBuffer();
if (buf.length === 0) {
nav.exitCommand();
return;
}
nav.backspaceCommand();
return;
}
// printable char
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
evt.preventDefault();
nav.appendCommand(evt.name);
return;
}
}
// ── Unified router (normal + visual) ───────────────────────────────────────
function dispatch(action: KeybindActionName, evt: any) {
const tab = nav.activeTab();
const pane = nav.activePane();
switch (action) {
// ── modes ──
case "escape":
evt.preventDefault();
if (nav.mode() === NavMode.VISUAL) {
nav.toNormal();
break;
}
k.clearPending();
break;
case "command":
evt.preventDefault();
nav.enterCommand();
break;
case "visual-mode":
evt.preventDefault();
nav.enterVisual();
break;
case "toggle-select":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
case "toggle-all":
case "invert-all":
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
break;
// ── tabs ──
case "tab-next":
evt.preventDefault();
nav.nextTab();
break;
case "tab-prev":
evt.preventDefault();
nav.prevTab();
break;
default: {
const dt = tabByDigit(action);
if (dt) {
evt.preventDefault();
nav.setActiveTab(dt);
break;
}
// ── 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) ──
if (action === "audio-toggle") {
evt.preventDefault();
audio.togglePlayback().catch(() => {});
break;
}
if (action === "audio-seek-forward") {
evt.preventDefault();
audio.seekRelative(10).catch(() => {});
break;
}
if (action === "audio-seek-backward") {
evt.preventDefault();
audio.seekRelative(-10).catch(() => {});
break;
}
if (action === "audio-next") {
evt.preventDefault();
advanceEpisode(1);
break;
}
if (action === "audio-prev") {
evt.preventDefault();
advanceEpisode(-1);
break;
}
// ── global app ──
if (action === "quit") {
evt.preventDefault();
process.exit(0);
}
if (action === "help") {
evt.preventDefault();
setShowHelp((v) => !v);
}
// ── page-local list/pane actions ──
if (PAGE_ACTIONS.has(action)) {
evt.preventDefault();
emit("nav.action", { action, tab, pane, mode: nav.mode() });
}
}
}
}
useKeyboard(
(evt: any) => {
// Input fields (search boxes, dialogs) own their keys.
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) return;
if (nav.mode() === NavMode.COMMAND) {
handleCommandKey(evt);
return;
}
const action = k.tryMatch(evt);
if (action) dispatch(action, evt);
},
{ release: false },
);
// ── Status bar fragments ──────────────────────────────────────────────────
const nowPlaying = () => {
const ep = audio.currentEpisode();
if (!ep) return null;
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
return `${title}`;
};
const modeLabel = () =>
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
const pendingLabel = () =>
k
.pending()
.map((s) => s.key)
.join(" ");
return (
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={t.surface}
>
{/* ── Middle row: tab sidebar (root pane) + active page ──────────────── */}
<box flexDirection="row" flexGrow={1} width="100%">
{/* ── Left tab sidebar ─────────────────────────────────────────────── */}
<box
flexDirection="column"
width={14}
height="100%"
backgroundColor={t.background}
border
borderColor={t.border}
>
<For
each={Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
)}
>
{(tab) => {
const active = () => nav.activeTab() === tab;
const focused = () =>
active() && nav.activePane() === SIDEBAR_PANE;
return (
<box
flexDirection="row"
backgroundColor={
focused() ? t.accent : active() ? t.primary : t.background
}
paddingLeft={1}
onMouseDown={() => {
nav.setActivePane(SIDEBAR_PANE);
nav.setActiveTab(tab);
}}
>
<text fg={focused() || active() ? t.surface : t.textMuted}>
{focused() ? " " : " "}
{tab}. {TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
<box flexGrow={1} backgroundColor={t.background} />
<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} height="100%">
{LayerGraph[nav.activeTab()]()}
</box>
</box>
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
<box
flexDirection="row"
height={1}
width="100%"
backgroundColor={t.backgroundPanel ?? t.background}
>
<Show
when={nav.mode() === NavMode.COMMAND}
fallback={
<>
<text fg={t.accent} paddingLeft={1}>
{modeLabel()}
</text>
<text fg={t.textMuted} paddingLeft={1}>
{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}>
{nav.selectedIds().length}
</text>
</Show>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}>
{pendingLabel()}
</text>
<text fg={t.textMuted} paddingRight={1}>
:cmd ~help q quit
</text>
</>
}
>
<text fg={t.accent} paddingLeft={1}>
:
</text>
<text fg={t.text}>{nav.commandBuffer()}</text>
<text fg={t.textMuted}></text>
<Show when={nav.commandError()}>
<text fg={t.error} paddingLeft={1}>
{nav.commandError()}
</text>
</Show>
</Show>
</box>
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
<Show when={showHelp()}>
<HelpOverlay
onClose={() => setShowHelp(false)}
sections={helpSections(k)}
theme={t as any}
/>
</Show>
</box>
);
}
function helpSections(k: ReturnType<typeof useKeybinds>) {
const p = (a: KeybindActionName) => k.print(a);
return [
{
group: "Move",
items: [
["j/k", "move"],
["J/K", "5 lines"],
["ctrl-d/u", "half page"],
["gg/G", "top/bottom"],
],
},
{
group: "Panes",
items: [
["h/l", "swipe pane"],
["1-6 / [ ]", "switch tabs"],
[":", "command"],
["~", "help"],
],
},
{
group: "Select",
items: [
["space", "toggle"],
["v", "visual"],
["ctrl-a", "all"],
["esc", "clear"],
],
},
{
group: "Audio",
items: [
[p("audio-toggle"), "play/pause"],
[p("audio-next"), "next"],
[p("audio-seek-forward"), "fwd 10s"],
[p("audio-seek-backward"), "back 10s"],
],
},
{
group: "List",
items: [
["enter", "open"],
["r", "refresh"],
["s", "search"],
["f", "filter"],
[",", "sort"],
[".", "hidden"],
],
},
];
}
function HelpOverlay(props: {
onClose: () => void;
sections: { group: string; items: string[][] }[];
theme: any;
}) {
useKeyboard((evt: any) => {
if (k_match_escape(evt)) {
evt.preventDefault();
props.onClose();
}
});
const th = props.theme;
return (
<box
position="absolute"
top={2}
left={0}
width="100%"
alignItems="center"
backgroundColor="rgba(0,0,0,160)"
onMouseUp={() => props.onClose()}
>
<box
flexDirection="column"
border
borderColor={th.border}
backgroundColor={th.backgroundPanel ?? th.background}
padding={1}
width={60}
onMouseUp={(e: any) => e.stopPropagation()}
>
<text fg={th.accent}>
Yazi-style keybinds press ~ or Esc to close
</text>
<For each={props.sections}>
{(sec) => (
<box flexDirection="column" marginTop={1}>
<text fg={th.textSecondary}>{sec.group}</text>
<For each={sec.items}>
{(it) => (
<box flexDirection="row" gap={2}>
<text fg={th.accent}>{String(it[0]).padEnd(14, " ")}</text>
<text fg={th.textPrimary ?? th.text}>{it[1]}</text>
</box>
)}
</For>
</box>
)}
</For>
<box marginTop={1}>
<text fg={th.textMuted}>
Edit ~/.config/podtui/keybinds.jsonc to remap.
</text>
</box>
</box>
</box>
);
}
function k_match_escape(evt: any): boolean {
return (
evt.name === "escape" || evt.name === "~" || (evt.ctrl && evt.name === "[")
);
}
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
* search) into the player tab. */
export function playEpisodeAndSwitch(
nav: ReturnType<typeof useNavigation>,
audio: ReturnType<typeof useAudio>,
episode: import("@/types/episode").Episode,
) {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
useAudioNavStore().setSource(AudioSource.FEED);
}
// Re-export Episode type for callers building pane trees.
export type { Episode };

View File

@@ -1,28 +1,27 @@
import { For } from "solid-js";
import { shortcuts } from "@/config/shortcuts"; import { shortcuts } from "@/config/shortcuts";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
/** Yazi-style keybind reference. The Shell has its own overlay; this component
* is kept for embedding inside Settings or other surfaces. */
export function ShortcutHelp() { export function ShortcutHelp() {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<box border title="Shortcuts" style={{ padding: 1 }}> <box
<box style={{ flexDirection: "column" }}> border
<box style={{ flexDirection: "row" }}> title="Shortcuts"
<text fg={theme.text}>{shortcuts[0]?.keys ?? ""} </text> style={{ flexDirection: "column", padding: 1 }}
<text fg={theme.text}>{shortcuts[0]?.action ?? ""}</text> >
</box> <box style={{ flexDirection: "column" }}>
<box style={{ flexDirection: "row" }}> <For each={shortcuts}>
<text fg={theme.text}>{shortcuts[1]?.keys ?? ""} </text> {(s) => (
<text fg={theme.text}>{shortcuts[1]?.action ?? ""}</text> <box style={{ flexDirection: "row" }} gap={2}>
</box> <text fg={theme.accent}>{s.keys}</text>
<box style={{ flexDirection: "row" }}> <text fg={theme.text}>{s.action}</text>
<text fg={theme.text}>{shortcuts[2]?.keys ?? ""} </text> </box>
<text fg={theme.text}>{shortcuts[2]?.action ?? ""}</text> )}
</box> </For>
<box style={{ flexDirection: "row" }}> </box>
<text fg={theme.text}>{shortcuts[3]?.keys ?? ""} </text> </box>
<text fg={theme.text}>{shortcuts[3]?.action ?? ""}</text> );
</box>
</box>
</box>
);
} }

View File

@@ -1,20 +0,0 @@
{
"up": ["up", "k"],
"down": ["down", "j"],
"left": ["left", "h"],
"right": ["right", "l"],
"cycle": ["tab"], // this will cycle no matter the depth/orientation
"dive": ["return"],
"out": ["esc"],
"inverseModifier": ["shift"],
"leader": ":", // will not trigger while focused on input
"quit": ["<leader>q"],
"refresh": ["<leader>r"],
"audio-toggle": ["<leader>p"],
"audio-pause": [],
"audio-play": [],
"audio-next": ["<leader>n"],
"audio-prev": ["<leader>l"],
"audio-seek-forward": ["<leader>sf"],
"audio-seek-backward": ["<leader>sb"],
}

73
src/config/keybinds.jsonc Normal file
View File

@@ -0,0 +1,73 @@
{
// ── Yazi-style keybinds for PodTui ──────────────────────────────────────
// Notation:
// "j" single lowercase key
// "G" shift + g (uppercase letter = shift)
// "ctrl-d" ctrl modifier
// "shift-j" shift modifier (equivalent to "J")
// "meta-x" alt/meta modifier (macOS users: map Option to Alt)
// ["g","g"] a two-key sequence (matches only when pressed in order)
// "return" special key (also: escape tab space backspace up down left right)
//
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
// : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl.
// ── Movement (within a pane) ─────────────────────────────────────────────
"move-down": ["j", "down"],
"move-up": ["k", "up"],
"page-down": ["ctrl-d"],
"page-up": ["ctrl-u"],
"full-down": ["ctrl-f"],
"full-up": ["ctrl-b"],
"jump-down": ["J"], // 5 lines down (shift+j)
"jump-up": ["K"], // 5 lines up (shift+k)
"goto-top": [["g", "g"]],
"goto-bottom": ["G"],
// ── Pane focus / swipe (yazi h/l) ────────────────────────────────────────
"swipe-prev": ["h", "left"], // focus left pane (parent)
"swipe-next": ["l", "right"], // focus right pane (preview)
// ── Open / activate ──────────────────────────────────────────────────────
"open": ["return", "enter"],
"open-interactive": ["shift-return"],
// ── Selection & visual mode ──────────────────────────────────────────────
"toggle-select": ["space"],
"visual-mode": ["v"],
"toggle-all": ["ctrl-a"],
"invert-all": ["ctrl-r"],
"escape": ["escape", "ctrl-["],
// ── Tabs ──────────────────────────────────────────────────────────────────
"tab-prev": ["["],
"tab-next": ["]"],
"tab-goto-1": ["1"],
"tab-goto-2": ["2"],
"tab-goto-3": ["3"],
"tab-goto-4": ["4"],
"tab-goto-5": ["5"],
"tab-goto-6": ["6"],
// ── Command bar & help & quit ────────────────────────────────────────────
"command": [":"],
"quit": ["q", "ctrl-c"],
"help": ["~", "f1"],
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
"search": ["s"],
"filter": ["f"],
"sort": [","],
"toggle-hidden": ["."],
"refresh": ["r"],
// ── Audio transport (preserved) ──────────────────────────────────────────
// Kept on shifted single keys so they never collide with the yazi core
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
"audio-toggle": ["P"], // play / pause (shift+p)
"audio-next": ["N"], // next episode (shift+n)
"audio-prev": ["B"], // prev episode (shift+b)
"audio-seek-forward": ["shift-."], // seek forward (shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (shift+,)
}

View File

@@ -1,6 +1,26 @@
/**
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
* Shown in help overlays; the canonical source remains keybinds.jsonc.
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
*/
export const shortcuts = [ export const shortcuts = [
{ keys: "Ctrl+Q", action: "Quit" }, { keys: "j / k", action: "Move down / up (within pane)" },
{ keys: "Ctrl+S", action: "Save" }, { keys: "h / l", action: "Swipe to prev / next pane" },
{ keys: "Left/Right", action: "Switch tabs" }, { keys: "J / K", action: "Jump 5 lines down / up" },
{ keys: "Esc", action: "Close modal" }, { keys: "ctrl-d / u", action: "Half page down / up" },
] as const { keys: "g g / G", action: "Go to top / bottom of list" },
{ keys: "1-6", action: "Go to tab 1-6" },
{ keys: "[ / ]", action: "Previous / next tab" },
{ keys: "Enter", action: "Open / activate focused item" },
{ keys: "Space", action: "Toggle selection on item" },
{ keys: "v", action: "Enter visual (range) select mode" },
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
{ keys: "r / s / f", action: "Refresh / search / filter" },
{ keys: ", / .", action: "Sort / toggle hidden" },
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
{ keys: "< / >", action: "Seek backward / forward 10s" },
{ keys: "~ / F1", action: "Help" },
{ keys: "q", action: "Quit" },
] as const;

View File

@@ -1,136 +1,375 @@
import { createSignal, onMount } from "solid-js"; import { createSignal, onMount } from "solid-js";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { import {
copyKeybindsIfNeeded, copyKeybindsIfNeeded,
loadKeybindsFromFile, loadKeybindsFromFile,
saveKeybindsToFile, saveKeybindsToFile,
} from "../utils/keybinds-persistence"; } from "../utils/keybinds-persistence";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
export type KeybindsResolved = { // ── Keybind model ───────────────────────────────────────────────────────────
up: string[]; // Yazi-style: every binding is one or more "strokes". A stroke is a single
down: string[]; // key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a
left: string[]; // sequence (e.g. ["g","g"] = gg, ["space","n"] = <leader>n). The matcher
right: string[]; // buffers keystrokes, prefers the longest matching sequence, and exposes the
cycle: string[]; // this will cycle no matter the depth/orientation // pending buffer reactively so the status bar can show it (very yazi).
dive: string[];
out: string[];
inverseModifier: string;
leader: string; // will not trigger while focused on input
quit: string[];
select: string[]; // for selecting/activating items
"audio-toggle": string[];
"audio-pause": string[];
"audio-play": string[];
"audio-next": string[];
"audio-prev": string[];
"audio-seek-forward": string[];
"audio-seek-backward": string[];
};
export enum KeybindAction { /** A single key press. `key` is the lowercase logical key name
UP, * ("j", "return", "space", "up", "f1", ...). */
DOWN, export interface Stroke {
LEFT, key: string;
RIGHT, ctrl?: boolean;
CYCLE, shift?: boolean;
DIVE, meta?: boolean;
OUT, }
QUIT,
SELECT, /** Raw config spec for one action: a list of alternative sequences. Each
AUDIO_TOGGLE, * alternative is itself a list of stroke-notation strings. So
AUDIO_PAUSE, * "j" -> [[ {key:"j"} ]]
AUDIO_PLAY, * ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]]
AUDIO_NEXT, * [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */
AUDIO_PREV, export type KeybindSpec = string | (string | string[])[];
AUDIO_SEEK_F,
AUDIO_SEEK_B, /** Canonical action names. Must match keys in keybinds.jsonc. */
export type KeybindActionName =
| "move-down"
| "move-up"
| "page-down"
| "page-up"
| "full-down"
| "full-up"
| "jump-down"
| "jump-up"
| "goto-top"
| "goto-bottom"
| "swipe-prev"
| "swipe-next"
| "open"
| "open-interactive"
| "toggle-select"
| "visual-mode"
| "toggle-all"
| "invert-all"
| "escape"
| "tab-prev"
| "tab-next"
| "tab-goto-1"
| "tab-goto-2"
| "tab-goto-3"
| "tab-goto-4"
| "tab-goto-5"
| "tab-goto-6"
| "command"
| "quit"
| "help"
| "search"
| "filter"
| "sort"
| "toggle-hidden"
| "refresh"
| "audio-toggle"
| "audio-next"
| "audio-prev"
| "audio-seek-forward"
| "audio-seek-backward"
// legacy compat (kept so older callers don't crash)
| "select"
| "leader"
| "inverseModifier"
| "cycle"
| "dive"
| "out"
| "up"
| "down"
| "left"
| "right"
| "audio-pause"
| "audio-play";
/** Resolved config: action -> list of alternative stroke-sequences. */
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
const SEQ_TIMEOUT_MS = 600;
// ── Stroke parsing ───────────────────────────────────────────────────────────
// Notation: "ctrl-d", "shift-j", "meta-x", "C-d", "M-x", "S-j".
// uppercase letter "G" => {key:"g", shift:true}
// special: return/enter/escape/tab/space/backspace/up/down/left/right
export function parseStroke(notation: string): Stroke {
const raw = notation.trim();
let ctrl = false,
shift = false,
meta = false;
let key = raw;
const parts = raw.split(/[-+]/);
if (parts.length > 1) {
key = parts[parts.length - 1];
for (const mod of parts.slice(0, -1)) {
const m = mod.toLowerCase();
if (m === "ctrl" || m === "c") ctrl = true;
else if (m === "shift" || m === "s") shift = true;
else if (m === "meta" || m === "alt" || m === "m") meta = true;
}
}
// Uppercase single letter w/o modifier => shift+letter (vim convention)
if (
parts.length === 1 &&
key.length === 1 &&
key >= "A" &&
key <= "Z" &&
!ctrl &&
!shift &&
!meta
) {
shift = true;
key = key.toLowerCase();
}
return { key: key.toLowerCase(), ctrl, shift, meta };
}
/** Turn one raw spec into a list of alternative stroke-sequences. */
export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
if (spec == null) return [];
const alts: Stroke[][] = [];
const push = (item: string | string[]) => {
const seq = Array.isArray(item) ? item : [item];
alts.push(seq.map(parseStroke));
};
if (Array.isArray(spec)) {
for (const item of spec) push(item);
} else {
push(spec);
}
return alts;
}
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
export function strokeFromEvent(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): Stroke {
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
return {
key: (evt.name ?? "").toLowerCase(),
ctrl: !!evt.ctrl,
shift: !!evt.shift,
meta: !!evt.meta,
};
}
function strokeEq(a: Stroke, b: Stroke): boolean {
return (
a.key === b.key &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.meta === !!b.meta
);
}
/** A human label for a stroke, for the status bar / help. */
export function strokeLabel(s: Stroke): string {
let out = "";
if (s.ctrl) out += "C-";
if (s.meta) out += "M-";
if (s.shift) out += "S-";
out += s.key;
return out;
}
export function sequenceLabel(seq: Stroke[]): string {
return seq.map(strokeLabel).join(" ");
} }
export const { use: useKeybinds, provider: KeybindProvider } = export const { use: useKeybinds, provider: KeybindProvider } =
createSimpleContext({ createSimpleContext({
name: "Keybinds", name: "Keybinds",
init: () => { init: () => {
const [store, setStore] = createStore({ const [store, setStore] = createStore<KeybindsResolved>({});
up: [], // Resolved sequences per action, recomputed when store changes.
down: [], const [resolved, setResolved] = createSignal<Record<string, Stroke[][]>>(
left: [], {},
right: [], );
cycle: [], const [ready, setReady] = createSignal(false);
dive: [], const [pending, setPending] = createSignal<Stroke[]>([]);
out: [],
inverseModifier: "",
leader: "",
quit: [],
select: [],
refresh: [],
"audio-toggle": [],
"audio-pause": [],
"audio-play": [],
"audio-next": [],
"audio-prev": [],
"audio-seek-forward": [],
"audio-seek-backward": [],
} as KeybindsResolved);
const [ready, setReady] = createSignal(false);
async function load() { let pendingTimer: ReturnType<typeof setTimeout> | undefined;
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
setReady(true);
}
async function save() { function recompute() {
saveKeybindsToFile(store); const out: Record<string, Stroke[][]> = {};
} for (const name of Object.keys(store) as string[]) {
out[name] = parseBindingSpec((store as any)[name]);
}
setResolved(out);
}
function print(input: keyof KeybindsResolved): string { async function load() {
const keys = store[input] || []; await copyKeybindsIfNeeded();
return Array.isArray(keys) ? keys.join(", ") : keys; const keybinds = await loadKeybindsFromFile();
} setStore(keybinds);
recompute();
setReady(true);
}
function match( async function save() {
keybind: keyof KeybindsResolved, saveKeybindsToFile(store as KeybindsResolved);
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean }, }
): boolean {
const keys = store[keybind];
if (!keys) return false;
for (const key of keys) { function print(input: KeybindActionName): string {
if (evt.name === key) return true; const alts = resolved()[input] ?? [];
} return alts.map(sequenceLabel).join(" / ") || "—";
return false; }
}
function isInverting(evt: { function clearPending() {
name: string; if (pendingTimer) {
ctrl?: boolean; clearTimeout(pendingTimer);
meta?: boolean; pendingTimer = undefined;
shift?: boolean; }
}) { if (pending().length > 0) setPending([]);
if (store.inverseModifier === "ctrl" && evt.ctrl) return true; }
if (store.inverseModifier === "meta" && evt.meta) return true;
if (store.inverseModifier === "shift" && evt.shift) return true;
return false;
}
// Load on mount function armTimer() {
onMount(() => { if (pendingTimer) clearTimeout(pendingTimer);
load().catch(() => {}); pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS);
}); }
return { /** Look up every action whose sequence-list the candidate is a prefix of
get ready() { * (i.e. a longer match is still possible) and every action that the
return ready(); * candidate exactly equals. */
}, function classify(candidate: Stroke[]) {
get keybinds() { const exact: KeybindActionName[] = [];
return store; const prefix: KeybindActionName[] = [];
}, const map = resolved();
save, for (const name of Object.keys(map) as KeybindActionName[]) {
print, for (const seq of map[name] ?? []) {
match, if (seq.length < candidate.length) continue;
isInverting, let isPrefix = true;
}; for (let i = 0; i < candidate.length; i++) {
}, if (!strokeEq(seq[i], candidate[i])) {
}); isPrefix = false;
break;
}
}
if (!isPrefix) continue;
if (seq.length === candidate.length) exact.push(name);
else prefix.push(name);
}
}
return { exact, prefix };
}
/** Legacy single-key matcher (used by command-palette registrations and
* older call sites). Returns true iff `name`'s sequence list contains a
* single-stroke alternative equal to the event. */
function match(
name: KeybindActionName,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const alts = resolved()[name] ?? [];
const s = strokeFromEvent(evt);
// skip in command/input mode unless explicitly handled by caller
for (const seq of alts) {
if (seq.length === 1 && strokeEq(seq[0], s)) return true;
}
return false;
}
/** New sequence-aware matcher. Returns the resolved action or null.
* Callers should invoke this once per keypress in a single router. */
function tryMatch(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): KeybindActionName | null {
const stroke = strokeFromEvent(evt);
const candidate = [...pending(), stroke];
const { exact, prefix } = classify(candidate);
// Still mid-sequence: wait for more keys (unless this stroke also
// exactly matches something AND nothing depends on a longer prefix).
if (prefix.length > 0) {
setPending(candidate);
armTimer();
// If there's also an exact match, we *could* fire now — but yazi
// prefers to wait for the longer sequence within the timeout, then
// falls through. We honor that: only fire exact if no prefix.
if (exact.length > 0) {
// ambiguous prefix+exact: keep waiting (e.g. `g` could be gg)
}
return null;
}
// No longer-match possible: decide on exact.
clearPending();
if (exact.length === 0) {
// The new stroke might itself begin a fresh sequence.
const fresh = classify([stroke]);
if (fresh.prefix.length > 0) {
setPending([stroke]);
armTimer();
return null;
}
if (fresh.exact.length > 0) {
// prefer longest-sequence match among fresh.exact
return pickLongest(fresh.exact);
}
return null;
}
return pickLongest(exact);
}
function pickLongest(names: KeybindActionName[]): KeybindActionName {
const map = resolved();
let best = names[0];
let bestLen = 0;
for (const n of names) {
for (const seq of map[n] ?? []) {
if (seq.length > bestLen) {
bestLen = seq.length;
best = n;
}
}
}
return best;
}
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
// so it always reports false. Migrated callers should use tryMatch().
function isInverting(_evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}): boolean {
return false;
}
onMount(() => {
load().catch(() => {});
});
return {
get ready() {
return ready();
},
get keybinds() {
return store;
},
get resolved() {
return resolved();
},
pending,
match,
tryMatch,
isInverting,
print,
save,
load,
clearPending,
};
},
});

View File

@@ -1,73 +1,426 @@
import { createEffect, createSignal, on } from "solid-js"; import { createEffect, createSignal, on, batch, createMemo } from "solid-js";
import { createSimpleContext } from "./helper"; import { createSimpleContext } from "./helper";
import { TABS, TabsCount, LayerDepths } from "@/utils/navigation"; import { TABS, TabsCount, DEPTH_TABS, rootFrameFor } from "@/utils/navigation";
// Page-specific pane counts // ── Yazi-style navigation state ──────────────────────────────────────────────
const PANE_COUNTS = { // Two pane models coexist:
[TABS.FEED]: 1, //
[TABS.MYSHOWS]: 2, // • Depth-stack tabs (Feed, MyShows, Discover, Settings) use a yazi-style
[TABS.DISCOVER]: 2, // depth stack. The three content columns render as:
[TABS.SEARCH]: 3, // left = the previous depth's list (empty at depth 0)
[TABS.PLAYER]: 1, // center = the current depth's list (always where focus lives)
[TABS.SETTINGS]: 5, // 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",
VISUAL = "VISUAL",
COMMAND = "COMMAND",
INPUT = "INPUT",
}
/** 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, // 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
// selection anchored at the focused index.
type SelectionMap = Record<string, Set<string>>;
const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL;
export const { use: useNavigation, provider: NavigationProvider } = export const { use: useNavigation, provider: NavigationProvider } =
createSimpleContext({ createSimpleContext({
name: "Navigation", name: "Navigation",
init: () => { init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED); const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activeDepth, setActiveDepth] = createSignal(0); // App focus starts on the left tab sidebar (root pane); tab switches
const [inputFocused, setInputFocused] = createSignal(false); // 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);
createEffect( // per-tab depth stack. Depth-tabs get a root frame on first visit.
on( const [stacks, setStacks] = createSignal<
() => activeTab, Partial<Record<TABS, DepthFrame[]>>
() => setActiveDepth(0), >({ [TABS.FEED]: [rootFrameFor(TABS.FEED)] });
),
);
const nextTab = () => { // per-pane focused index (for j/k movement in fixed-pane tabs). Keyed
if (activeTab() >= TabsCount) { // by `${tab}:${pane}`. Depth-tabs read/write the top frame's `focus`
setActiveTab(1); // for pane 0 (DEPTH_CENTER_PANE) instead.
return; const [paneIndices, setPaneIndices] = createSignal<
} Record<string, number>
setActiveTab(activeTab() + 1); >({});
}; const [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
index: number;
} | null>(null);
const prevTab = () => { const [commandBuffer, setCommandBuffer] = createSignal("");
if (activeTab() <= 1) { const [commandError, setCommandError] = createSignal<string | null>(null);
setActiveTab(TabsCount);
return;
}
setActiveTab(activeTab() - 1);
};
const nextPane = () => { /** Depth stack for a tab (empty for fixed-pane tabs). */
// Move to next pane within the current tab's pane structure const depthStackFor = (tab: TABS = activeTab()) => stacks()[tab] ?? [];
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev % count) + 1);
};
const prevPane = () => { const ensureStack = (tab: TABS) => {
// Move to previous pane within the current tab's pane structure if (DEPTH_TABS.has(tab) && depthStackFor(tab).length === 0) {
const count = PANE_COUNTS[activeTab()]; setStacks((s) => ({ ...s, [tab]: [rootFrameFor(tab)] }));
if (count <= 1) return; // No panes to navigate (feed/player) }
setActiveDepth((prev) => (prev - 2 + count) % count + 1); };
};
return { // On tab change: ensure a root frame exists (depth-tabs) + reset
activeTab, // focus to the sidebar, clear modes/command/visual state.
activeDepth, createEffect(
inputFocused, on(activeTab, (tab) => {
setActiveTab, ensureStack(tab);
setActiveDepth, batch(() => {
setInputFocused, setActivePane(SIDEBAR_PANE);
nextTab, setMode(NavMode.NORMAL);
prevTab, setCount(null);
nextPane, setCommandBuffer("");
prevPane, setCommandError(null);
}; setVisualAnchor(null);
}, });
}); }),
);
// ── 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;
setActiveTab(tab);
};
const nextTab = () =>
setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS)));
const prevTab = () =>
setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS)));
// ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane);
/** 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) => {
setActivePane((p) => {
const n = Math.max(SIDEBAR_PANE, Math.min(paneCount - 1, p + dir));
return n;
});
};
// ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
/** 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) => {
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. */
const move = (
delta: number,
listLen: number,
countOverride?: number,
): number => {
if (listLen <= 0) return 0;
const steps = countOverride ?? count() ?? 1;
const pane = activePane();
const cur = focusedIndex(pane);
let next = cur + delta * steps;
// wrap-around like yazi (arrow wraps top<->bottom)
next = ((next % listLen) + listLen) % listLen;
setFocusedIndex(pane, next);
// visual-mode range selection: add newly-traversed items to selection
if (HAS_VISUAL(mode()) && visualAnchor()) {
growVisualSelection(next);
}
return next;
};
const gotoIndex = (index: number, listLen: number): number => {
if (listLen <= 0) return 0;
const pane = activePane();
const next = Math.max(0, Math.min(listLen - 1, index));
setFocusedIndex(pane, next);
if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next);
return next;
};
// ── selection ───────────────────────────────────────────────────────────
const selSet = (key: string): Set<string> =>
selections()[key] ?? new Set();
const toggleSelected = (id: string) => {
const key = paneKey();
setSelections((m) => {
const set = new Set(m[key] ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
return { ...m, [key]: set };
});
};
const isSelected = (id: string) => selSet(paneKey()).has(id);
const clearSelection = (key?: string) => {
const k = key ?? paneKey();
setSelections((m) => {
if (!(k in m)) return m;
const next = { ...m };
delete next[k];
return next;
});
};
const selectedIds = createMemo(() => [...selSet(paneKey())]);
/** Enter visual mode, anchoring range selection at the current focus. */
const enterVisual = () => {
const pane = activePane();
setVisualAnchor({ paneKey: paneKey(pane), index: focusedIndex(pane) });
setMode(NavMode.VISUAL);
};
/** Grow selection between the visual anchor and `index` for the active
* pane. Callers pass item ids aligned to indices; we store ids via the
* resolve callback registered per-pane (see registerResolver). */
let resolvers: Record<string, (index: number) => string | undefined> = {};
const registerResolver = (
key: string,
fn: (i: number) => string | undefined,
) => {
resolvers[key] = fn;
};
const growVisualSelection = (index: number) => {
const anchor = visualAnchor();
if (!anchor) return;
const resolve = resolvers[anchor.paneKey];
if (!resolve) return;
const lo = Math.min(anchor.index, index);
const hi = Math.max(anchor.index, index);
const ids: string[] = [];
for (let i = lo; i <= hi; i++) {
const id = resolve(i);
if (id) ids.push(id);
}
const key = anchor.paneKey;
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
};
// ── modes ────────────────────────────────────────────────────────────────
const enterCommand = () => {
setMode(NavMode.COMMAND);
setCommandBuffer("");
setCommandError(null);
};
const enterInput = () => setMode(NavMode.INPUT);
const exitCommand = () => {
batch(() => {
setMode(NavMode.NORMAL);
setCommandBuffer("");
setCommandError(null);
});
};
const exitVisual = () => {
batch(() => {
setMode(NavMode.NORMAL);
setVisualAnchor(null);
});
};
const toNormal = () => {
if (mode() === NavMode.VISUAL) {
clearSelection();
exitVisual();
} else {
setMode(NavMode.NORMAL);
}
};
// ── command buffer ───────────────────────────────────────────────────────
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
const submitCommand = (): string => {
const cmd = commandBuffer().trim();
exitCommand();
return cmd;
};
// ── count register ───────────────────────────────────────────────────────
const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d);
const consumeCount = (): number => {
const c = count();
setCount(null);
return c ?? 1;
};
return {
activeTab,
activePane,
mode,
count,
inputFocused,
commandBuffer,
commandError,
visualAnchor,
selections,
selectedIds,
// depth stack
depthStack,
currentDepth,
topFrame,
depthFocus,
setDepthFocus,
pushDepth,
popDepth,
isDepthTab,
// tab
setActiveTab: gotoTab,
nextTab,
prevTab,
// pane focus
setActivePane: setPane,
swipe,
// focus index
focusedIndex,
setFocusedIndex,
move,
gotoIndex,
// selection
isSelected,
toggleSelected,
clearSelection,
selectedIdsFor: (key: string) => [...selSet(key)],
registerResolver,
enterVisual,
exitVisual,
// modes
setActiveTabSignal: setActiveTab,
setActiveDepth: setPane, // legacy alias
activeDepth: activePane, // legacy alias
setInputFocused,
nextPane: () => {}, // legacy noop; swipe() replaces this
prevPane: () => {},
setMode,
enterCommand,
enterInput,
exitCommand,
toNormal,
// command buffer
setCommandBuffer,
appendCommand,
backspaceCommand,
submitCommand,
setCommandError,
// count
pushCountDigit,
consumeCount,
};
},
});

View File

@@ -1,185 +1,401 @@
/** /**
* DiscoverPage component - Main discover/browse interface for PodTUI * DiscoverPage — yazi depth-stack view of discoverable podcasts.
*
* 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).
*
* `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 { createSignal, For, Show, onMount } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover"; import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { PodcastCard } from "./PodcastCard"; import {
import { SelectableBox, SelectableText } from "@/components/Selectable"; useNavigation,
import { useNavigation } from "@/context/NavigationContext"; NavMode,
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; DEPTH_CENTER_PANE,
type PaneId,
type DepthFrame,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum DiscoverPagePaneType { export const DiscoverPaneCount = 1;
CATEGORIES = 1,
SHOWS = 2, function DiscoverPage() {
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
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 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;
const ensureFocus = () => {
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);
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id;
return podcasts()[i]?.id;
});
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
const c = focusedCategory();
if (!c) return;
discoverStore.setSelectedCategory(c.id);
nav.pushDepth({ kind: "results", ctx: c.id, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
return;
}
if (depth() >= 1) {
const pod = focusedPodcast();
if (pod) discoverStore.toggleSubscription(pod.id);
}
}
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => 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);
}
},
refresh: () => {
discoverStore.refresh().catch(() => {});
},
};
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();
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
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%">
{/* ── left: previous depth (empty at root) ──────────────────────────── */}
<box
flexDirection="column"
flexGrow={PANE_RATIO.parent}
flexShrink={1}
flexBasis={0}
height="100%"
style={{ width: depth() === 0 ? 0 : undefined }}
overflow="hidden"
>
<Show when={depth() >= 1}>
<box height={1} paddingLeft={1} backgroundColor={headerBg}>
<text fg={theme.textSecondary}>Categories</text>
</box>
<scrollbox
height="100%"
border
borderColor={theme.border}
backgroundColor={theme.background}
>
<For each={categories()}>
{(cat, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={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 (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), lf, isActive)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>{cat.name}</text>
<Show when={selected()}>
<text fg={index() === lf ? theme.surface : theme.accent}>
*
</text>
</Show>
</box>
);
}}
</For>
</Show>
{/* depth ≥1: results */}
<Show when={depth() >= 1}>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => {
const lf = focusedPodIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
{podcast.title}
</text>
<Show when={podcast.isSubscribed}>
<text
fg={index() === lf ? theme.surface : theme.success}
>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={index() === lf ? theme.surface : muted()}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
);
}}
</For>
</Show>
</Show>
</scrollbox>
</box>
{/* ── 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%"
border
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={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>
Updated: {formatDate(pod().lastUpdated)}
</text>
<box height={1} />
<text fg={muted()}>
enter: subscribe · h: back · r: refresh
</text>
</box>
)}
</Show>
</Show>
</scrollbox>
</box>
</box>
);
} }
export const DiscoverPaneCount = 2;
export function DiscoverPage() { export { DiscoverPage };
const discoverStore = useDiscoverStore();
const [showIndex, setShowIndex] = createSignal(0);
const [categoryIndex, setCategoryIndex] = createSignal(0);
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
setShowIndex(showIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return;
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length === 0) return;
if (isDown && !isInverting()) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if (isUp && isInverting()) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
}
},
{ release: false },
);
});
const handleCategorySelect = (categoryId: string) => {
discoverStore.setSelectedCategory(categoryId);
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
if (index >= 0) setCategoryIndex(index);
setShowIndex(0);
};
const handleShowSelect = (index: number) => {
setShowIndex(index);
};
const handleSubscribe = (podcast: { id: string }) => {
discoverStore.toggleSubscription(podcast.id);
};
const { theme } = useTheme();
return (
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
<box
border
padding={1}
borderColor={
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
? theme.border
: theme.accent
}
flexDirection="column"
gap={1}
>
<text
fg={
nav.activeDepth() == DiscoverPagePaneType.CATEGORIES
? theme.accent
: theme.text
}
>
Categories:
</text>
<box flexDirection="column" gap={1}>
<For each={discoverStore.categories}>
{(category) => {
const isSelected = () =>
discoverStore.selectedCategory() === category.id;
return (
<SelectableBox
selected={isSelected}
onMouseDown={() => handleCategorySelect(category.id)}
>
<SelectableText selected={isSelected} primary>
{category.icon} {category.name}
</SelectableText>
</SelectableBox>
);
}}
</For>
</box>
</box>
<box
flexDirection="column"
flexGrow={1}
border
borderColor={
nav.activeDepth() == DiscoverPagePaneType.SHOWS
? theme.accent
: theme.border
}
>
<box padding={1}>
<SelectableText
selected={() => false}
primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
Trending in{" "}
{DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory(),
)?.name ?? "All"}
</SelectableText>
</box>
<box flexDirection="column" height="100%">
<Show
fallback={
<box padding={2}>
{discoverStore.filteredPodcasts().length !== 0 ? (
<text fg={theme.warning}>Loading trending shows...</text>
) : (
<text fg={theme.textMuted}>
No podcasts found in this category.
</text>
)}
</box>
}
when={
!discoverStore.isLoading() &&
discoverStore.filteredPodcasts().length === 0
}
>
<scrollbox
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
<box flexDirection="column">
<For each={discoverStore.filteredPodcasts()}>
{(podcast, index) => (
<PodcastCard
podcast={podcast}
selected={
index() === showIndex() &&
nav.activeDepth() == DiscoverPagePaneType.SHOWS
}
onSelect={() => handleShowSelect(index())}
onSubscribe={() => handleSubscribe(podcast)}
/>
)}
</For>
</box>
</scrollbox>
</Show>
</box>
</box>
</box>
);
}

View File

@@ -1,195 +1,530 @@
/** /**
* FeedPage - Shows latest episodes across all subscribed shows * FeedPage — yazi depth-stack view of episodes across subscribed shows.
* Reverse chronological order, grouped by date *
* 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.
*
* `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 { createSignal, For, Show, onMount } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
useNavigation,
NavMode,
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 { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed"; import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { TABS } from "@/utils/navigation"; import { PANE_RATIO } from "@/utils/navigation";
import { useKeyboard } from "@opentui/solid";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
enum FeedPaneType {
FEED = 1,
}
export const FeedPaneCount = 1; export const FeedPaneCount = 1;
const ITEMS_PER_BATCH = 50; type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
type EpItem = { episode: Episode; feed: Feed };
export function FeedPage() { function FeedPage() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const nav = useNavigation(); const downloadStore = useDownloadStore();
const { theme } = useTheme(); const audioNav = useAudioNavStore();
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal< const audio = useAudio();
string | undefined const { theme } = useTheme();
>(); const muted = () => theme.muted || theme.text;
const allEpisodes = () => feedStore.getAllEpisodesChronological(); const nav = useNavigation();
const keybind = useKeybinds();
const [focusedIndex, setFocusedIndex] = createSignal(0);
onMount(() => { const stack = nav.depthStack;
useKeyboard( const depth = nav.currentDepth;
(keyEvent: any) => { const focus = (d: number = depth()) => nav.depthFocus(d);
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) { // ── feeds list (depth 0) ─────────────────────────────────────────────────
const episodes = allEpisodes(); const feedList = createMemo<FeedListItem[]>(() => {
if (episodes.length > 0 && episodes[focusedIndex()]) { const all: FeedListItem[] = [{ kind: "all" }];
setSelectedEpisodeID(episodes[focusedIndex()].episode.id); for (const f of feedStore.getFilteredFeeds())
} all.push({ kind: "feed", feed: f });
return; return all;
} });
const focusedFeedIdx = () =>
feedList().length === 0 ? 0 : Math.min(focus(0), feedList().length - 1);
const focusedFeedItem = (): FeedListItem | undefined =>
feedList()[focusedFeedIdx()];
// don't handle pane navigation here - unified in App.tsx // ── episodes list (depth 1) — derived from the depth-1 frame's ctx ───────
if (nav.activeDepth() !== FeedPaneType.FEED) return; const drilledFeedId = (): string => stack()[1]?.ctx ?? "all";
const episodes = createMemo<EpItem[]>(() => {
if (depth() < 1) return [];
const id = drilledFeedId();
if (id === "all")
return feedStore.getAllEpisodesChronological() as EpItem[];
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: f }));
});
const focusedEpIdx = () =>
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
const focusedItem = (): EpItem | undefined => episodes()[focusedEpIdx()];
const episodes = allEpisodes(); const curLen = () => (depth() === 0 ? feedList().length : episodes().length);
if (episodes.length === 0) return;
if (isDown && !isInverting()) { const ensureFocus = () => {
setFocusedIndex((i) => (i + 1) % episodes.length); if (depth() === 0 && feedList().length > 0 && focus(0) >= feedList().length)
} else if (isUp && isInverting()) { nav.setDepthFocus(feedList().length - 1, 0);
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length); if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { nav.setDepthFocus(episodes().length - 1, 1);
setFocusedIndex((i) => (i + 1) % episodes.length); };
} else if ((isCycle && isInverting()) || (isUp && isInverting())) { onMount(ensureFocus);
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
}
},
{ release: false },
);
});
const formatDate = (date: Date): string => { onMount(() => {
return format(date, "MMM d, yyyy"); 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;
});
});
const groupEpisodesByDate = () => { // ── helpers ────────────────────────────────────────────────────────────────
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {}; const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (item: EpItem | undefined) => {
if (!item) return;
audio.play(item.episode).catch(() => {});
audioNav.setSource(AudioSource.FEED);
};
for (const item of allEpisodes()) { // ── drill / open ───────────────────────────────────────────────────────────
const dateKey = formatDate(new Date(item.episode.pubDate)); function open() {
if (!groups[dateKey]) { if (depth() === 0) {
groups[dateKey] = []; const item = focusedFeedItem();
} if (!item) return;
groups[dateKey].push(item); 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());
}
}
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => { // ── nav.action handler ────────────────────────────────────────────────────
// Convert date strings back to Date objects for proper chronological sorting const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
const dateA = new Date(a); "move-down": () => step(1),
const dateB = new Date(b); "move-up": () => step(-1),
// Sort in descending order (newest first) "jump-down": () => step(5),
return dateB.getTime() - dateA.getTime(); "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);
}
},
refresh: () => {
const item = focusedFeedItem();
if (item?.kind === "feed")
feedStore.refreshFeed(item.feed.id).catch(() => {});
else feedStore.refreshAllFeeds().catch(() => {});
},
};
function step(delta: number) {
nav.move(delta, curLen());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
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));
});
const formatDuration = (seconds: number): string => { // ── render ──────────────────────────────────────────────────────────────────
const mins = Math.floor(seconds / 60); const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const hrs = Math.floor(mins / 60); const border = (active: boolean) => (active ? theme.accent : theme.border);
if (hrs > 0) return `${hrs}h ${mins % 60}m`; const focusBg = (i: number, listFocus: number, active: boolean) =>
return `${mins}m`; i === listFocus && active
}; ? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active ? theme.surface : theme.text;
const headerBg = theme.background;
return ( const feedLabel = (item: FeedListItem) =>
<box item.kind === "all"
border ? "All Feeds"
borderColor={ : item.feed.customName || item.feed.podcast.title;
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent const feedCount = (item: FeedListItem) =>
} item.kind === "all"
backgroundColor={theme.background} ? feedStore.getAllEpisodesChronological().length
flexDirection="column" : item.feed.episodes.length;
height="100%"
width="100%" return (
> <box flexDirection="row" flexGrow={1} width="100%" height="100%">
<Show {/* ── left: previous depth (empty at root) ──────────────────────────── */}
when={allEpisodes().length > 0} <box
fallback={ flexDirection="column"
<box padding={2}> flexGrow={PANE_RATIO.parent}
<text fg={theme.textMuted}> flexShrink={1}
No episodes yet. Subscribe to shows from Discover or Search. flexBasis={0}
</text> height="100%"
</box> style={{ width: depth() === 0 ? 0 : undefined }}
} overflow="hidden"
> >
<scrollbox <Show when={depth() >= 1}>
height="100%" <box height={1} paddingLeft={1} backgroundColor={headerBg}>
focused={nav.activeDepth() == FeedPaneType.FEED} <text fg={theme.textSecondary}>
> Feeds · {feedList().length - 1}
<For each={groupEpisodesByDate()}> </text>
{([date, items]) => ( </box>
<box flexDirection="column" gap={1} padding={1}> <scrollbox
<SelectableText selected={() => false} primary> height="100%"
{date} border
</SelectableText> borderColor={theme.border}
<For each={items}> backgroundColor={theme.background}
{(item) => { >
const isSelected = () => { <For each={feedList()}>
if ( {(item, index) => (
nav.activeTab() == TABS.FEED && <box
nav.activeDepth() == FeedPaneType.FEED && flexDirection="row"
selectedEpisodeID() && gap={1}
selectedEpisodeID() === item.episode.id paddingLeft={1}
) { paddingRight={1}
return true; backgroundColor={focusBg(index(), nav.depthFocus(0), false)}
} >
return false; <text fg={focusFg(index(), nav.depthFocus(0), false)}>
}; {index() === nav.depthFocus(0) ? "" : " "}
const isFocused = () => { </text>
const episodes = allEpisodes(); <text fg={focusFg(index(), nav.depthFocus(0), false)}>
const currentIndex = episodes.findIndex( {feedLabel(item)}
(e: any) => e.episode.id === item.episode.id, </text>
); <text fg={muted()}>({feedCount(item)})</text>
return currentIndex === focusedIndex(); </box>
}; )}
return ( </For>
<SelectableBox </scrollbox>
selected={isSelected} </Show>
flexDirection="column" </box>
gap={0}
paddingLeft={1} {/* ── center: current depth ─────────────────────────────────────────── */}
paddingRight={1} <box
paddingTop={0} flexDirection="column"
paddingBottom={0} flexGrow={PANE_RATIO.current}
onMouseDown={() => { flexShrink={1}
setSelectedEpisodeID(item.episode.id); flexBasis={0}
const episodes = allEpisodes(); height="100%"
setFocusedIndex( >
episodes.findIndex((e: any) => e.episode.id === item.episode.id), <box height={1} paddingLeft={1} backgroundColor={headerBg}>
); <text fg={theme.textSecondary}>
}} {depth() === 0
> ? `Feeds · ${feedList().length - 1}`
<SelectableText selected={isSelected} primary> : `${(() => {
{item.episode.title} const fi = focusedFeedItem();
</SelectableText> return fi?.kind === "feed"
<box flexDirection="row" gap={2} paddingLeft={2}> ? fi.feed.customName || fi.feed.podcast.title
<SelectableText selected={isSelected} primary> : "All Episodes";
{item.feed.podcast.title} })()} · ${episodes().length}`}
</SelectableText> </text>
<SelectableText selected={isSelected} tertiary> </box>
{formatDuration(item.episode.duration)} <scrollbox
</SelectableText> height="100%"
</box> focused={isActive}
</SelectableBox> border
); borderColor={border(isActive)}
}} backgroundColor={theme.background}
</For> >
</box> {/* depth 0: feeds */}
)} <Show when={depth() === 0}>
</For> <Show
</scrollbox> when={feedList().length > 1}
</Show> fallback={
</box> <box padding={1}>
); <text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={feedList()}>
{(item, index) => {
const fi = focusedFeedIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), fi, isActive)}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive)}>
{feedLabel(item)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
({feedCount(item)})
</text>
</box>
);
}}
</For>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => {
const fi = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi, isActive)}>
{index() === fi ? "" : " "}
</text>
<text fg={focusFg(index(), fi, isActive)}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === fi ? theme.surface : theme.info}>
{formatDate(item.episode.pubDate)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{formatDuration(item.episode.duration)}
</text>
<text fg={index() === fi ? theme.surface : muted()}>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</Show>
</scrollbox>
</box>
{/* ── 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%"
border
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={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => {
const it = item();
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{it.episode.episodeNumber
? `#${it.episode.episodeNumber} `
: ""}
{it.episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>
{formatDate(it.episode.pubDate)}
</text>
<text fg={muted()}>
{formatDuration(it.episode.duration)}
</text>
<Show when={downloadLabel(it.episode.id)}>
<text fg={downloadColor(it.episode.id)}>
{downloadLabel(it.episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{it.feed.customName || it.feed.podcast.title}
</text>
<Show when={it.feed.podcast.author}>
<text fg={muted()}>by {it.feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{it.episode.description?.slice(0, 400) ??
"No description available."}
{(it.episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · space: select · h: back
</text>
</box>
);
}}
</Show>
</Show>
</scrollbox>
</box>
</box>
);
} }
export { FeedPage };

View File

@@ -1,325 +1,469 @@
/** /**
* MyShowsPage - Two-panel file-explorer style view * MyShowsPage — yazi depth-stack view of subscribed shows.
* Left panel: list of subscribed shows *
* Right panel: episodes for the selected show * 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.
*
* `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 { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useNavigation } from "@/context/NavigationContext"; import {
useNavigation,
NavMode,
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 { LoadingIndicator } from "@/components/LoadingIndicator";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; import { PANE_RATIO } from "@/utils/navigation";
enum MyShowsPaneType { export const MyShowsPaneCount = 1;
SHOWS = 1,
EPISODES = 2,
}
export const MyShowsPaneCount = 2;
export function MyShowsPage() { export function MyShowsPage() {
const feedStore = useFeedStore(); const feedStore = useFeedStore();
const downloadStore = useDownloadStore(); const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const [isRefreshing, setIsRefreshing] = createSignal(false); const audio = useAudio();
const [showIndex, setShowIndex] = createSignal(0); const { theme } = useTheme();
const [episodeIndex, setEpisodeIndex] = createSignal(0); const muted = () => theme.muted || theme.text;
const { theme } = useTheme(); const nav = useNavigation();
const mutedColor = () => theme.muted || theme.text;
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => { const stack = nav.depthStack;
useKeyboard( const depth = nav.currentDepth;
(keyEvent: any) => { const focus = (d: number = depth()) => nav.depthFocus(d);
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
const shows = feedStore.getFilteredFeeds(); const shows = () => feedStore.getFilteredFeeds();
const episodesList = episodes();
if (isSelect) { const focusedShowIdx = () =>
if (shows.length > 0 && showIndex() < shows.length) { shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
setShowIndex(showIndex() + 1); const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
}
if (episodesList.length > 0 && episodeIndex() < episodesList.length) {
setEpisodeIndex(episodeIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx // depth-1 frame ctx = the drilled feed id
if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return; 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()];
if (episodesList.length > 0) { const curLen = () => (depth() === 0 ? shows().length : episodes().length);
if (isDown && !isInverting()) {
setEpisodeIndex((i) => (i + 1) % episodesList.length);
} else if (isUp && isInverting()) {
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setEpisodeIndex((i) => (i + 1) % episodesList.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
}
}
},
{ release: false },
);
});
/** Threshold: load more when within this many items of the end */ const ensureFocus = () => {
const LOAD_MORE_THRESHOLD = 5; 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);
const shows = () => feedStore.getFilteredFeeds(); onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return shows()[i]?.id;
return episodes()[i]?.id;
});
});
const selectedShow = createMemo(() => { // ── helpers ─────────────────────────────────────────────────────────────────
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation const formatDate = (d: Date) => format(d, "MMM d, yyyy");
}); const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
const episodes = createMemo(() => { // ── drill / open ───────────────────────────────────────────────────────────
const show = selectedShow(); function open() {
if (!show) return []; if (depth() === 0) {
return [...show.episodes].sort( const show = selectedShow();
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(), 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);
}
}
const formatDate = (date: Date): string => { // ── nav.action ──────────────────────────────────────────────────────────────
return format(date, "MMM d, yyyy"); 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);
}
},
refresh: () => {
const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {});
},
};
function step(delta: number) {
nav.move(delta, curLen());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
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));
});
const formatDuration = (seconds: number): string => { // ── render ──────────────────────────────────────────────────────────────────
const mins = Math.floor(seconds / 60); const isActive = nav.activePane() === DEPTH_CENTER_PANE;
const hrs = Math.floor(mins / 60); const border = (active: boolean) => (active ? theme.accent : theme.border);
if (hrs > 0) return `${hrs}h ${mins % 60}m`; const focusBg = (i: number, lf: number, active: boolean) =>
return `${mins}m`; 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;
/** Get download status label for an episode */ return (
const downloadLabel = (episodeId: string): string => { <box flexDirection="row" flexGrow={1} width="100%" height="100%">
const status = downloadStore.getDownloadStatus(episodeId); {/* ── left: previous depth (empty at root) ──────────────────────────── */}
switch (status) { <box
case DownloadStatus.QUEUED: flexDirection="column"
return "[Q]"; flexGrow={PANE_RATIO.parent}
case DownloadStatus.DOWNLOADING: { flexShrink={1}
const pct = downloadStore.getDownloadProgress(episodeId); flexBasis={0}
return `[${pct}%]`; height="100%"
} style={{ width: depth() === 0 ? 0 : undefined }}
case DownloadStatus.COMPLETED: overflow="hidden"
return "[DL]"; >
case DownloadStatus.FAILED: <Show when={depth() >= 1}>
return "[ERR]"; <box height={1} paddingLeft={1} backgroundColor={headerBg}>
default: <text fg={theme.textSecondary}>Shows ({shows().length})</text>
return ""; </box>
} <scrollbox
}; height="100%"
border
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>
const handleRefresh = async () => { {/* ── center: current depth ─────────────────────────────────────────── */}
const show = selectedShow(); <box
if (!show) return; flexDirection="column"
setIsRefreshing(true); flexGrow={PANE_RATIO.current}
await feedStore.refreshFeed(show.id); flexShrink={1}
setIsRefreshing(false); 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={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => {
const lf = focusedShowIdx();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf, isActive)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
{showTitle(feed)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
({feed.episodes.length})
</text>
</box>
);
}}
</For>
</Show>
</Show>
const handleUnsubscribe = () => { {/* depth ≥1: episodes */}
const show = selectedShow(); <Show when={depth() >= 1}>
if (!show) return; <Show
feedStore.removeFeed(show.id); when={episodes().length > 0}
setShowIndex((i) => Math.max(0, i - 1)); fallback={
setEpisodeIndex(0); <box padding={1}>
}; <text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = focusedEpIdx();
return (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf, isActive)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf, isActive)}>
{index() === lf ? "" : " "}
</text>
<text fg={focusFg(index(), lf, isActive)}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text fg={index() === lf ? theme.surface : theme.info}>
{formatDate(ep.pubDate)}
</text>
<text fg={index() === lf ? theme.surface : muted()}>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</Show>
</scrollbox>
</box>
/** Get download status color */ {/* ── right: preview ────────────────────────────────────────────────── */}
const downloadColor = (episodeId: string): string => { <box
const status = downloadStore.getDownloadStatus(episodeId); flexDirection="column"
switch (status) { flexGrow={PANE_RATIO.preview}
case DownloadStatus.QUEUED: flexShrink={1}
return theme.warning.toString(); flexBasis={0}
case DownloadStatus.DOWNLOADING: height="100%"
return theme.primary.toString(); >
case DownloadStatus.COMPLETED: <box height={1} paddingLeft={1} backgroundColor={headerBg}>
return theme.success.toString(); <text fg={theme.textSecondary}>Preview</text>
case DownloadStatus.FAILED: </box>
return theme.error.toString(); <scrollbox
default: height="100%"
return mutedColor().toString(); border
} 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>
return ( {/* depth ≥1 preview: hovered episode */}
<box flexDirection="row" flexGrow={1} width="100%"> <Show when={depth() >= 1}>
<box flexDirection="column" height="100%"> <Show
<Show when={isRefreshing()}> when={focusedEpisode()}
<text fg={theme.warning}>Refreshing...</text> fallback={
</Show> <box padding={1}>
<Show <text fg={muted()}>No episode focused</text>
when={shows().length > 0} </box>
fallback={ }
<box padding={1}> >
<text fg={theme.muted}> {(ep) => (
No shows yet. Subscribe from Discover or Search. <box flexDirection="column" gap={1} padding={1}>
</text> <text fg={theme.textPrimary ?? theme.text}>
</box> <strong>
} {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
> {ep().title}
<scrollbox </strong>
border </text>
height="100%" <box flexDirection="row" gap={2}>
borderColor={ <text fg={theme.info}>{formatDate(ep().pubDate)}</text>
nav.activeDepth() == MyShowsPaneType.SHOWS <text fg={muted()}>{formatDuration(ep().duration)}</text>
? theme.accent <Show when={downloadLabel(ep().id)}>
: theme.border <text fg={downloadColor(ep().id)}>
} {downloadLabel(ep().id)}
focused={nav.activeDepth() == MyShowsPaneType.SHOWS} </text>
> </Show>
<For each={shows()}> </box>
{(feed, index) => ( <Show when={selectedShow()?.podcast.author}>
<box <text fg={muted()}>
flexDirection="row" by {selectedShow()!.podcast.author}
gap={1} </text>
paddingLeft={1} </Show>
paddingRight={1} <box height={1} />
backgroundColor={ <text fg={theme.textSecondary}>
index() === showIndex() ? theme.primary : undefined {ep().description?.slice(0, 400) ??
} "No description available."}
onMouseDown={() => { {(ep().description?.length ?? 0) > 400 ? "…" : ""}
setShowIndex(index()); </text>
setEpisodeIndex(0); <box height={1} />
audioNav.setSource( <text fg={muted()}>
AudioSource.MY_SHOWS, enter: play · space: select · h: back
selectedShow()?.podcast.id, </text>
); </box>
}} )}
> </Show>
<text </Show>
fg={index() === showIndex() ? theme.surface : theme.text} </scrollbox>
> </box>
{index() === showIndex() ? ">" : " "} </box>
</text> );
<text
fg={index() === showIndex() ? theme.surface : theme.text}
>
{feed.customName || feed.podcast.title}
</text>
<text fg={index() === showIndex() ? undefined : theme.text}>
({feed.episodes.length})
</text>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
<box flexDirection="column" height="100%">
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={theme.muted}>Select a show</text>
</box>
}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={theme.muted}>No episodes. Press [r] to refresh.</text>
</box>
}
>
<scrollbox
border
height="100%"
borderColor={
nav.activeDepth() == MyShowsPaneType.EPISODES
? theme.accent
: theme.border
}
focused={nav.activeDepth() == MyShowsPaneType.EPISODES}
>
<For each={episodes()}>
{(episode, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={
index() === episodeIndex() ? theme.primary : undefined
}
onMouseDown={() => setEpisodeIndex(index())}
>
<box flexDirection="row" gap={1}>
<text
fg={
index() === episodeIndex()
? theme.surface
: theme.text
}
>
{index() === episodeIndex() ? ">" : " "}
</text>
<text
fg={
index() === episodeIndex()
? theme.surface
: theme.text
}
>
{episode.episodeNumber
? `#${episode.episodeNumber} `
: ""}
{episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={index() === episodeIndex() ? undefined : theme.info}
>
{formatDate(episode.pubDate)}
</text>
<text fg={theme.muted}>
{formatDuration(episode.duration)}
</text>
<Show when={downloadLabel(episode.id)}>
<text fg={downloadColor(episode.id)}>
{downloadLabel(episode.id)}
</text>
</Show>
</box>
</box>
)}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
<Show
when={
!feedStore.isLoadingMore() &&
selectedShow() &&
feedStore.hasMoreEpisodes(selectedShow()!.id)
}
>
<box paddingLeft={2} paddingTop={1}>
<text fg={theme.muted}>Scroll down for more episodes</text>
</box>
</Show>
</scrollbox>
</Show>
</Show>
</box>
</box>
);
} }

View File

@@ -1,112 +1,122 @@
/**
* PlayerPage — single-pane audio now-playing view.
*
* Audio transport (play/pause, next/prev, seek) is handled globally by the
* Shell router (P/N/B/</>). This page renders a single rich pane showing the
* current episode, waveform, and playback controls. Panes/swipe do nothing
* (PaneCount=1).
*/
import { Show } from "solid-js";
import { PlaybackControls } from "./PlaybackControls"; import { PlaybackControls } from "./PlaybackControls";
import { RealtimeWaveform } from "./RealtimeWaveform"; import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import { useNavigation } from "@/context/NavigationContext";
import { useKeybinds } from "@/context/KeybindContext";
import { useKeyboard } from "@opentui/solid";
import { onMount } from "solid-js";
enum PlayerPaneType {
PLAYER = 1,
}
export const PlayerPaneCount = 1; export const PlayerPaneCount = 1;
export function PlayerPage() { export function PlayerPage() {
const audio = useAudio(); const audio = useAudio();
const { theme } = useTheme(); const { theme } = useTheme();
const nav = useNavigation(); const nav = useNavigation();
const muted = () => theme.muted || theme.text;
const keybind = useKeybinds(); // Single pane — always active.
const isActive = () => true;
const border = () => theme.accent;
onMount(() => { const progressPercent = () => {
useKeyboard( const d = audio.duration();
(keyEvent: any) => { if (d <= 0) return 0;
const isInverting = keybind.isInverting(keyEvent); return Math.min(100, Math.round((audio.position() / d) * 100));
};
if (keybind.match("audio-toggle", keyEvent)) { const formatTime = (seconds: number) => {
audio.togglePlayback(); const m = Math.floor(seconds / 60);
return; const s = Math.floor(seconds % 60);
} return `${m}:${String(s).padStart(2, "0")}`;
};
if (keybind.match("audio-seek-forward", keyEvent)) { return (
audio.seek(audio.currentEpisode()?.duration ?? 0); <box flexDirection="column" width="100%" height="100%">
return; {/* ── pane 0: now playing ─────────────────────────────────────────── */}
} <box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Player</text>
</box>
<scrollbox
height="100%"
focused={isActive()}
border
borderColor={border()}
backgroundColor={theme.background}
>
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={muted()}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</box>
if (keybind.match("audio-seek-backward", keyEvent)) { <Show when={audio.error()}>
audio.seek(0); {(err) => <text fg={theme.error}>{err()}</text>}
return; </Show>
}
},
{ release: false },
);
});
const progressPercent = () => { <Show
const d = audio.duration(); when={audio.currentEpisode()}
if (d <= 0) return 0; fallback={
return Math.min(100, Math.round((audio.position() / d) * 100)); <box padding={1}>
}; <text fg={muted()}>No episode loaded.</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1}>
<text fg={theme.text}>
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ??
"No description available."}
</text>
const formatTime = (seconds: number) => { <RealtimeWaveform
const m = Math.floor(seconds / 60); visualizerConfig={(() => {
const s = Math.floor(seconds % 60); const viz = useAppStore().state().settings.visualizer;
return `${m}:${String(s).padStart(2, "0")}`; return {
}; bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
)}
</Show>
return ( <PlaybackControls
<box flexDirection="column" gap={1} width="100%"> isPlaying={audio.isPlaying()}
<box flexDirection="row" justifyContent="space-between"> volume={audio.volume()}
<text fg={theme.text}> speed={audio.speed()}
<strong>Now Playing</strong> backendName={audio.backendName()}
</text> hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
<text fg={theme.muted}> onToggle={audio.togglePlayback}
{formatTime(audio.position())} / {formatTime(audio.duration())} ( onPrev={() => audio.seek(0)}
{progressPercent()}%) onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
</text> onSpeedChange={(s: number) => audio.setSpeed(s)}
</box> onVolumeChange={(v: number) => audio.setVolume(v)}
/>
{audio.error() && <text fg={theme.error}>{audio.error()}</text>} <box height={1} />
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
<box </box>
border </scrollbox>
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border} </box>
padding={1} );
flexDirection="column"
gap={1}
>
<text fg={theme.text}>
<strong>{audio.currentEpisode()?.title}</strong>
</text>
<text fg={theme.muted}>{audio.currentEpisode()?.description}</text>
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
onToggle={audio.togglePlayback}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows)
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
</box>
);
} }

View File

@@ -1,210 +1,395 @@
/** /**
* SearchPage component - Main search interface for PodTUI * SearchPage — yazi-style 3-pane view.
*
* pane 0 (parent) — query input with recent-search history (clickable)
* pane 1 (current) — search results list (navigate j/k)
* pane 2 (preview) — detail of the focused search result
*
* The Shell resets activePane to CURRENT(1) on tab enter so the user lands on
* the results pane. Swipe left (h) to pane 0 to type a query — the Shell
* router skips keys while `nav.inputFocused()` is true so the `<input>`
* element captures typing natively. Press Enter (onSubmit) to search and
* auto-swipe to the results pane.
*/ */
import { createSignal, createEffect, Show, onMount } from "solid-js"; import {
import { useKeyboard } from "@opentui/solid"; createSignal,
createMemo,
createEffect,
For,
Show,
onMount,
onCleanup,
} from "solid-js";
import { useSearchStore } from "@/stores/search"; import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults"; import { format } from "date-fns";
import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "@/types/source";
import { MyShowsPage } from "../MyShows/MyShowsPage";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext"; import {
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SearchResult } from "@/types/source";
import { PANE_RATIO } from "@/utils/navigation";
enum SearchPaneType {
INPUT = 1,
RESULTS = 2,
HISTORY = 3,
}
export const SearchPaneCount = 3; export const SearchPaneCount = 3;
export function SearchPage() { function SearchPage() {
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const [inputValue, setInputValue] = createSignal(""); const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0); const { theme } = useTheme();
const [historyIndex, setHistoryIndex] = createSignal(0); const muted = () => theme.muted || theme.text;
const { theme } = useTheme(); const nav = useNavigation();
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => { const INPUT = PaneSlot.PARENT; // 0
useKeyboard( const RESULTS = PaneSlot.CURRENT; // 1
(keyEvent: any) => { const DETAIL = PaneSlot.PREVIEW; // 2
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) { const results = () => searchStore.results();
const results = searchStore.results();
if (results.length > 0 && resultIndex() < results.length) {
setResultIndex(resultIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx // The focused result tracks pane 1's focused row.
if (nav.activeDepth() !== SearchPaneType.RESULTS) return; const focusedResult = createMemo(() => {
const list = results();
if (list.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1);
return list[idx];
});
const results = searchStore.results(); // Register a resolver so visual-mode range selection grows by result id.
if (results.length === 0) return; onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => results()[i]?.podcast.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => results()[i]?.podcast.id,
);
});
onCleanup(() => unsub());
});
if (isDown && !isInverting()) { // Keep results focus in range after searches complete.
setResultIndex((i) => (i + 1) % results.length); const ensureFocus = () => {
} else if (isUp && isInverting()) { const list = results();
setResultIndex((i) => (i - 1 + results.length) % results.length); if (list.length === 0) return;
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { const cur = nav.focusedIndex(RESULTS);
setResultIndex((i) => (i + 1) % results.length); if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) { };
setResultIndex((i) => (i - 1 + results.length) % results.length); onMount(ensureFocus);
}
},
{ release: false },
);
});
const handleSearch = async () => { // ── input pane: set inputFocused so Shell router yields keys to <input> ─────
const query = inputValue().trim(); createEffect(() => {
if (query) { const isInputPane = nav.activePane() === INPUT;
await searchStore.search(query); nav.setInputFocused(isInputPane);
if (searchStore.results().length > 0) { });
//setFocusArea("results"); //TODO: move level onMount(() => {
setResultIndex(0); onCleanup(() => nav.setInputFocused(false));
} });
}
};
const handleHistorySelect = async (query: string) => { // ── helpers ─────────────────────────────────────────────────────────────────
setInputValue(query); const formatDate = (d: Date) => format(d, "MMM d, yyyy");
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
};
const handleResultSelect = (result: SearchResult) => { const handleSubmit = () => {
//props.onSubscribe?.(result); const query = inputValue().trim();
searchStore.markSubscribed(result.podcast.id); if (!query) return;
}; searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
};
return ( const handleHistorySelect = (query: string) => {
<box flexDirection="column" height="100%" gap={1} width="100%"> setInputValue(query);
{/* Search Header */} searchStore.search(query).catch(() => {});
<box flexDirection="column" gap={1}> nav.setFocusedIndex(RESULTS, 0);
<text fg={theme.text}> nav.setActivePane(RESULTS);
<strong>Search Podcasts</strong> };
</text>
{/* Search Input */} const handleSubscribe = (result: SearchResult) => {
<box flexDirection="row" gap={1} alignItems="center"> searchStore.markSubscribed(result.podcast.id);
<text fg="gray">Search:</text> };
<input
value={inputValue()}
onInput={(value) => {
setInputValue(value);
}}
placeholder="Enter podcast name, topic, or author..."
focused={nav.activeDepth() === SearchPaneType.INPUT}
width={50}
/>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={handleSearch}
>
<text fg={theme.primary}>[Enter] Search</text>
</box>
</box>
{/* Status */} // ── nav.action handler ──────────────────────────────────────────────────────
<Show when={searchStore.isSearching()}> const PAGE_ACTIONS: Partial<
<text fg={theme.warning}>Searching...</text> Record<KeybindActionName, (pane: PaneId) => void>
</Show> > = {
<Show when={searchStore.error()}> "move-down": (p) => step(p, 1),
<text fg={theme.error}>{searchStore.error()}</text> "move-up": (p) => step(p, -1),
</Show> "jump-down": (p) => step(p, 5),
</box> "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 === RESULTS || p === DETAIL) {
const result = focusedResult();
if (result) handleSubscribe(result);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const result = focusedResult();
if (result) nav.toggleSelected(result.podcast.id);
}
},
search: () => {
nav.setActivePane(INPUT);
},
refresh: () => {
if (inputValue().trim()) {
searchStore.search(inputValue().trim()).catch(() => {});
}
},
};
{/* Main Content - Results or History */} function len(pane: PaneId): number {
<box flexDirection="row" height="100%" gap={2}> if (pane === RESULTS) return results().length;
{/* Results Panel */} return 0;
<box }
flexDirection="column" function step(pane: PaneId, delta: number) {
flexGrow={1} nav.move(delta, len(pane));
border }
borderColor={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.accent
: theme.border
}
>
<box padding={1}>
<text
fg={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.primary
: theme.muted
}
>
Results ({searchStore.results().length})
</text>
</box>
<Show
when={searchStore.results().length > 0}
fallback={
<box padding={2}>
<text fg={theme.muted}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
</box>
}
>
<SearchResults
results={searchStore.results()}
selectedIndex={resultIndex()}
focused={nav.activeDepth() === SearchPaneType.RESULTS}
onSelect={handleResultSelect}
onChange={setResultIndex}
isSearching={searchStore.isSearching()}
error={searchStore.error()}
/>
</Show>
</box>
{/* History Sidebar */} const onAction = (data: {
<box width={30} border borderColor={theme.border}> action: KeybindActionName;
<box padding={1} flexDirection="column"> pane: PaneId;
<box paddingBottom={1}> mode: NavMode;
<text }) => {
fg={ ensureFocus();
nav.activeDepth() === SearchPaneType.HISTORY const handler = PAGE_ACTIONS[data.action];
? theme.primary if (handler) handler(data.pane);
: theme.muted };
}
> onMount(() => {
History on("nav.action", onAction);
</text> onCleanup(() => off("nav.action", onAction));
</box> });
<SearchHistory
history={searchStore.history()} // ── render ──────────────────────────────────────────────────────────────────
selectedIndex={historyIndex()} const isActive = (p: PaneId) => nav.activePane() === p;
focused={nav.activeDepth() === SearchPaneType.HISTORY} const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory} const focusBg = (i: number, pane: PaneId) =>
onClear={searchStore.clearHistory} i === nav.focusedIndex(pane) && isActive(pane)
onChange={setHistoryIndex} ? theme.primary
/> : i === nav.focusedIndex(pane)
</box> ? theme.border
</box> : undefined;
</box> const focusFg = (i: number, pane: PaneId) =>
</box> i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: query input ──────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Search</text>
</box>
<scrollbox
height="100%"
focused={false}
border
borderColor={border(INPUT)}
backgroundColor={theme.background}
>
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={muted()}>Query:</text>
<input
value={inputValue()}
onInput={setInputValue}
onSubmit={() => handleSubmit()}
placeholder="Enter podcast name..."
focused={isActive(INPUT)}
width={28}
/>
</box>
<text fg={muted()}>Enter to search · h/l: panes</text>
<Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>Recent</text>
<Show
when={searchStore.history().length > 0}
fallback={<text fg={muted()}>No recent searches</text>}
>
<For each={searchStore.history().slice(0, 12)}>
{(query) => (
<box
flexDirection="row"
paddingLeft={1}
onMouseDown={() => handleHistorySelect(query)}
>
<text fg={muted()}>
{">"} {query}
</text>
</box>
)}
</For>
</Show>
</box>
</scrollbox>
</box>
{/* ── pane 1: results ──────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Results · {results().length}</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
<Show
when={results().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
</box>
}
>
<For each={results()}>
{(result, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>
{result.podcast.title}
</text>
<Show when={result.podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
[+]
</text>
</Show>
</box>
<Show when={result.podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2}
>
by {result.podcast.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 2: detail ───────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Detail</text>
</box>
<scrollbox
height="100%"
focused={isActive(DETAIL)}
border
borderColor={border(DETAIL)}
backgroundColor={theme.background}
>
<Show
when={focusedResult()}
fallback={
<box padding={1}>
<text fg={muted()}>No result focused</text>
</box>
}
>
{(result) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<Show when={result().podcast.author}>
<text fg={muted()}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textSecondary}>
{result().podcast.description!.slice(0, 400) ??
"No description available."}
{(result().podcast.description?.length ?? 0) > 400
? "…"
: ""}
</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
<text fg={muted()}>
Updated: {formatDate(result().podcast.lastUpdated)}
</text>
<Show when={result().sourceName}>
<text fg={muted()}>Source: {result().sourceName}</text>
</Show>
<box height={1} />
<Show when={!result().podcast.isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
<box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
} }
export { SearchPage };

View File

@@ -1,159 +1,94 @@
import { createSignal } from "solid-js"; /**
import { useKeyboard } from "@opentui/solid"; * PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
import { useAppStore } from "@/stores/app"; * SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
import { useTheme } from "@/context/ThemeContext"; * driven by the Shell router via nav.action.
import type { ThemeName } from "@/types/settings"; */
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto"; import { useAppStore } from "@/stores/app";
import type { ThemeName } from "@/types/settings";
import type { SettingItem } from "./types";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "system", label: "System" }, { value: "system", label: "System" },
{ value: "catppuccin", label: "Catppuccin" }, { value: "catppuccin", label: "Catppuccin" },
{ value: "gruvbox", label: "Gruvbox" }, { value: "gruvbox", label: "Gruvbox" },
{ value: "tokyo", label: "Tokyo" }, { value: "tokyo", label: "Tokyo" },
{ value: "nord", label: "Nord" }, { value: "nord", label: "Nord" },
{ value: "custom", label: "Custom" }, { value: "custom", label: "Custom" },
]; ];
export function PreferencesPanel() { export function usePreferencesItems(): SettingItem[] {
const appStore = useAppStore(); const app = useAppStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("theme");
const settings = () => appStore.state().settings; const settings = () => app.state().settings;
const preferences = () => appStore.state().preferences; const prefs = () => app.state().preferences;
const handleKey = (key: { name: string; shift?: boolean }) => { return [
if (key.name === "tab") { {
const fields: FocusField[] = [ id: "theme",
"theme", label: "Theme",
"font", kind: "select",
"speed", display: () =>
"explicit", THEME_LABELS.find((t) => t.value === settings().theme)?.label ??
"auto", settings().theme,
]; help: () =>
const idx = fields.indexOf(focusField()); `Color theme.\nType: select\nDefault: system\nCurrent: ${settings().theme}\nCycle with j/k; Enter to apply.`,
const next = key.shift cycle: (dir) => {
? (idx - 1 + fields.length) % fields.length const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
: (idx + 1) % fields.length; const next = (idx + dir + THEME_LABELS.length) % THEME_LABELS.length;
setFocusField(fields[next]); app.setTheme(THEME_LABELS[next].value);
return; },
} },
{
if (key.name === "left" || key.name === "h") { id: "fontSize",
stepValue(-1); label: "Font Size",
} kind: "number",
if (key.name === "right" || key.name === "l") { display: () => `${settings().fontSize}px`,
stepValue(1); help: () =>
} `Terminal font size in pixels.\nType: number (1020)\nDefault: 14\nCurrent: ${settings().fontSize}\nj/k to /+1px.`,
if (key.name === "space" || key.name === "return") { cycle: (dir) => {
toggleValue(); const next = Math.min(20, Math.max(10, settings().fontSize + dir));
} app.updateSettings({ fontSize: next });
}; },
},
const stepValue = (delta: number) => { {
const field = focusField(); id: "playbackSpeed",
if (field === "theme") { label: "Playback Speed",
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme); kind: "number",
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length; display: () => `${settings().playbackSpeed}x`,
appStore.setTheme(THEME_LABELS[next].value); help: () =>
return; `Default audio playback speed.\nType: number (0.52.0)\nDefault: 1.0\nCurrent: ${settings().playbackSpeed}\nj/k to /+0.1.`,
} cycle: (dir) => {
if (field === "font") { const next = Math.min(
const next = Math.min(20, Math.max(10, settings().fontSize + delta)); 2,
appStore.updateSettings({ fontSize: next }); Math.max(0.5, settings().playbackSpeed + dir * 0.1),
return; );
} app.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
if (field === "speed") { },
const next = Math.min( },
2, {
Math.max(0.5, settings().playbackSpeed + delta * 0.1), id: "showExplicit",
); label: "Show Explicit",
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) }); kind: "toggle",
} display: () => (prefs().showExplicit ? "On" : "Off"),
}; help: () =>
`Whether to list explicit episodes.\nType: toggle\nDefault: true\nCurrent: ${prefs().showExplicit}\nSpace/Enter to toggle.`,
const toggleValue = () => { toggle: () =>
const field = focusField(); app.updatePreferences({
if (field === "explicit") { showExplicit: !prefs().showExplicit,
appStore.updatePreferences({ showExplicit: !preferences().showExplicit }); }),
} },
if (field === "auto") { {
appStore.updatePreferences({ autoDownload: !preferences().autoDownload }); id: "autoDownload",
} label: "Auto Download",
}; kind: "toggle",
display: () => (prefs().autoDownload ? "On" : "Off"),
useKeyboard(handleKey); help: () =>
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
return ( toggle: () =>
<box flexDirection="column" gap={1}> app.updatePreferences({
<text fg={theme.textMuted}>Preferences</text> autoDownload: !prefs().autoDownload,
}),
<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>
);
} }

View File

@@ -1,119 +1,519 @@
import { createSignal, For, onMount } from "solid-js"; /**
import { useKeyboard } from "@opentui/solid"; * SettingsPage — yazi depth-stack settings.
import { SourceManager } from "./SourceManager"; *
* 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
*
* 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, createMemo } from "solid-js";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { PreferencesPanel } from "./PreferencesPanel"; import {
import { SyncPanel } from "./SyncPanel"; useNavigation,
import { VisualizerSettings } from "./VisualizerSettings"; NavMode,
import { useNavigation } from "@/context/NavigationContext"; DEPTH_CENTER_PANE,
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; 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";
enum SettingsPaneType { export const SettingsPaneCount = 1;
SYNC = 1,
SOURCES = 2,
PREFERENCES = 3,
VISUALIZER = 4,
ACCOUNT = 5,
}
export const SettingsPaneCount = 5;
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [ const SECTIONS: SettingsSectionDef[] = [
{ id: SettingsPaneType.SYNC, label: "Sync" }, {
{ id: SettingsPaneType.SOURCES, label: "Sources" }, id: 0,
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" }, label: "Sync",
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" }, description: "Import/export subscriptions and sync status.",
{ id: SettingsPaneType.ACCOUNT, label: "Account" }, },
{
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).",
},
]; ];
export function SettingsPage() { /** Resolve the items for a section id at render time. Section 4 (Account) has
const { theme } = useTheme(); * no items yet. */
const nav = useNavigation(); function sectionItems(sectionId: number): SettingItem[] {
const keybind = useKeybinds(); switch (sectionId) {
case 0:
// Helper function to check if a depth is active return useSyncItems();
const isActive = (depth: SettingsPaneType): boolean => { case 1:
return nav.activeDepth() === depth; return useSourceItems();
}; case 2:
return usePreferencesItems();
// Helper function to get the current depth as a number case 3:
const currentDepth = () => nav.activeDepth() as number; return useVisualizerItems();
default:
onMount(() => { return [];
useKeyboard( }
(keyEvent: any) => { }
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent); export function SettingsPage() {
const isCycle = keybind.match("cycle", keyEvent); const { theme } = useTheme();
const isSelect = keybind.match("select", keyEvent); const nav = useNavigation();
const isInverting = keybind.isInverting(keyEvent);
const stack = nav.depthStack;
// don't handle pane navigation here - unified in App.tsx const depth = nav.currentDepth;
if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return;
// ── depth 0: sections ────────────────────────────────────────────────────
if (isDown && !isInverting()) { const focusedSectionIdx = () =>
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1); Math.min(nav.depthFocus(0), SECTIONS.length - 1);
} else if (isUp && isInverting()) { const focusedSection = () => SECTIONS[focusedSectionIdx()] ?? SECTIONS[0];
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { // ── depth ≥1: section items (resolved from the section id stored in the
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1); // depth-0 frame's ctx). The depth-1 frame kind is "settings:<id>". ────
} else if ((isCycle && isInverting()) || (isUp && isInverting())) { const sectionForDepth1 = (): SettingsSectionDef | undefined => {
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1); const f = stack()[1];
} if (!f) return undefined;
}, const id = Number(f.ctx ?? "0");
{ release: false }, return SECTIONS[id];
); };
}); const items = createMemo<SettingItem[]>(() => {
const sec = sectionForDepth1();
return ( if (!sec) return [];
<box flexDirection="column" gap={1} height="100%" width="100%"> return sectionItems(sec.id);
<box flexDirection="row" gap={1}> });
<For each={SECTIONS}> const focusedItemIdx = () =>
{(section, index) => ( items().length === 0 ? 0 : Math.min(nav.depthFocus(1), items().length - 1);
<box const focusedItem = (): SettingItem | undefined => items()[focusedItemIdx()];
border
borderColor={theme.border} // ── depth 2: the editor item (resolved from depth-1 frame ctx + item id) ─
padding={0} const editorItem = (): SettingItem | undefined => {
backgroundColor={ const f1 = stack()[1];
currentDepth() === section.id ? theme.primary : undefined const f2 = stack()[2];
} if (!f1 || !f2) return undefined;
onMouseDown={() => nav.setActiveDepth(section.id)} const secId = Number(f1.ctx ?? "0");
> const list = sectionItems(secId);
<text return list.find((it) => it.id === f2.ctx);
fg={ };
currentDepth() === section.id ? theme.text : theme.textMuted
} // ── drill / open dispatch ───────────────────────────────────────────────
> function open() {
[{index() + 1}] {section.label} const d = depth();
</text> if (d === 0) {
</box> // drill into the focused section's items
)} const id = focusedSection().id;
</For> nav.pushDepth({
</box> kind: `settings:${id}`,
ctx: String(id),
<box focus: 0,
border });
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border} nav.setActivePane(DEPTH_CENTER_PANE);
flexGrow={1} return;
padding={1} }
flexDirection="column" if (d === 1) {
gap={1} const it = focusedItem();
> if (!it) return;
{isActive(SettingsPaneType.SYNC) && <SyncPanel />} switch (it.kind) {
{isActive(SettingsPaneType.SOURCES) && ( case "toggle":
<SourceManager focused /> it.toggle?.();
)} return;
{isActive(SettingsPaneType.PREFERENCES) && ( case "action":
<PreferencesPanel /> it.run?.();
)} return;
{isActive(SettingsPaneType.VISUALIZER) && ( case "info":
<VisualizerSettings /> return;
)} case "editor":
{isActive(SettingsPaneType.ACCOUNT) && ( case "number":
<box flexDirection="column" gap={1}> case "select":
<text fg={theme.textMuted}>Account</text> nav.pushDepth({
</box> kind: `settings:item:${it.id}`,
)} ctx: it.id,
</box> focus: 0,
</box> });
); 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: {
action: KeybindActionName;
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();
};
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.";
});
// ── 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%"
border
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={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.focused ? "" : " "}</text>
<text fg={fg()}>{props.label}</text>
<Show when={props.value}>
<box flexGrow={1} />
<text fg={props.focused ? fg() : theme.textMuted}>{props.value}</text>
</Show>
<Show when={props.hint}>
<text fg={theme.textMuted}>{props.hint}</text>
</Show>
</box>
);
}
/** Center editor for number/select/toggle items without a bespoke renderer. */
function GenericEditor(props: { item: SettingItem }) {
const { theme } = useTheme();
const it = props.item;
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>{it.label}</strong>
</text>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textMuted}>Value:</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{it.display()}</text>
</box>
</box>
<Show when={it.kind === "number" || it.kind === "select"}>
<text fg={theme.muted ?? theme.textMuted}>
j/k to adjust · Enter to nudge forward · h to go back
</text>
</Show>
<Show when={it.kind === "toggle"}>
<text fg={theme.muted ?? theme.textMuted}>
Enter/Space to toggle · h to go back
</text>
</Show>
</box>
);
}
/** Renders a string with `\n` newlines as stacked <text> lines. */
function MultiLine(props: { text: string }) {
const lines = () => props.text.split("\n");
const { theme } = useTheme();
return (
<For each={lines()}>
{(line, i) => (
<text fg={i() === 0 ? theme.accent : theme.textMuted}>
{line || " "}
</text>
)}
</For>
);
} }

View File

@@ -1,317 +1,141 @@
/** /**
* Source management component for PodTUI * SourceManager — exposes podcast sources as SettingItems for the depth-stack.
* Add, remove, and configure podcast sources *
* • "Add Source" — an editor item; drilling in shows a name/URL add form.
* • Each source — a toggle item (Space toggles enabled) whose display shows
* the source type and on/off state.
*
* Advanced per-API-source options (country/language/explicit) are flattened to
* simple toggles/cycles reachable by drilling into the source's editor.
* Movement flows through nav.action — no own useKeyboard (avoids the old
* right-pane key conflicts).
*/ */
import { createSignal, For } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { SourceType } from "@/types/source"; import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source"; import type { PodcastSource } from "@/types/source";
import { SelectableBox, SelectableText } from "@/components/Selectable"; import type { SettingItem } from "./types";
interface SourceManagerProps { export function useSourceItems(): SettingItem[] {
focused?: boolean; const feedStore = useFeedStore();
onClose?: () => void;
const typeBadge = (s: PodcastSource) =>
s.type === SourceType.API
? "[API]"
: s.type === SourceType.RSS
? "[RSS]"
: "[?]";
const items: SettingItem[] = [
{
id: "add",
label: "Add Source",
kind: "editor",
display: () => "+",
help: () =>
`Add a custom RSS feed by URL.\nDrill in (Enter/l) to open the add-source form.\nType: editor`,
renderEditor: () => <AddSourceForm />,
},
];
for (const s of feedStore.sources()) {
items.push({
id: `src:${s.id}`,
label: s.name,
kind: "toggle",
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
help: () =>
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
toggle: () => feedStore.toggleSource(s.id),
});
}
return items;
} }
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"; function AddSourceForm() {
const feedStore = useFeedStore();
const { theme } = useTheme();
const [name, setName] = createSignal("");
const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
export function SourceManager(props: SourceManagerProps) { const submit = () => {
const feedStore = useFeedStore(); const u = url().trim();
const { theme } = useTheme(); if (!u) {
const [selectedIndex, setSelectedIndex] = createSignal(0); setError("URL is required");
const [focusArea, setFocusArea] = createSignal<FocusArea>("list"); return;
const [newSourceUrl, setNewSourceUrl] = createSignal(""); }
const [newSourceName, setNewSourceName] = createSignal(""); try {
const [error, setError] = createSignal<string | null>(null); new URL(u);
} catch {
setError("Invalid URL format");
return;
}
feedStore.addSource({
name: name().trim() || "Custom Source",
type: SourceType.RSS,
baseUrl: u,
enabled: true,
description: `Custom RSS feed: ${u}`,
});
setName("");
setUrl("");
setError(null);
};
const sources = () => feedStore.sources(); return (
<box flexDirection="column" padding={1} gap={1}>
const handleKeyPress = (key: { name: string; shift?: boolean }) => { <text fg={theme.text}>
if (key.name === "escape") { <strong>Add Source</strong>
if (focusArea() !== "list") { </text>
setFocusArea("list"); <box flexDirection="row" gap={1}>
setError(null); <text fg={theme.textMuted}>Name:</text>
} else if (props.onClose) { <input
props.onClose(); value={name()}
} onInput={setName}
return; placeholder="My Custom Feed"
} width={25}
/>
if (key.name === "tab") { </box>
const areas: FocusArea[] = [ <box flexDirection="row" gap={1}>
"list", <text fg={theme.textMuted}>URL:</text>
"country", <input
"language", value={url()}
"explicit", onInput={(v) => {
"add", setUrl(v);
"url", setError(null);
]; }}
const idx = areas.indexOf(focusArea()); placeholder="https://example.com/feed.rss"
const nextIdx = key.shift width={35}
? (idx - 1 + areas.length) % areas.length />
: (idx + 1) % areas.length; </box>
setFocusArea(areas[nextIdx]); <box
return; border
} borderColor={theme.border}
padding={0}
if (focusArea() === "list") { width={15}
if (key.name === "up" || key.name === "k") { onMouseDown={submit}
setSelectedIndex((i) => Math.max(0, i - 1)); >
} else if (key.name === "down" || key.name === "j") { <text fg={theme.primary}>[+] Add</text>
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1)); </box>
} else if ( <Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
key.name === "return" || <Show when={feedStore.sources().length > 0}>
key.name === "space" <box flexDirection="column" marginTop={1}>
) { <text fg={theme.textMuted}>
const source = sources()[selectedIndex()]; Current sources ({feedStore.sources().length}):
if (source) { </text>
feedStore.toggleSource(source.id); <For each={feedStore.sources()}>
} {(s) => (
} else if (key.name === "d" || key.name === "delete") { <text fg={theme.textMuted}>
const source = sources()[selectedIndex()]; {s.enabled ? "●" : "○"} {s.name}
if (source) { </text>
const removed = feedStore.removeSource(source.id); )}
if (!removed) { </For>
setError("Cannot remove default sources"); </box>
} </Show>
} </box>
} else if (key.name === "a") { );
setFocusArea("add");
}
}
if (focusArea() === "country") {
if (
key.name === "enter" ||
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) {
const next = source.country === "US" ? "GB" : "US";
feedStore.updateSource(source.id, { country: next });
}
}
}
if (focusArea() === "explicit") {
if (
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) {
feedStore.updateSource(source.id, {
allowExplicit: !source.allowExplicit,
});
}
}
}
if (focusArea() === "language") {
if (
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) {
const next = source.language === "ja_jp" ? "en_us" : "ja_jp";
feedStore.updateSource(source.id, { language: next });
}
}
}
};
const handleAddSource = () => {
const url = newSourceUrl().trim();
const name = newSourceName().trim() || `Custom Source`;
if (!url) {
setError("URL is required");
return;
}
try {
new URL(url);
} catch {
setError("Invalid URL format");
return;
}
feedStore.addSource({
name,
type: "rss" as SourceType,
baseUrl: url,
enabled: true,
description: `Custom RSS feed: ${url}`,
});
setNewSourceUrl("");
setNewSourceName("");
setFocusArea("list");
setError(null);
};
const getSourceIcon = (source: PodcastSource) => {
if (source.type === SourceType.API) return "[API]";
if (source.type === SourceType.RSS) return "[RSS]";
return "[?]";
};
const selectedSource = () => sources()[selectedIndex()];
const isApiSource = () => selectedSource()?.type === SourceType.API;
const sourceCountry = () => selectedSource()?.country || "US";
const sourceExplicit = () => selectedSource()?.allowExplicit !== false;
const sourceLanguage = () => selectedSource()?.language || "en_us";
return (
<box flexDirection="column" border borderColor={theme.border} padding={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Podcast Sources</strong>
</text>
<box border borderColor={theme.border} padding={0} onMouseDown={props.onClose}>
<text fg={theme.primary}>[Esc] Close</text>
</box>
</box>
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
{/* Source list */}
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
Sources:
</text>
<scrollbox height={6}>
<For each={sources()}>
{(source, index) => (
<SelectableBox
selected={() => focusArea() === "list" && index() === selectedIndex()}
flexDirection="row"
gap={1}
padding={0}
onMouseDown={() => {
setSelectedIndex(index());
setFocusArea("list");
feedStore.toggleSource(source.id);
}}
>
<SelectableText
selected={() => focusArea() === "list" && index() === selectedIndex()}
primary
>
{focusArea() === "list" && index() === selectedIndex()
? ">"
: " "}
</SelectableText>
<SelectableText
selected={() => focusArea() === "list" && index() === selectedIndex()}
primary
>
{source.name}
</SelectableText>
</SelectableBox>
)}
</For>
</scrollbox>
<text fg={theme.textMuted}>
Space/Enter to toggle, d to delete, a to add
</text>
{/* API settings */}
<box flexDirection="column" gap={1}>
<SelectableText selected={() => false} primary={isApiSource()}>
{isApiSource()
? "API Settings"
: "API Settings (select an API source)"}
</SelectableText>
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
focusArea() === "country" ? theme.primary : undefined
}
>
<SelectableText selected={() => false} primary={focusArea() === "country"}>
Country: {sourceCountry()}
</SelectableText>
</box>
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
focusArea() === "language" ? theme.primary : undefined
}
>
<SelectableText selected={() => false} primary={focusArea() === "language"}>
Language:{" "}
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
</SelectableText>
</box>
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
focusArea() === "explicit" ? theme.primary : undefined
}
>
<SelectableText selected={() => false} primary={focusArea() === "explicit"}>
Explicit: {sourceExplicit() ? "Yes" : "No"}
</SelectableText>
</box>
</box>
<SelectableText selected={() => false} tertiary>
Enter/Space to toggle focused setting
</SelectableText>
</box>
</box>
{/* Add new source form */}
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
<SelectableText selected={() => false} primary={focusArea() === "add" || focusArea() === "url"}>
Add New Source:
</SelectableText>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Name:</SelectableText>
<input
value={newSourceName()}
onInput={setNewSourceName}
placeholder="My Custom Feed"
focused={props.focused && focusArea() === "add"}
width={25}
/>
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>URL:</SelectableText>
<input
value={newSourceUrl()}
onInput={(v) => {
setNewSourceUrl(v);
setError(null);
}}
placeholder="https://example.com/feed.rss"
focused={props.focused && focusArea() === "url"}
width={35}
/>
</box>
<box border borderColor={theme.border} padding={0} width={15} onMouseDown={handleAddSource}>
<SelectableText selected={() => false} primary>[+] Add Source</SelectableText>
</box>
</box>
{/* Error message */}
{error() && <SelectableText selected={() => false} tertiary>{error()}</SelectableText>}
<SelectableText selected={() => false} tertiary>Tab to switch sections, Esc to close</SelectableText>
</box>
);
} }

View File

@@ -1,32 +1,57 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => { /**
let current = value * SyncPanel — exposes Import / Export / status as SettingItems. The Import and
return [() => current, (next) => { * Export dialogs render as depth-2 editors. No own useKeyboard.
current = next */
}]
import { createSignal } from "solid-js";
import { ImportDialog } from "./ImportDialog";
import { ExportDialog } from "./ExportDialog";
import { SyncStatus } from "./SyncStatus";
import type { SettingItem } from "./types";
// Module-level state so the action items can open their dialogs as depth-2
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show.
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>(
null,
);
export { syncEditor };
export function closeSyncEditor() {
setSyncEditor(null);
} }
import { ImportDialog } from "./ImportDialog" export function useSyncItems(): SettingItem[] {
import { ExportDialog } from "./ExportDialog" return [
import { SyncStatus } from "./SyncStatus" {
import { useTheme } from "@/context/ThemeContext" id: "import",
label: "Import",
export function SyncPanel() { kind: "editor",
const { theme } = useTheme(); display: () => "→",
const mode = createSignal<"import" | "export" | null>(null) help: () =>
`Import subscriptions from a sync file (JSON or OPML).\nDrill in (Enter/l) to open the import dialog.\nType: editor`,
return ( renderEditor: () => <ImportDialog />,
<box style={{ flexDirection: "column", gap: 1 }}> },
<box style={{ flexDirection: "row", gap: 1 }}> {
<box border borderColor={theme.border} onMouseDown={() => mode[1]("import")}> id: "export",
<text fg={theme.text}>Import</text> label: "Export",
</box> kind: "editor",
<box border borderColor={theme.border} onMouseDown={() => mode[1]("export")}> display: () => "→",
<text fg={theme.text}>Export</text> help: () =>
</box> `Export subscriptions to a sync file.\nDrill in (Enter/l) to open the export dialog.\nType: editor`,
</box> renderEditor: () => <ExportDialog />,
<SyncStatus /> },
{mode[0]() === "import" ? <ImportDialog /> : null} {
{mode[0]() === "export" ? <ExportDialog /> : null} id: "status",
</box> label: "Status",
) kind: "info",
display: () => "Idle",
help: () =>
`Last sync status. (Sync is run from the import/export dialogs.)\nType: info`,
},
];
}
/** Renders the live sync status block (used by the Settings page header for the
* Sync section, when relevant). */
export function SyncStatusBlock() {
return <SyncStatus />;
} }

View File

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

View File

@@ -0,0 +1,45 @@
/**
* Settings item model — each settings section exposes a list of items that the
* SettingsPage renders through the yazi depth-stack (sections → items → editor).
*
* All movement flows through the Shell's nav.action router (j/k move, Enter/l
* drill, h back), so panels no longer register their own useKeyboard — that was
* the root cause of the "right pane ignores keys / double-handled input" bugs.
*/
import type { JSX } from "solid-js";
export type SettingItemKind =
| "toggle"
| "number"
| "select"
| "action"
| "editor"
| "info";
export interface SettingItem {
/** Stable id within its section. */
id: string;
/** One-line label shown in the items list. */
label: string;
/** Category — decides how the item is interacted with. */
kind: SettingItemKind;
/** Current value as a short string (shown to the right of the label). */
display: () => string;
/** Help text for the preview pane: description, type, default, current. */
help: () => string;
/** For number/select: nudge the value by -1 or +1 (j/k at depth 2). */
cycle?: (dir: -1 | 1) => void;
/** For toggle: flip the value (Space/Enter at depth 1). */
toggle?: () => void;
/** For action: run immediately (Enter at depth 1). */
run?: () => void;
/** For editor: a bespoke depth-2 editor component. */
renderEditor?: () => JSX.Element;
}
export interface SettingsSectionDef {
id: number;
label: string;
description: string;
items?: () => SettingItem[];
}

View File

@@ -3,185 +3,201 @@
* Manages search state, history, and results * Manages search state, history, and results
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import { searchPodcasts } from "../utils/search" import { searchPodcasts } from "../utils/search";
import { useFeedStore } from "./feed" import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source" import type { SearchResult } from "../types/source";
const STORAGE_KEY = "podtui_search_history" const STORAGE_KEY = "podtui_search_history";
const MAX_HISTORY = 20 const MAX_HISTORY = 20;
export interface SearchState { export interface SearchState {
query: string query: string;
isSearching: boolean isSearching: boolean;
results: SearchResult[] results: SearchResult[];
error: string | null error: string | null;
} }
const CACHE_TTL = 1000 * 60 * 5 const CACHE_TTL = 1000 * 60 * 5;
/** Load search history from localStorage */ /** Load search history from localStorage */
function loadHistory(): string[] { function loadHistory(): string[] {
if (typeof localStorage === "undefined") return [] if (typeof localStorage === "undefined") return [];
try { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [] return stored ? JSON.parse(stored) : [];
} catch { } catch {
return [] return [];
} }
} }
/** Save search history to localStorage */ /** Save search history to localStorage */
function saveHistory(history: string[]): void { function saveHistory(history: string[]): void {
if (typeof localStorage === "undefined") return if (typeof localStorage === "undefined") return;
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history)) localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch { } catch {
// Ignore errors // Ignore errors
} }
} }
/** Create search store */ /** Create search store */
export function createSearchStore() { export function createSearchStore() {
const feedStore = useFeedStore() const feedStore = useFeedStore();
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("");
const [isSearching, setIsSearching] = createSignal(false) const [isSearching, setIsSearching] = createSignal(false);
const [results, setResults] = createSignal<SearchResult[]>([]) const [results, setResults] = createSignal<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory()) const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]) const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => { const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds() const feeds = feedStore.feeds();
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl)) const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl));
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id)) const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id));
return items.map((item) => ({ return items.map((item) => ({
...item, ...item,
podcast: { podcast: {
...item.podcast, ...item.podcast,
isSubscribed: isSubscribed:
item.podcast.isSubscribed || item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) || subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id), subscribedIds.has(item.podcast.id),
}, },
})) }));
} };
/** Perform search (multi-source implementation) */ /** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => { const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim() const q = searchQuery.trim();
if (!q) { if (!q) {
setResults([]) setResults([]);
return return;
} }
setQuery(q) setQuery(q);
setIsSearching(true) setIsSearching(true);
setError(null) setError(null);
// Add to history // Add to history
addToHistory(q) addToHistory(q);
try { try {
const sources = feedStore.sources() const sources = feedStore.sources();
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id) const enabledSourceIds = sources
const sourceIds = selectedSources().length > 0 .filter((s) => s.enabled)
? selectedSources() .map((s) => s.id);
: enabledSourceIds const sourceIds =
selectedSources().length > 0 ? selectedSources() : enabledSourceIds;
const searchResults = await searchPodcasts(q, sourceIds, sources, { // Empty query guard already returned above; if there are no enabled
cacheTtl: CACHE_TTL, // sources, tell the user instead of returning an empty list that looks
}) // like a network outage.
if (enabledSourceIds.length === 0) {
setError(
"No search sources are enabled. Enable one in Settings → Sources.",
);
setResults([]);
return;
}
setResults(applySubscribedStatus(searchResults)) const searchResults = await searchPodcasts(q, sourceIds, sources, {
} catch (e) { cacheTtl: CACHE_TTL,
setError("Search failed. Please try again.") });
setResults([])
} finally {
setIsSearching(false)
}
}
/** Add query to history */ setResults(applySubscribedStatus(searchResults));
const addToHistory = (q: string) => { } catch (e) {
setHistory((prev) => { setError(
// Remove duplicates and add to front e instanceof Error && e.message
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase()) ? e.message
const updated = [q, ...filtered].slice(0, MAX_HISTORY) : "Search failed. Please try again.",
saveHistory(updated) );
return updated setResults([]);
}) } finally {
} setIsSearching(false);
}
};
/** Clear search history */ /** Add query to history */
const clearHistory = () => { const addToHistory = (q: string) => {
setHistory([]) setHistory((prev) => {
saveHistory([]) // Remove duplicates and add to front
} const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
};
/** Remove single history item */ /** Clear search history */
const removeFromHistory = (q: string) => { const clearHistory = () => {
setHistory((prev) => { setHistory([]);
const updated = prev.filter((h) => h !== q) saveHistory([]);
saveHistory(updated) };
return updated
})
}
/** Clear results */ /** Remove single history item */
const clearResults = () => { const removeFromHistory = (q: string) => {
setResults([]) setHistory((prev) => {
setQuery("") const updated = prev.filter((h) => h !== q);
setError(null) saveHistory(updated);
} return updated;
});
};
/** Mark a podcast as subscribed in results */ /** Clear results */
const markSubscribed = (podcastId: string, feedUrl?: string) => { const clearResults = () => {
setResults((prev) => setResults([]);
prev.map((result) => { setQuery("");
const matchesId = result.podcast.id === podcastId setError(null);
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false };
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
}
}
return result
})
)
}
return { /** Mark a podcast as subscribed in results */
// State const markSubscribed = (podcastId: string, feedUrl?: string) => {
query, setResults((prev) =>
isSearching, prev.map((result) => {
results, const matchesId = result.podcast.id === podcastId;
error, const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
history, if (matchesId || matchesUrl) {
selectedSources, return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
};
}
return result;
}),
);
};
// Actions return {
search, // State
setQuery, query,
clearResults, isSearching,
clearHistory, results,
removeFromHistory, error,
setSelectedSources, history,
markSubscribed, selectedSources,
}
// Actions
search,
setQuery,
clearResults,
clearHistory,
removeFromHistory,
setSelectedSources,
markSubscribed,
};
} }
/** Singleton search store */ /** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() { export function useSearchStore() {
if (!searchStoreInstance) { if (!searchStoreInstance) {
searchStoreInstance = createSearchStore() searchStoreInstance = createSearchStore();
} }
return searchStoreInstance return searchStoreInstance;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -20,128 +20,148 @@
* ``` * ```
*/ */
type EventHandler<T = unknown> = (data: T) => void type EventHandler<T = unknown> = (data: T) => void;
// Export EventHandler type for external use // Export EventHandler type for external use
export type { EventHandler } export type { EventHandler };
interface EventBusInstance { interface EventBusInstance {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
once<T = unknown>(event: string, handler: EventHandler<T>): () => void once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
off<T = unknown>(event: string, handler: EventHandler<T>): void off<T = unknown>(event: string, handler: EventHandler<T>): void;
emit<T = unknown>(event: string, data: T): void emit<T = unknown>(event: string, data: T): void;
clear(): void clear(): void;
} }
function createEventBus(): EventBusInstance { function createEventBus(): EventBusInstance {
const handlers = new Map<string, Set<EventHandler>>() const handlers = new Map<string, Set<EventHandler>>();
return { return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void { on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) { if (!handlers.has(event)) {
handlers.set(event, new Set()) handlers.set(event, new Set());
} }
handlers.get(event)!.add(handler as EventHandler) handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function // Return unsubscribe function
return () => { return () => {
this.off(event, handler) this.off(event, handler);
} };
}, },
once<T = unknown>(event: string, handler: EventHandler<T>): () => void { once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => { const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler) this.off(event, wrappedHandler);
handler(data) handler(data);
} };
return this.on(event, wrappedHandler) return this.on(event, wrappedHandler);
}, },
off<T = unknown>(event: string, handler: EventHandler<T>): void { off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event) const eventHandlers = handlers.get(event);
if (eventHandlers) { if (eventHandlers) {
eventHandlers.delete(handler as EventHandler) eventHandlers.delete(handler as EventHandler);
if (eventHandlers.size === 0) { if (eventHandlers.size === 0) {
handlers.delete(event) handlers.delete(event);
} }
} }
}, },
emit<T = unknown>(event: string, data: T): void { emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event) const eventHandlers = handlers.get(event);
if (eventHandlers) { if (eventHandlers) {
for (const handler of eventHandlers) { for (const handler of eventHandlers) {
try { try {
handler(data) handler(data);
} catch (error) { } catch (error) {
console.error(`Error in event handler for "${event}":`, error) console.error(`Error in event handler for "${event}":`, error);
} }
} }
} }
}, },
clear(): void { clear(): void {
handlers.clear() handlers.clear();
}, },
} };
} }
// Singleton event bus instance // Singleton event bus instance
export const EventBus = createEventBus() export const EventBus = createEventBus();
import type { KeybindActionName } from "@/context/KeybindContext";
import type { TABS } from "@/utils/navigation";
import type { PaneId, NavMode } from "@/context/NavigationContext";
// Common event types for the application // Common event types for the application
export type AppEvents = { export type AppEvents = {
"theme.changed": { theme: string; mode: "dark" | "light" } "theme.changed": { theme: string; mode: "dark" | "light" };
"theme.mode.changed": { mode: "dark" | "light" } "theme.mode.changed": { mode: "dark" | "light" };
"theme.reload": {} "theme.reload": {};
"navigation.tab.changed": { tab: string; previousTab?: string } "navigation.tab.changed": { tab: string; previousTab?: string };
"navigation.layer.changed": { depth: number; previousDepth: number } "navigation.layer.changed": { depth: number; previousDepth: number };
"feed.subscribed": { feedId: string; feedUrl: string } "feed.subscribed": { feedId: string; feedUrl: string };
"feed.unsubscribed": { feedId: string } "feed.unsubscribed": { feedId: string };
"player.play": { episodeId: string } "player.play": { episodeId: string };
"player.pause": { episodeId: string } "player.pause": { episodeId: string };
"player.stop": {} "player.stop": {};
"auth.login": { userId: string } "auth.login": { userId: string };
"auth.logout": {} "auth.logout": {};
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number } "toast.show": {
"dialog.open": { dialogId: string } message: string;
"dialog.close": { dialogId?: string } variant: "info" | "success" | "warning" | "error";
"command.execute": { command: string; args?: unknown } title?: string;
"clipboard.copied": { text: string } duration?: number;
"selection.start": { x: number; y: number } };
"selection.end": { text: string } "dialog.open": { dialogId: string };
"dialog.close": { dialogId?: string };
"command.execute": { command: string; args?: unknown };
// Yazi-style unified router → active page dispatch. The Shell router
// emits these; each page subscribes to the subset it implements.
"nav.action": {
action: KeybindActionName;
tab: TABS;
pane: PaneId;
mode: NavMode;
};
"clipboard.copied": { text: string };
"selection.start": { x: number; y: number };
"selection.end": { text: string };
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio) // Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
"media.toggle": {} "media.toggle": {};
"media.volumeUp": {} "media.volumeUp": {};
"media.volumeDown": {} "media.volumeDown": {};
"media.seekForward": {} "media.seekForward": {};
"media.seekBackward": {} "media.seekBackward": {};
"media.speedCycle": {} "media.speedCycle": {};
} };
// Type-safe emit and on functions // Type-safe emit and on functions
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void { export function emit<K extends keyof AppEvents>(
EventBus.emit(event, data) event: K,
data: AppEvents[K],
): void {
EventBus.emit(event, data);
} }
export function on<K extends keyof AppEvents>( export function on<K extends keyof AppEvents>(
event: K, event: K,
handler: EventHandler<AppEvents[K]> handler: EventHandler<AppEvents[K]>,
): () => void { ): () => void {
return EventBus.on(event, handler) return EventBus.on(event, handler);
} }
export function once<K extends keyof AppEvents>( export function once<K extends keyof AppEvents>(
event: K, event: K,
handler: EventHandler<AppEvents[K]> handler: EventHandler<AppEvents[K]>,
): () => void { ): () => void {
return EventBus.once(event, handler) return EventBus.once(event, handler);
} }
export function off<K extends keyof AppEvents>( export function off<K extends keyof AppEvents>(
event: K, event: K,
handler: EventHandler<AppEvents[K]> handler: EventHandler<AppEvents[K]>,
): void { ): void {
EventBus.off(event, handler) EventBus.off(event, handler);
} }

View File

@@ -1,90 +1,121 @@
/** /**
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME * Keybinds persistence via JSONC file in XDG_CONFIG_HOME
* *
* Handles copying keybind.jsonc from package to user config directory * Handles copying keybinds.jsonc from package to user config directory
* and loading/saving keybind configurations. * and loading/saving keybind configurations.
*/ */
import { copyFile, mkdir } from "fs/promises"; import { copyFile } from "fs/promises";
import path from "path"; import path from "path";
import { parseJSONC } from "./jsonc"; import { parseJSONC } from "./jsonc";
import { getConfigFilePath, ensureConfigDir } from "./config-dir"; import { getConfigFilePath, ensureConfigDir } from "./config-dir";
import type { KeybindsResolved } from "../context/KeybindContext"; import type { KeybindsResolved } from "../context/KeybindContext";
const KEYBINDS_SOURCE = path.join( const KEYBINDS_SOURCE = path.join(
process.cwd(), process.cwd(),
"src", "src",
"config", "config",
"keybind.jsonc", "keybinds.jsonc",
); );
const KEYBINDS_FILE = "keybinds.jsonc"; const KEYBINDS_FILE = "keybinds.jsonc";
/** Default keybinds from package */ /** Default keybinds (yazi-style) — mirrors src/config/keybinds.jsonc so the
* app works before a user keybinds file is copied into place. */
const DEFAULT_KEYBINDS: KeybindsResolved = { const DEFAULT_KEYBINDS: KeybindsResolved = {
up: ["up", "k"], // movement
down: ["down", "j"], "move-down": ["j", "down"],
left: ["left", "h"], "move-up": ["k", "up"],
right: ["right", "l"], "page-down": ["ctrl-d"],
cycle: ["tab"], "page-up": ["ctrl-u"],
dive: ["return"], "full-down": ["ctrl-f"],
select: ["return"], "full-up": ["ctrl-b"],
out: ["esc"], "jump-down": ["J"],
inverseModifier: "shift", "jump-up": ["K"],
leader: ":", "goto-top": [["g", "g"]],
quit: ["<leader>q"], "goto-bottom": ["G"],
"audio-toggle": ["<leader>p"], // pane swipe
"audio-pause": [], "swipe-prev": ["h", "left"],
"audio-play": [], "swipe-next": ["l", "right"],
"audio-next": ["<leader>n"], // open / select
"audio-prev": ["<leader>l"], open: ["return", "enter"],
"audio-seek-forward": ["<leader>sf"], "open-interactive": ["shift-return"],
"audio-seek-backward": ["<leader>sb"], "toggle-select": ["space"],
"visual-mode": ["v"],
"toggle-all": ["ctrl-a"],
"invert-all": ["ctrl-r"],
escape: ["escape", "ctrl-["],
// tabs
"tab-prev": ["["],
"tab-next": ["]"],
"tab-goto-1": ["1"],
"tab-goto-2": ["2"],
"tab-goto-3": ["3"],
"tab-goto-4": ["4"],
"tab-goto-5": ["5"],
"tab-goto-6": ["6"],
// command / help / quit
command: [":"],
quit: ["q", "ctrl-c"],
help: ["~", "f1"],
// list ops
search: ["s"],
filter: ["f"],
sort: [","],
"toggle-hidden": ["."],
refresh: ["r"],
// audio transport (preserved; shifted single keys, no collisions)
"audio-toggle": ["P"],
"audio-next": ["N"],
"audio-prev": ["B"],
"audio-seek-forward": ["shift-."],
"audio-seek-backward": ["shift-,"],
}; };
/** Copy keybind.jsonc to user config directory on first run */ /** Copy keybinds.jsonc to user config directory on first run */
export async function copyKeybindsIfNeeded(): Promise<void> { export async function copyKeybindsIfNeeded(): Promise<void> {
try { try {
const targetPath = getConfigFilePath(KEYBINDS_FILE); const targetPath = getConfigFilePath(KEYBINDS_FILE);
// Check if file already exists // Check if file already exists
const targetFile = Bun.file(targetPath); const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return; if (await targetFile.exists()) return;
await ensureConfigDir(); await ensureConfigDir();
await copyFile(KEYBINDS_SOURCE, targetPath); await copyFile(KEYBINDS_SOURCE, targetPath);
} catch { } catch {
// Silently ignore errors // Silently ignore errors
} }
} }
/** Load keybinds from JSONC file */ /** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> { export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try { try {
const filePath = getConfigFilePath(KEYBINDS_FILE); const filePath = getConfigFilePath(KEYBINDS_FILE);
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (!(await file.exists())) return DEFAULT_KEYBINDS; if (!(await file.exists())) return DEFAULT_KEYBINDS;
const raw = await file.text(); const raw = await file.text();
const parsed = parseJSONC(raw); const parsed = parseJSONC(raw);
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS; if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved; // Merge so partial user configs inherit defaults for missing keys.
} catch { return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
return DEFAULT_KEYBINDS; } catch {
} return DEFAULT_KEYBINDS;
}
} }
/** Save keybinds to JSONC file */ /** Save keybinds to JSONC file */
export async function saveKeybindsToFile( export async function saveKeybindsToFile(
keybinds: KeybindsResolved, keybinds: KeybindsResolved,
): Promise<void> { ): Promise<void> {
try { try {
await ensureConfigDir(); await ensureConfigDir();
const filePath = getConfigFilePath(KEYBINDS_FILE); const filePath = getConfigFilePath(KEYBINDS_FILE);
await Bun.write(filePath, JSON.stringify(keybinds, null, 2)); await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
} catch { } catch {
// Silently ignore write errors // Silently ignore write errors
} }
} }

View File

@@ -6,33 +6,88 @@ import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage"; import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
export enum DIRECTION { export enum DIRECTION {
Increment, Increment,
Decrement, Decrement,
} }
export enum TABS { export enum TABS {
FEED = 1, FEED = 1,
MYSHOWS = 2, MYSHOWS = 2,
DISCOVER = 3, DISCOVER = 3,
SEARCH = 4, SEARCH = 4,
PLAYER = 5, PLAYER = 5,
SETTINGS = 6, SETTINGS = 6,
} }
export const TabsCount = 6; export const TabsCount = 6;
/** Tabs that use the yazi depth-stack model (prev | current | preview
* columns, infinite drill via push/pop). Search and Player keep the legacy
* fixed-pane model. */
export const DEPTH_TABS: ReadonlySet<TABS> = new Set([
TABS.FEED,
TABS.MYSHOWS,
TABS.DISCOVER,
TABS.SETTINGS,
]);
/** Root (depth-0) frame for a depth-tab — identifies the top-level list each
* page renders at root. Pages interpret the `kind` to derive their list. */
export function rootFrameFor(
tab: TABS,
): import("@/context/NavigationContext").DepthFrame {
switch (tab) {
case TABS.FEED:
return { kind: "feeds", focus: 0 };
case TABS.MYSHOWS:
return { kind: "shows", focus: 0 };
case TABS.DISCOVER:
return { kind: "discover:categories", focus: 0 };
case TABS.SETTINGS:
return { kind: "settings:sections", focus: 0 };
default:
return { kind: "root", focus: 0 };
}
}
export const LayerGraph = { export const LayerGraph = {
[TABS.FEED]: FeedPage, [TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage, [TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage, [TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage, [TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage, [TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage, [TABS.SETTINGS]: SettingsPage,
}; };
export const LayerDepths = { export const LayerDepths = {
[TABS.FEED]: FeedPaneCount, [TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount, [TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount, [TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount, [TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount, [TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingsPaneCount, [TABS.SETTINGS]: SettingsPaneCount,
};
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
// of terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
export const PANE_RATIO = {
parent: 1,
current: 4,
preview: 3,
} as const;
// 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]: 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]: 1, // depth: sections → items → editor
}; };

View File

@@ -1,156 +1,174 @@
import { searchSourceByType } from "./source-searcher" import { searchSourceByType } from "./source-searcher";
import type { PodcastSource, SearchResult } from "../types/source" import type { PodcastSource, SearchResult } from "../types/source";
import type { Episode } from "../types/episode" import type { Episode } from "../types/episode";
type SearchCacheEntry = { type SearchCacheEntry = {
timestamp: number timestamp: number;
results: SearchResult[] results: SearchResult[];
} };
type SearchOptions = { type SearchOptions = {
cacheTtl?: number cacheTtl?: number;
} };
const searchCache = new Map<string, SearchCacheEntry>() const searchCache = new Map<string, SearchCacheEntry>();
const rateLimitState = new Map<string, number[]>() const rateLimitState = new Map<string, number[]>();
const RATE_LIMIT_WINDOW_MS = 60000 const RATE_LIMIT_WINDOW_MS = 60000;
const RATE_LIMIT_MAX_CALLS = 20 const RATE_LIMIT_MAX_CALLS = 20;
const throttleSource = async (sourceId: string) => { const throttleSource = async (sourceId: string) => {
const now = Date.now() const now = Date.now();
const windowStart = now - RATE_LIMIT_WINDOW_MS const windowStart = now - RATE_LIMIT_WINDOW_MS;
const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [] const timestamps =
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) { if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
if (waitMs > 0) { if (waitMs > 0) {
await new Promise((resolve) => setTimeout(resolve, waitMs)) await new Promise((resolve) => setTimeout(resolve, waitMs));
} }
} }
const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [] const updated =
updated.push(Date.now()) rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
rateLimitState.set(sourceId, updated) updated.push(Date.now());
} rateLimitState.set(sourceId, updated);
};
const buildCacheKey = (query: string, sourceIds: string[]) => { const buildCacheKey = (query: string, sourceIds: string[]) => {
const keySources = [...sourceIds].sort().join(",") const keySources = [...sourceIds].sort().join(",");
return `${query.toLowerCase()}::${keySources}` return `${query.toLowerCase()}::${keySources}`;
} };
const isCacheValid = (entry: SearchCacheEntry, ttl: number) => const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
Date.now() - entry.timestamp < ttl Date.now() - entry.timestamp < ttl;
const dedupeResults = (results: SearchResult[]): SearchResult[] => { const dedupeResults = (results: SearchResult[]): SearchResult[] => {
const map = new Map<string, SearchResult>() const map = new Map<string, SearchResult>();
for (const result of results) { for (const result of results) {
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title const key =
const existing = map.get(key) result.podcast.feedUrl || result.podcast.id || result.podcast.title;
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) { const existing = map.get(key);
map.set(key, result) if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
} map.set(key, result);
} }
return Array.from(map.values()) }
} return Array.from(map.values());
};
export const searchPodcasts = async ( export const searchPodcasts = async (
query: string, query: string,
sourceIds: string[], sourceIds: string[],
sources: PodcastSource[], sources: PodcastSource[],
options: SearchOptions = {} options: SearchOptions = {},
): Promise<SearchResult[]> => { ): Promise<SearchResult[]> => {
const trimmed = query.trim() const trimmed = query.trim();
if (!trimmed) return [] if (!trimmed) return [];
const activeSources = sources.filter( const activeSources = sources.filter(
(source) => sourceIds.includes(source.id) && source.enabled (source) => sourceIds.includes(source.id) && source.enabled,
) );
if (activeSources.length === 0) return [] if (activeSources.length === 0) {
// No enabled sources — surface a clear cause instead of returning empty,
// which otherwise looks indistinguishable from a network failure.
if (sourceIds.length === 0) {
throw new Error("No search sources are enabled");
}
throw new Error("No enabled sources match the selected search sources");
}
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5 const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5;
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id)) const cacheKey = buildCacheKey(
const cached = searchCache.get(cacheKey) trimmed,
if (cached && isCacheValid(cached, cacheTtl)) { activeSources.map((s) => s.id),
return cached.results );
} const cached = searchCache.get(cacheKey);
if (cached && isCacheValid(cached, cacheTtl)) {
return cached.results;
}
const results: SearchResult[] = [] const results: SearchResult[] = [];
const errors: Error[] = [] const errors: Error[] = [];
await Promise.all( await Promise.all(
activeSources.map(async (source) => { activeSources.map(async (source) => {
try { try {
await throttleSource(source.id) await throttleSource(source.id);
const sourceResults = await searchSourceByType(trimmed, source) const sourceResults = await searchSourceByType(trimmed, source);
results.push(...sourceResults) results.push(...sourceResults);
} catch (error) { } catch (error) {
errors.push(error as Error) errors.push(error as Error);
} }
}) }),
) );
const deduped = dedupeResults(results) const deduped = dedupeResults(results);
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
if (sorted.length === 0 && errors.length > 0) { if (sorted.length === 0 && errors.length > 0) {
throw new Error("Search failed for all sources") throw new Error("Search failed for all sources");
} }
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted }) searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted });
return sorted return sorted;
} };
type ItunesEpisodeResult = { type ItunesEpisodeResult = {
trackId?: number trackId?: number;
trackName?: string trackName?: string;
description?: string description?: string;
shortDescription?: string shortDescription?: string;
releaseDate?: string releaseDate?: string;
trackTimeMillis?: number trackTimeMillis?: number;
episodeUrl?: string episodeUrl?: string;
previewUrl?: string previewUrl?: string;
trackViewUrl?: string trackViewUrl?: string;
} };
type ItunesEpisodeResponse = { type ItunesEpisodeResponse = {
resultCount: number resultCount: number;
results: ItunesEpisodeResult[] results: ItunesEpisodeResult[];
} };
export const searchEpisodes = async ( export const searchEpisodes = async (
query: string, query: string,
feedId: string feedId: string,
): Promise<Episode[]> => { ): Promise<Episode[]> => {
const trimmed = query.trim() const trimmed = query.trim();
if (!trimmed) return [] if (!trimmed) return [];
const url = new URL("https://itunes.apple.com/search") const url = new URL("https://itunes.apple.com/search");
url.searchParams.set("term", trimmed) url.searchParams.set("term", trimmed);
url.searchParams.set("media", "podcast") url.searchParams.set("media", "podcast");
url.searchParams.set("entity", "podcastEpisode") url.searchParams.set("entity", "podcastEpisode");
url.searchParams.set("country", "US") url.searchParams.set("country", "US");
url.searchParams.set("lang", "en_us") url.searchParams.set("lang", "en_us");
const response = await fetch(url.toString()) const response = await fetch(url.toString());
if (!response.ok) return [] if (!response.ok) return [];
const data = (await response.json()) as ItunesEpisodeResponse const data = (await response.json()) as ItunesEpisodeResponse;
return data.results return data.results
.map((item) => { .map((item) => {
if (!item.trackName) return null if (!item.trackName) return null;
const id = item.trackId ? `episode-${item.trackId}` : `episode-${item.trackName}` const id = item.trackId
const audioUrl = item.episodeUrl || item.previewUrl || item.trackViewUrl || "" ? `episode-${item.trackId}`
: `episode-${item.trackName}`;
const audioUrl =
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
return { return {
id, id,
podcastId: feedId, podcastId: feedId,
title: item.trackName, title: item.trackName,
description: item.description || item.shortDescription || "", description: item.description || item.shortDescription || "",
audioUrl, audioUrl,
duration: item.trackTimeMillis ? Math.round(item.trackTimeMillis / 1000) : 0, duration: item.trackTimeMillis
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(), ? Math.round(item.trackTimeMillis / 1000)
} : 0,
}) pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
.filter((item): item is Episode => Boolean(item)) };
} })
.filter((item): item is Episode => Boolean(item));
};

View File

@@ -0,0 +1,54 @@
# 01. Rearchitect nav model — remove the sidebar pane
meta:
id: yazi-remake-01
feature: yazi-remake
priority: P1
depends_on: []
tags: [implementation, nav-model, tests-required]
objective:
- Remove the always-on `SIDEBAR_PANE` concept from the navigation context so `activeTab` is plain tab state (not a pane), establishing clean parent|current|preview semantics for the yazi remake.
deliverables:
- `src/context/NavigationContext.tsx` — delete `SIDEBAR_PANE` constant and all references; `activeTab` is no longer a pane
- `src/utils/navigation.ts` — update `TabPaneCount` semantics; depth-tabs = 1 focusable pane (current), the 3 visible columns are a render concern not 3 panes
- Updated header/comment block describing the parent|current|preview model
- `swipe()` / `popDepth()` reworked: depth-tabs `l`=drill (`open`), `h`=pop (noop at depth 0); fixed-pane tabs `h/l` move between parent/current/preview
- Tab-enter resets focus to `DEPTH_CENTER_PANE` (current pane), not a sidebar
steps:
- Audit every reference to `SIDEBAR_PANE` across the codebase (grep)
- In `NavigationContext.tsx`: delete the `SIDEBAR_PANE = -1` export and the `focusedIndex`/`setFocusedIndex` SIDEBAR_PANE branch added previously
- Set the initial `activePane` signal and the tab-switch createEffect to reset to `DEPTH_CENTER_PANE` (the current pane), not `SIDEBAR_PANE`
- Rework `swipe()` to clamp to `[0, paneCount-1]` for fixed-pane tabs (the sidebar is no longer in the chain); depth-tabs don't use `swipe` for drill/pop (that lives in Shell dispatch)
- In `utils/navigation.ts`: confirm `TabPaneCount` reflects focusable content panes only (depth-tabs = 1, Search = 3, Player = 1); update `PANE_RATIO` leave-behind note (ratio change happens in task 02)
- Update the file header comment block to describe parent|current|preview
- Run `lens_diagnostics` on the two files
tests:
- Unit: `focusedIndex(DEPTH_CENTER_PANE)` on a depth-tab returns the top frame's focus; `setFocusedIndex` writes to the top frame (Arrange a tab with a 2-frame stack, Act by calling setFocusedIndex, Assert topFrame.focus updated)
- Integration: tab-switch effect sets `activePane` to `DEPTH_CENTER_PANE` (not -1); `swipe(-1, 3)` on a fixed tab clamps to 0 not -1
- e2e (harness): app boots with `nav.state.pane === 0` (current), not -1
acceptance_criteria:
- No symbol `SIDEBAR_PANE` exists anywhere in `src/`
- Initial `activePane` === `DEPTH_CENTER_PANE` (0)
- Tab-enter sets `activePane` to `DEPTH_CENTER_PANE`
- `swipe()` lower bound is 0 (no `-1`)
validation:
- `grep -rn "SIDEBAR_PANE" src/` returns nothing
- `bun run build` passes
- `lens_diagnostics` paths=[`src/context/NavigationContext.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
notes:
- This task unblocks 03/04/05/06. It must not delete `DEPTH_CENTER_PANE` — that constant is generalised to "the current pane" and retained
- `SIDEBAR_ACTIONS` (added in Shell in a prior turn) is removed in task 06 (the keybind rewrite), not here — but Shell will temporarily fail to compile after this task until 05/06 land; that's expected and the build command ignores type errors, so gate success on grep + targeted diagnostics, not the full build

View File

@@ -0,0 +1,56 @@
# 02. Build the reusable 3-pane layout primitive (1:3:3 ratio, stable parent slot)
meta:
id: yazi-remake-02
feature: yazi-remake
priority: P1
depends_on: []
tags: [implementation, layout, tests-required]
objective:
- Create one reusable `<YaziPaneRow>` primitive that renders three bordered columns (parent | current | preview) at a 1:3:3 grow ratio with a stable 1/7 parent slot even when blank, so every list tab shares an identical, layout-stable shell.
deliverables:
- `src/components/YaziPaneRow.tsx` — new component: props `parent`, `current`, `preview` (Solid JSX/accessors), `parentLabel`, `currentLabel`, `previewLabel`, `focused` (boolean, defaults to current)
- `src/utils/navigation.ts``PANE_RATIO` updated to `{ parent: 1, current: 3, preview: 3 }` (was `{ parent: 1, current: 4, preview: 3 }`)
- Each pane: bordered `scrollbox` + slim header label row (height=1)
- Parent pane keeps its 1/7 `flexGrow` slot even when empty (renders a muted placeholder, never `width:0`)
- Focus ring (border color = accent on current; muted `border` on parent & preview)
steps:
- Set `PANE_RATIO = { parent: 1, current: 3, preview: 3 }` in `utils/navigation.ts`
- Create `YaziPaneRow.tsx` exporting a component that lays out three `<box flexGrow={PANE_RATIO.x}>` columns in a row
- Each column: a height-1 header `<box>` with the label text, then a `<scrollbox height="100%" border borderColor=…>` rendering the passed children
- Thread a `theme` via `useTheme()` inside the primitive (don't require callers to pass colors)
- `focused` prop controls which column gets the accent border — default current; parent & preview always muted
- Ensure the parent column renders a muted placeholder box (e.g. a single `<text fg={muted}>—</text>` or empty) when its children are null, but critically keeps `flexGrow={PANE_RATIO.parent}` so width never collapses
- Add a JSDoc header describing the yazi 1:3:3 contract
- Run diagnostics on the new file
tests:
- Unit: the primitive renders three boxes with flexGrow 1/3/3 regardless of null children (Arrange null parent, render, Assert three columns present with correct flexGrow)
- Integration: toggling `focused` swaps the accent border onto the requested column
- e2e (harness): a page using the primitive shows three equal-ratio columns with the parent column visibly non-zero width even when blank
acceptance_criteria:
- `PANE_RATIO` is `{ parent: 1, current: 3, preview: 3 }`
- `YaziPaneRow` accepts parent/current/preview children + labels + focused
- Parent column width never collapses to 0 (stable 1/7 slot)
- Only the focused column shows the accent border
validation:
- `grep -n "PANE_RATIO" src/utils/navigation.ts` shows the new 1:3:3 values
- `lens_diagnostics` paths=[`src/components/YaziPaneRow.tsx`,`src/utils/navigation.ts`] severity=error → 0 findings
- Harness: render a throwaway page using `<YaziPaneRow>`; confirm 3 columns at 1:3:3 via the frame
notes:
- Independent of task 01 (no nav-state dependency) — can be built in parallel
- Callers (tasks 03/04) pass their own parent/current/preview JSX; the primitive is purely structural
- opentui scrollbox: use `focused` only on the current pane so scroll focus follows the cursor

View File

@@ -0,0 +1,58 @@
# 03. Convert Feed/MyShows/Discover/Settings to the shared parent|current|preview primitive
meta:
id: yazi-remake-03
feature: yazi-remake
priority: P2
depends_on: [yazi-remake-01, yazi-remake-02]
tags: [implementation, pages, tests-required]
objective:
- Rewrite the four depth-stack list tabs to render through `<YaziPaneRow>`, with the previous-depth list now visible in the parent pane (blank at depth 0), the current-depth list in current, and the hovered item in preview — eliminating per-page bespoke 3-column JSX.
deliverables:
- `src/pages/Feed/FeedPage.tsx` — rewritten to use `<YaziPaneRow>`; parent = previous-depth list, current = current-depth list, preview = hovered item detail
- `src/pages/MyShows/MyShowsPage.tsx` — same conversion
- `src/pages/Discover/DiscoverPage.tsx` — same conversion
- `src/pages/Settings/SettingsPage.tsx` — same conversion (sections → items → editor)
- Each page's `nav.action` handler retained but only acts on the current pane
- All per-page bespoke row/flexbox 3-column JSX removed
steps:
- For each of the four pages, read the current implementation to extract the parent/current/preview content builders
- Wrap the page body in `<YaziPaneRow parent={…} current={…} preview={…} focused={isActive} />`
- Parent pane: render the previous-depth frame's list (depth-1). At depth 0 the parent receives null/placeholder (the primitive keeps the slot)
- Current pane: the current-depth list, focusable, with `onMouseDown` row handlers calling `nav.setActivePane(DEPTH_CENTER_PANE)` + `nav.setDepthFocus(i, depth)`
- Preview pane: hovered-item detail derived from `focusedIndex(DEPTH_CENTER_PANE)` (unchanged logic, just relocated into the preview slot)
- Keep `pushDepth`/`popDepth` calls in the `open` action (drill) — behaviour unchanged, only layout changes
- Remove the old inline `<box flexGrow={PANE_RATIO.parent/current/preview}>` columns in favour of the primitive
- Verify each page's `nav.action` handler guards on `data.pane === DEPTH_CENTER_PANE && nav.activePane() === DEPTH_CENTER_PANE`
tests:
- Unit: each page's `open` action pushes a frame and the parent pane switches from blank to the previous list (Arrange depth 0, Act open, Assert stack length 2 and parent renders the old list)
- Integration: `h` (pop) returns parent to blank at depth 0; `l` (drill) populates parent with the previous list
- e2e (harness): Feed depth 0→1→2 shows parent blank → previous feeds list → previous episodes list; Settings sections→items→editor shows the chain in the parent pane
acceptance_criteria:
- All four pages render via `<YaziPaneRow>` (no bespoke 3-column JSX remains)
- Parent pane is blank at depth 0, populated at depth ≥ 1
- Drilling (l/Enter) populates the parent with the previous-depth list
- Popping (h) empties the parent back to blank at depth 0
- j/k move focus only within the current pane
validation:
- `grep -rn "YaziPaneRow" src/pages/` returns 4 files
- `lens_diagnostics` paths over the four page files severity=error → 0 findings
- Harness walk: `init` → navigate Feed → `l` (drill) → `l` (drill) → `h` (pop) → `h` (pop); confirm parent slot transitions blank→list→list→blank
notes:
- Depends on 01 (pane model) and 02 (the primitive) being merged
- The already-working `<Show when={item}>{(item) => (… item() …)}</Show>` accessor pattern for opentui `<Show>` callbacks must be preserved in preview panes
- Keep `LoadingIndicator` usages where they exist

View File

@@ -0,0 +1,52 @@
# 04. Fit Search and Player into the 3-pane (1:3:3) model
meta:
id: yazi-remake-04
feature: yazi-remake
priority: P2
depends_on: [yazi-remake-01, yazi-remake-02]
tags: [implementation, pages, tests-required]
objective:
- Bring the two fixed-layout tabs (Search, Player) into the same 1:3:3 parent|current|preview shell, deciding per-page whether to adopt the depth-stack or stay fixed-3-pane, while applying the new ratios throughout.
deliverables:
- `src/pages/Search/SearchPage.tsx` — rendered through `<YaziPaneRow>`; parent = query input + recent-search history, current = results list, preview = focused-result detail
- `src/pages/Player/PlayerPage.tsx` — rendered through `<YaziPaneRow>`; current = now-playing transport, preview = episode description/notes, parent = blank placeholder (or compact episode list if available)
- Decision recorded in each file's header comment: depth-stack vs fixed-3-pane
steps:
- Read both pages to understand their current pane semantics
- Search: map INPUT→parent, RESULTS→current, DETAIL→preview inside `<YaziPaneRow>`. If the 1/7 parent slot is too narrow for the input box, widen parent for Search only by passing an override ratio OR move the query into current and results into parent — pick the option that keeps the input usable and document it
- Search: keep the `inputFocused` effect (Shell yields keys to `<input>` when current-pane focus is on the query) — adapt to whichever pane the input lives in
- Player: single content pane; parent = blank/placeholder (1/7), current = transport + progress + controls (3/7), preview = episode art/description/notes (3/7). If no preview data, render a muted placeholder but keep the slot
- Confirm fixed-pane tab swipe (h/l between parent/current/preview) still routes correctly for Search
- Run diagnostics
tests:
- Unit: Search's `handleSubmit` swipes to the results pane and sets focus index 0 (Arrange empty results, Act submit, Assert activePane === results pane & focusedIndex 0)
- Integration: Player renders with parent blank and the transport in current
- e2e (harness): Search shows query | results | detail at 1:3:3; Player shows blank | transport | notes at 1:3:3
acceptance_criteria:
- Both pages render via `<YaziPaneRow>` at 1:3:3
- Search input remains typeable (Shell yields keys when the query pane is focused)
- Player's transport is in the current pane with focus
- No layout collapse: parent & preview keep their slots even if blank
validation:
- `grep -rn "YaziPaneRow" src/pages/Search src/pages/Player` returns 2 files
- `lens_diagnostics` paths over both files severity=error → 0 findings
- Harness: navigate to Search, type a query, press Enter, see results in current + detail in preview; navigate to Player, see transport + notes
notes:
- Depends on 01 (pane model — though Search is fixed-pane, the model cleanup affects `swipe` bounds) and 02 (the primitive)
- If Search input at 1/7 is genuinely too tight (~14 cols at 100w), prefer moving the query into the current pane for Search only and the results into parent — but confirm width with the harness before committing
- Player is single-content; the 1:3:3 with blanks is mostly cosmetic but keeps the layout globally consistent

View File

@@ -0,0 +1,56 @@
# 05. Rebuild Shell chrome — drop sidebar, add yazi bottom status/tab bar
meta:
id: yazi-remake-05
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-01]
tags: [implementation, shell-chrome, tests-required]
objective:
- Remove the always-on left tab sidebar entirely and replace it with a full-width page area above a slim yazi-style bottom bar that surfaces the active tab, depth/counts, selection, now-playing, and a discoverable tab strip.
deliverables:
- `src/components/Shell.tsx` — sidebar JSX deleted; render `LayerGraph[tab]()` full-width + a rebuilt bottom status/command bar
- Bottom bar (normal mode): mode label, `TAB_LABEL[tab] · depth N · i/len` (or `pane i/n` for fixed tabs), selection count `●N`, now-playing `♪ title`, pending-keybind hint, and a compact tab strip `[1]Feed [2]MyShows …` with the active tab marked
- Bottom bar (command mode): `:` prompt + buffer + error (unchanged, just relocated if needed)
- Help overlay kept; now-playing relocated from the old sidebar footer into the status bar
steps:
- Read `Shell.tsx` and delete the entire left tab sidebar `<box flexDirection="column" width={14}>…` block
- Replace the middle row with a single full-width `<box flexGrow={1}>{LayerGraph[nav.activeTab()]()}</box>`
- Rebuild the bottom bar: a height-1 `<box flexDirection="row">` with the fragments described above
- Tab strip: render `Object.values(TABS)` filtered to numbers; for each tab show `[N] Label` with the active tab inverted/highlighted (accent bg or `≡` marker)
- Status fragment: `nav.activePane() === DEPTH_CENTER_PANE ? (isDepthTab ? \`depth ${currentDepth()}\` : \`pane ${activePane()+1}/${count}\`) : 'tabs'` — but since the sidebar is gone, default to the depth/pane string (focus starts on current)
- Relocate `nowPlaying()` text from the sidebar footer into the bottom bar
- Keep `runCommand`, `handleCommandKey`, the help overlay, and `playEpisodeAndSwitch` untouched
- Run diagnostics
tests:
- Unit: `nowPlaying()` formats `♪ <truncated title>` (Arrange a current episode, Assert the string)
- Integration: switching tabs updates the tab strip's active marker and the status tab label
- e2e (harness): `init` shows no left sidebar, a full-width page, and a bottom bar containing the tab strip + `Feed · depth 0`; cycling tabs moves the strip's active marker
acceptance_criteria:
- No `width={14}` sidebar `<box>` remains in `Shell.tsx`
- The active page fills the full content width
- The bottom bar shows the active tab, depth, counts, selection, now-playing, and the tab strip
- The active tab is visually marked in the strip
validation:
- `grep -n "width={14}" src/components/Shell.tsx` returns nothing
- `grep -n "LayerGraph" src/components/Shell.tsx` shows the full-width render
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
- Harness: `init` frame has no sidebar column and shows the tab strip in the last row
notes:
- Depends on 01 (the pane model: focus starts on current, so the status fragment no longer needs the `SIDEBAR_PANE` branch)
- Task 06 rewrites the dispatch keybinds in this same file; do the chrome here and leave the dispatch `SIDEBAR_ACTIONS` branch for 06 to remove (or remove it here if 01 already deleted the constant — coordinate with 01)
- `playEpisodeAndSwitch` and the command bar must keep working

View File

@@ -0,0 +1,56 @@
# 06. Rewire keybinds — h/l drill+pop, digits switch tabs, focus starts on current
meta:
id: yazi-remake-06
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-01, yazi-remake-05]
tags: [implementation, keybinds, tests-required]
objective:
- Rewire the Shell dispatch so the sidebar's special-cased j/k branch is gone, h/l drill/pop on depth-tabs and swipe on fixed tabs, digit keys + `[ ]` are the sole tab switcher, and app focus starts on the current pane.
deliverables:
- `src/components/Shell.tsx` (dispatch) — `SIDEBAR_ACTIONS` set + the `if (nav.activePane() === SIDEBAR_PANE)` branch deleted
- `h`/`l` unified: depth-tabs `l`=current-drills (`open` emit), `h`=current-pops (noop at depth 0); fixed-pane tabs `h/l`=`swipe(∓1, count)`
- `1`-`6` / `tab-goto-*`, `tab-next`/`tab-prev` (`[`/`]`) — the only tab switchers
- Initial focus + tab-enter land on `DEPTH_CENTER_PANE`
- `keybinds.jsonc` reviewed (update labels/help only if needed)
steps:
- Read the current `dispatch()` (post task 01 it references a deleted `SIDEBAR_PANE` — fix the compile here)
- Remove the `SIDEBAR_ACTIONS` constant and its branch
- In the `default` case, implement: digit/tab-goto → `setActiveTab`; `swipe-prev` → (depth-tab & current & depth>0) `popDepth` else (depth-tab & current & depth==0) noop else `swipe(-1, count)`; `swipe-next` → (depth-tab & current) emit `open` else `swipe(1, count)`
- Move/list actions (`move-down/up`, `jump-*`, `page-*`, `goto-top/bottom`) flow to `PAGE_ACTIONS``emit("nav.action")` for the current pane only
- Confirm `escape`/`command`/`visual-mode`/`toggle-select`/audio/global branches unchanged
- Verify the app boot path sets focus to current (task 01 set the signal; confirm dispatch doesn't override)
- Run diagnostics + harness key sequence
tests:
- Unit: `dispatch("move-down")` on a depth-tab current pane emits `nav.action {action:"move-down"}` (Arrange current pane, Act, Assert emit)
- Integration: `dispatch("swipe-next")` on a depth-tab at depth 0 emits `open` (drill); `dispatch("swipe-prev")` at depth 1 pops to depth 0; at depth 0 `swipe-prev` is a noop
- e2e (harness): `l` drills (depth 0→1, parent populates), `h` pops (1→0, parent blanks), `1`/`2`/`3` switch tabs, `j`/`k` move the current list cursor without changing depth
acceptance_criteria:
- No `SIDEBAR_PANE` or `SIDEBAR_ACTIONS` references in `Shell.tsx`
- `h` at depth 0 is a noop (does not error, does not change pane)
- `l` at current on a depth-tab drills (depth+1)
- Digit keys switch tabs; focus lands on current pane
- `j`/`k` move within current only
validation:
- `grep -n "SIDEBAR" src/components/Shell.tsx` returns nothing
- `lens_diagnostics` paths=[`src/components/Shell.tsx`] severity=error → 0 findings
- Harness: `init` (focus on current) → `l` (depth 1, parent filled) → `l` (depth 2) → `h` (depth 1) → `h` (depth 0, parent blank) → `3` (Discover tab, focus on current) → `j`/`k` move
notes:
- Depends on 01 (pane model: `swipe` bounds, no SIDEBAR) and 05 (dispatch lives in the rebuilt Shell)
- If `keybinds.jsonc` has a `tab-next`/`tab-prev` mapping conflict, resolve here
- The noop `h` at depth 0 should feel inert (yazi: at root, `h` does nothing)

View File

@@ -0,0 +1,60 @@
# 07. Verify the remake — build + diagnostics + harness walk-through
meta:
id: yazi-remake-07
feature: yazi-remake
priority: P1
depends_on: [yazi-remake-03, yazi-remake-04, yazi-remake-05, yazi-remake-06]
tags: [verification, tests-required]
objective:
- Confirm the yazi remake meets every exit criterion via a clean build, zero diagnostics, and a full harness walk-through of every tab and depth.
deliverables:
- A passing `bun run build`
- `lens_diagnostics mode=all` with zero errors across edited files
- Harness frames + state proving the parent|current|preview 1:3:3 layout, drill/pop behaviour, tab switching, and status bar across all six tabs
steps:
- Run `bun run build` — expect "Build complete"
- Run `lens_diagnostics mode=all severity=error` — expect 0 findings across all session-edited files
- Run the drive harness (`scripts/tui-harness.tsx`) walk-through:
- `init` → confirm no sidebar, 3 columns at 1:3:3, focus on current, bottom tab strip visible
- Feed: `l` (depth 0→1, parent fills) → `l` (1→2) → `h` (2→1) → `h` (1→0, parent blanks) ; `j`/`k` move current
- `2` → MyShows: drill show→episodes, parent reflects
- `3` → Discover: category→results, parent shows categories
- `6` → Settings: sections→items→editor, parent shows the previous list at each depth
- `4` → Search: query|results|detail at 1:3:3; type + Enter works
- `5` → Player: blank|transport|notes at 1:3:3
- Capture the status bar content (active tab + depth + counts + now-playing + tab strip) from a representative frame
tests:
- Build: `bun run build` exits 0 with "Build complete"
- Diagnostics: `lens_diagnostics` mode=all → 0 errors
- Harness (integration/e2e): the walk-through above produces the expected frames & state (parent blank at depth 0, populates on drill, blanks on pop; digits switch tabs; h noop at depth 0)
acceptance_criteria:
- `bun run build` passes
- `lens_diagnostics` mode=all reports zero errors
- All six tabs render 3 stable columns at 1:3:3
- Parent pane is blank at depth 0; drill fills it with the previous-depth list; pop empties it
- `h` is a noop at depth 0; `l` drills; `1-6`/`[`/`]` switch tabs; `j/k` move current only
- No sidebar; focus starts on current; bottom bar shows active tab + depth + counts + tab strip
validation:
- `bun run build 2>&1 | tail -3` → "Build complete"
- `lens_diagnostics` mode=all severity=error → "No error issues…"
- Harness `state nav` after `init` shows `pane === 0` (current), not -1
- Harness frames for Feed depth 0/1/2 show the parent slot transition blank→list→list
notes:
- This is the gate for the whole feature — do not mark done if any criterion fails; open a blocker task instead
- If the harness reveals a visual regression (e.g. parent collapses, ratios off), file it against the responsible task (02 or 03) rather than patching here
- Save a representative `.harness/last-frame.txt` snapshot if a visual reference is useful for future sessions

View File

@@ -0,0 +1,40 @@
# Yazi UI Remake
Objective: Remake the PodTUI shell into a yazi-pure parent|current|preview 3-pane layout (1:3:3 ratio) with a bottom tab strip and no always-on sidebar.
Status legend: [ ] todo, [~] in-progress, [x] done
## Tasks
- [ ] 01 — rearchitect-nav-model → `01-rearchitect-nav-model.md`
- [ ] 02 — build-three-pane-layout-primitive → `02-build-three-pane-layout-primitive.md`
- [ ] 03 — convert-list-tabs-to-primitive → `03-convert-list-tabs-to-primitive.md`
- [ ] 04 — fit-search-and-player-panes → `04-fit-search-and-player-panes.md`
- [ ] 05 — rebuild-shell-chrome → `05-rebuild-shell-chrome.md`
- [ ] 06 — rewire-keybinds → `06-rewire-keybinds.md`
- [ ] 07 — verify-remake → `07-verify-remake.md`
## Dependencies
- 03 depends on 01
- 03 depends on 02
- 04 depends on 01
- 04 depends on 02
- 05 depends on 01
- 06 depends on 01
- 06 depends on 05
- 07 depends on 03
- 07 depends on 04
- 07 depends on 05
- 07 depends on 06
## Exit criteria
- The feature is complete when the left tab sidebar is gone; tabs switch only via digit keys `1-6` / `[ ]` and a bottom tab strip
- All tabs render three stable columns at 1/7 : 3/7 : 3/7 (parent | current | preview)
- The parent pane renders the previous-depth list and is blank (but keeps its 1/7 slot) at depth 0
- `h`/`l` drill (push) and pop depths on list tabs; `h` is a noop at depth 0
- `j`/`k` move within the current pane only; focus starts on the current pane
- Feed depth 0→1→2, MyShows, Discover, Settings (sections→items→editor), Search, and Player all render correctly via the drive harness
- `bun run build` passes and `lens_diagnostics` (mode=all) reports zero errors
- The bottom status bar shows active tab + depth + counts, selection count, now-playing, and the tab strip

View File

@@ -0,0 +1,131 @@
import { parseStroke, parseBindingSpec } from "../src/context/KeybindContext";
const cfg = {
"move-down": parseBindingSpec(["j", "down"]),
"move-up": parseBindingSpec(["k", "up"]),
"goto-top": parseBindingSpec([["g", "g"]]),
"goto-bottom": parseBindingSpec(["G"]),
"toggle-select": parseBindingSpec(["space"]),
"swipe-prev": parseBindingSpec(["h", "left"]),
"audio-toggle": parseBindingSpec(["P"]),
"audio-seek-forward": parseBindingSpec(["shift-."]),
"audio-seek-backward": parseBindingSpec(["shift-,"]),
sort: parseBindingSpec([","]),
quit: parseBindingSpec(["q"]),
command: parseBindingSpec([":"]),
"tab-next": parseBindingSpec(["]"]),
} as Record<string, ReturnType<typeof parseBindingSpec>>;
function eq(a: any, b: any) {
return (
a.key === b.key &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.meta === !!b.meta
);
}
function classify(candidate: any[]) {
const exact: string[] = [];
const prefix: string[] = [];
for (const name of Object.keys(cfg)) {
for (const seq of cfg[name]) {
if (seq.length < candidate.length) continue;
let p = true;
for (let i = 0; i < candidate.length; i++)
if (!eq(seq[i], candidate[i])) {
p = false;
break;
}
if (!p) continue;
if (seq.length === candidate.length) exact.push(name);
else prefix.push(name);
}
}
return { exact, prefix };
}
function longest(names: string[]) {
let best = names[0],
bl = 0;
for (const n of names)
for (const s of cfg[n])
if (s.length > bl) {
bl = s.length;
best = n;
}
return best;
}
function sim(strokes: any[]) {
let pending: any[] = [];
let fired: string | null = null;
for (const st of strokes) {
const cand = [...pending, st];
const { exact, prefix } = classify(cand);
if (prefix.length > 0) {
pending = cand;
continue;
}
if (exact.length === 0) {
const fresh = classify([st]);
if (fresh.prefix.length > 0) {
pending = [st];
continue;
}
if (fresh.exact.length > 0) fired = longest(fresh.exact);
else fired = null;
} else fired = longest(exact);
pending = [];
}
return fired;
}
const E = (k: string, o: any = {}) => ({ key: k, ...o });
let pass = 0,
fail = 0;
function check(label: string, got: string | null, want: string | null) {
const ok = got === want;
if (ok) pass++;
else fail++;
console.log(
`${ok ? "PASS" : "FAIL"} ${label} => ${got}${ok ? "" : ` (want ${want})`}`,
);
}
check("j -> move-down", sim([E("j")]), "move-down");
check("down -> move-down", sim([E("down")]), "move-down");
check("gg -> goto-top", sim([E("g"), E("g")]), "goto-top");
check("G -> goto-bottom", sim([E("g", { shift: true })]), "goto-bottom");
check("space -> toggle-select", sim([E("space")]), "toggle-select");
check("q -> quit", sim([E("q")]), "quit");
check(": -> command", sim([E(":")]), "command");
check("] -> tab-next", sim([E("]")]), "tab-next");
check(
"shift+p -> audio-toggle",
sim([E("p", { shift: true })]),
"audio-toggle",
);
check("plain p -> null", sim([E("p")]), null);
check(
"shift+, -> audio-seek-backward",
sim([E(",", { shift: true })]),
"audio-seek-backward",
);
check(", -> sort", sim([E(",")]), "sort");
check(
"shift+. -> audio-seek-forward",
sim([E(".", { shift: true })]),
"audio-seek-forward",
);
check(
"g then j -> move-down (timeout-ish fallthrough)",
sim([E("g"), E("j")]),
"move-down",
);
console.log(
parseStroke("ctrl-d"),
parseStroke("G"),
parseStroke(">"),
parseStroke("shift-return"),
);
console.log(`\n${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);

View File

@@ -12,8 +12,8 @@
"types": ["bun-types"], "types": ["bun-types"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"]
} }
}, },
"include": ["src/**/*", "tests/**/*"] "include": ["src/**/*", "tests/**/*", "scripts/**/*"]
} }