start revive

This commit is contained in:
2026-07-30 21:25:03 -04:00
parent b7c4938c54
commit d8f11040bc
25 changed files with 5344 additions and 2559 deletions

1
.gitignore vendored
View File

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

BIN
bun.lockb Executable file

Binary file not shown.

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

@@ -0,0 +1,573 @@
#!/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
*/
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 === "--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 = {
activeTab: nav.activeTab?.(),
activeDepth: nav.activeDepth?.(),
inputFocused: nav.inputFocused?.(),
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,
position: audioControls.position ? audioControls.position() : null,
duration: audioControls.duration ? audioControls.duration() : null,
volume: audioControls.volume ? audioControls.volume() : null,
error: audioControls.error ? audioControls.error() : null,
currentEpisodeTitle: 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,
selectedFeedId: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
isLoadingFeeds: 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, null, 2));
} catch {}
// ── Output ───────────────────────────────────────────────────────────────
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
console.log(
`\n=== FRAME ${spans.cols}x${spans.rows} (cursor ${spans.cursor[0]},${spans.cursor[1]}) | actions=${actions.length} | ${cmd} ===`,
);
console.log(plainFrame);
if (scope === "all") {
const styles = distinctStyles(spans);
if (styles.length) {
console.log("--- distinct styles (sample) ---");
for (const s of styles) console.log(` ${s.tag} ×${s.n}${s.sample}`);
}
}
// State subset.
const want = (k: string) => scope === "all" || scope === k;
if (want("nav"))
console.log("--- nav ---\n" + JSON.stringify(state.nav, null, 2));
if (want("audio"))
console.log("--- audio ---\n" + JSON.stringify(state.audio, null, 2));
if (want("feed"))
console.log("--- feed ---\n" + JSON.stringify(state.feed, null, 2));
if (want("app"))
console.log("--- app ---\n(src/stores/app.ts not dumped in v1)");
if (issues.length) {
console.log("--- issues (" + issues.length + ") ---");
for (const i of issues.slice(0, 40)) console.log(" ! " + i);
} else {
console.log("--- issues: none ---");
}
console.log(
`\n(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 { useKeyboard, useSelectionHandler } from "@opentui/solid";
import { TabNavigation } from "./components/TabNavigation";
import { CodeValidation } from "@/components/CodeValidation";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { ErrorBoundary } from "solid-js";
import { useSelectionHandler, useRenderer } from "@opentui/solid";
import { useAuthStore } from "@/stores/auth";
import { useFeedStore } from "@/stores/feed";
import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { FeedVisibility } from "@/types/feed";
import { Clipboard } from "@/utils/clipboard";
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 { KeybindProvider, useKeybinds } from "./context/KeybindContext";
import { NavigationProvider, useNavigation } from "./context/NavigationContext";
import { useAudioNavStore, AudioSource } from "./stores/audio-nav";
import {
NavigationProvider,
useNavigation,
NavMode,
} from "./context/NavigationContext";
import { TABS } from "./utils/navigation";
import { Shell } from "./components/Shell";
const DEBUG = import.meta.env.DEBUG;
export function App() {
const nav = useNavigation();
const auth = useAuthStore();
const feedStore = useFeedStore();
const audio = useAudio();
const toast = useToast();
const renderer = useRenderer();
const themeContext = useTheme();
const theme = themeContext.theme;
const nav = useNavigation();
const auth = useAuthStore();
const audio = useAudio();
const toast = useToast();
const renderer = useRenderer();
const themeContext = useTheme();
const theme = themeContext.theme;
const keybind = useKeybinds();
// Create a reactive expression for background color
const backgroundColor = () => {
return themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface;
};
const keybind = useKeybinds();
const audioNav = useAudioNavStore();
// Multimedia keys (physical play/seek keys) still feed the audio backend
// regardless of the on-screen yazi keybinds.
useMultimediaKeys({
playerFocused: () =>
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
? true
: false,
inputFocused: () => nav.inputFocused(),
hasEpisode: () => !!audio.currentEpisode(),
});
useMultimediaKeys({
playerFocused: () =>
nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0,
inputFocused: () => nav.inputFocused(),
hasEpisode: () => !!audio.currentEpisode(),
});
// Mouse text-selection → clipboard (unchanged from the old shell).
useSelectionHandler((selection: any) => {
if (!selection) return;
const text = selection.getSelectedText?.();
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) => {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
nav.setActiveDepth(1);
audioNav.setSource(AudioSource.FEED);
};
const backgroundColor = () =>
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface;
useSelectionHandler((selection: any) => {
if (!selection) return;
const text = selection.getSelectedText?.();
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();
});
});
useKeyboard(
(keyEvent) => {
const isCycle = keybind.match("cycle", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isDown = keybind.match("down", keyEvent);
const isLeft = keybind.match("left", keyEvent);
const isRight = keybind.match("right", keyEvent);
const isDive = keybind.match("dive", keyEvent);
const isOut = keybind.match("out", keyEvent);
const isToggle = keybind.match("audio-toggle", keyEvent);
const isNext = keybind.match("audio-next", keyEvent);
const isPrev = keybind.match("audio-prev", keyEvent);
const isSeekForward = keybind.match("audio-seek-forward", keyEvent);
const isSeekBackward = keybind.match("audio-seek-backward", keyEvent);
const isQuit = keybind.match("quit", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
// unified navigation: left->right, top->bottom across all tabs
if (nav.activeDepth() == 0) {
// at top level: cycle through tabs
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>
);
return (
<ErrorBoundary
fallback={(err) => (
<box border padding={2} borderColor={theme.error}>
<text fg={theme.error}>
Error: {err?.message ?? String(err)}
{"\n"}
Press 1-6 to switch tabs, or : to open the command bar.
</text>
</box>
)}
>
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={backgroundColor()}
>
{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>
</box>
)}
<Shell />
</box>
</ErrorBoundary>
);
}

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

@@ -0,0 +1,586 @@
/**
* Shell — yazi-style application chrome.
*
* Replaces the old left sidebar (vertical TabNavigation) with a horizontal
* top tab bar, renders the active page (which owns its own panes), and adds a
* bottom status/command bar. A single `useKeyboard` router translates keystrokes
* (via the sequence-aware keybind matcher) into actions: global ones (tabs,
* modes, audio, quit, help, command) are handled here; pane/list ones are
* dispatched to the active page over the `nav.action` event bus.
*/
import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext";
import { 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",
],
);
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;
}
// ── pane swipe ──
if (action === "swipe-prev") {
evt.preventDefault();
nav.swipe(-1, TabPaneCount[tab]);
break;
}
if (action === "swipe-next") {
evt.preventDefault();
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}
>
{/* ── Top tab bar ─────────────────────────────────────────────────────── */}
<box
flexDirection="row"
height={1}
width="100%"
backgroundColor={t.background}
>
<For
each={Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
)}
>
{(tab) => {
const active = () => nav.activeTab() === tab;
return (
<box
backgroundColor={active() ? t.primary : t.background}
paddingRight={1}
paddingLeft={1}
onMouseDown={() => nav.setActiveTab(tab)}
>
<text fg={active() ? t.surface : t.textMuted}>
{tab}. {TAB_LABEL[tab]}
</text>
</box>
);
}}
</For>
<box flexGrow={1} backgroundColor={t.background} />
<text fg={t.textMuted} paddingRight={1}>
{nowPlaying() ?? ""}
</text>
</box>
{/* ── Active page (owns its panes) ────────────────────────────────────── */}
<box flexDirection="column" flexGrow={1} width="100%">
{LayerGraph[nav.activeTab()]()}
</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()]} · 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 { 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() {
const { theme } = useTheme();
return (
<box border title="Shortcuts" style={{ padding: 1 }}>
<box style={{ flexDirection: "column" }}>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[0]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[0]?.action ?? ""}</text>
</box>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[1]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[1]?.action ?? ""}</text>
</box>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[2]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[2]?.action ?? ""}</text>
</box>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[3]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[3]?.action ?? ""}</text>
</box>
</box>
</box>
);
const { theme } = useTheme();
return (
<box
border
title="Shortcuts"
style={{ flexDirection: "column", padding: 1 }}
>
<box style={{ flexDirection: "column" }}>
<For each={shortcuts}>
{(s) => (
<box style={{ flexDirection: "row" }} gap={2}>
<text fg={theme.accent}>{s.keys}</text>
<text fg={theme.text}>{s.action}</text>
</box>
)}
</For>
</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 = [
{ keys: "Ctrl+Q", action: "Quit" },
{ keys: "Ctrl+S", action: "Save" },
{ keys: "Left/Right", action: "Switch tabs" },
{ keys: "Esc", action: "Close modal" },
] as const
{ keys: "j / k", action: "Move down / up (within pane)" },
{ keys: "h / l", action: "Swipe to prev / next pane" },
{ keys: "J / K", action: "Jump 5 lines down / up" },
{ keys: "ctrl-d / u", action: "Half page down / up" },
{ 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 { createSimpleContext } from "./helper";
import {
copyKeybindsIfNeeded,
loadKeybindsFromFile,
saveKeybindsToFile,
copyKeybindsIfNeeded,
loadKeybindsFromFile,
saveKeybindsToFile,
} from "../utils/keybinds-persistence";
import { createStore } from "solid-js/store";
export type KeybindsResolved = {
up: string[];
down: string[];
left: string[];
right: string[];
cycle: string[]; // this will cycle no matter the depth/orientation
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[];
};
// ── Keybind model ───────────────────────────────────────────────────────────
// Yazi-style: every binding is one or more "strokes". A stroke is a single
// key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a
// sequence (e.g. ["g","g"] = gg, ["space","n"] = <leader>n). The matcher
// buffers keystrokes, prefers the longest matching sequence, and exposes the
// pending buffer reactively so the status bar can show it (very yazi).
export enum KeybindAction {
UP,
DOWN,
LEFT,
RIGHT,
CYCLE,
DIVE,
OUT,
QUIT,
SELECT,
AUDIO_TOGGLE,
AUDIO_PAUSE,
AUDIO_PLAY,
AUDIO_NEXT,
AUDIO_PREV,
AUDIO_SEEK_F,
AUDIO_SEEK_B,
/** A single key press. `key` is the lowercase logical key name
* ("j", "return", "space", "up", "f1", ...). */
export interface Stroke {
key: string;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
}
/** Raw config spec for one action: a list of alternative sequences. Each
* alternative is itself a list of stroke-notation strings. So
* "j" -> [[ {key:"j"} ]]
* ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]]
* [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */
export type KeybindSpec = string | (string | string[])[];
/** 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 } =
createSimpleContext({
name: "Keybinds",
init: () => {
const [store, setStore] = createStore({
up: [],
down: [],
left: [],
right: [],
cycle: [],
dive: [],
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);
createSimpleContext({
name: "Keybinds",
init: () => {
const [store, setStore] = createStore<KeybindsResolved>({});
// Resolved sequences per action, recomputed when store changes.
const [resolved, setResolved] = createSignal<Record<string, Stroke[][]>>(
{},
);
const [ready, setReady] = createSignal(false);
const [pending, setPending] = createSignal<Stroke[]>([]);
async function load() {
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
setReady(true);
}
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
async function save() {
saveKeybindsToFile(store);
}
function recompute() {
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 {
const keys = store[input] || [];
return Array.isArray(keys) ? keys.join(", ") : keys;
}
async function load() {
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
recompute();
setReady(true);
}
function match(
keybind: keyof KeybindsResolved,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const keys = store[keybind];
if (!keys) return false;
async function save() {
saveKeybindsToFile(store as KeybindsResolved);
}
for (const key of keys) {
if (evt.name === key) return true;
}
return false;
}
function print(input: KeybindActionName): string {
const alts = resolved()[input] ?? [];
return alts.map(sequenceLabel).join(" / ") || "—";
}
function isInverting(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}) {
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;
}
function clearPending() {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = undefined;
}
if (pending().length > 0) setPending([]);
}
// Load on mount
onMount(() => {
load().catch(() => {});
});
function armTimer() {
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS);
}
return {
get ready() {
return ready();
},
get keybinds() {
return store;
},
save,
print,
match,
isInverting,
};
},
});
/** Look up every action whose sequence-list the candidate is a prefix of
* (i.e. a longer match is still possible) and every action that the
* candidate exactly equals. */
function classify(candidate: Stroke[]) {
const exact: KeybindActionName[] = [];
const prefix: KeybindActionName[] = [];
const map = resolved();
for (const name of Object.keys(map) as KeybindActionName[]) {
for (const seq of map[name] ?? []) {
if (seq.length < candidate.length) continue;
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,305 @@
import { createEffect, createSignal, on } from "solid-js";
import { createEffect, createSignal, on, batch, createMemo } from "solid-js";
import { createSimpleContext } from "./helper";
import { TABS, TabsCount, LayerDepths } from "@/utils/navigation";
import { TABS, TabsCount } from "@/utils/navigation";
// Page-specific pane counts
const PANE_COUNTS = {
[TABS.FEED]: 1,
[TABS.MYSHOWS]: 2,
[TABS.DISCOVER]: 2,
[TABS.SEARCH]: 3,
[TABS.PLAYER]: 1,
[TABS.SETTINGS]: 5,
};
// ── Yazi-style navigation state ──────────────────────────────────────────────
// PodTui's interaction model after the yazi redesign. A single source of truth
// for: which tab is active, which pane within a tab is focused (parent |
// current | preview), the current mode (normal/visual/command/input), the
// count register (for `5j` style motions), and the command-bar buffer.
//
// Panes are addressed by index 0..N-1 within the active tab. Each tab declares
// how many panes it has via the PaneSystem registry (see navigation.ts). h/l
// (swipe-prev / swipe-next) move pane focus; j/k move within the focused pane's
// list (handled per-pane via the focusedIndex accessors below).
export enum NavMode {
NORMAL = "NORMAL",
VISUAL = "VISUAL",
COMMAND = "COMMAND",
INPUT = "INPUT",
}
/** Slot semantics mirror yazi's three columns. Slots beyond 2 exist for
* tabs that need more panes (e.g. search = query/results/detail). */
export enum PaneSlot {
PARENT = 0, // left — the container list (e.g. shows)
CURRENT = 1, // middle — the items (e.g. episodes)
PREVIEW = 2, // right — detail of the hovered item
}
export type PaneId = number; // 0-based index into the active tab's pane list
// ── 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 } =
createSimpleContext({
name: "Navigation",
init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activeDepth, setActiveDepth] = createSignal(0);
const [inputFocused, setInputFocused] = createSignal(false);
createSimpleContext({
name: "Navigation",
init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activePane, setActivePane] = createSignal<PaneId>(
PaneSlot.CURRENT,
);
const [mode, setMode] = createSignal<NavMode>(NavMode.NORMAL);
const [count, setCount] = createSignal<number | null>(null);
const [inputFocused, setInputFocused] = createSignal(false);
createEffect(
on(
() => activeTab,
() => setActiveDepth(0),
),
);
// per-pane focused index (for j/k movement). Keyed by `${tab}:${pane}`.
const [paneIndices, setPaneIndices] = createSignal<
Record<string, number>
>({});
const [selections, setSelections] = createSignal<SelectionMap>({});
const [visualAnchor, setVisualAnchor] = createSignal<{
paneKey: string;
index: number;
} | null>(null);
const nextTab = () => {
if (activeTab() >= TabsCount) {
setActiveTab(1);
return;
}
setActiveTab(activeTab() + 1);
};
const [commandBuffer, setCommandBuffer] = createSignal("");
const [commandError, setCommandError] = createSignal<string | null>(null);
const prevTab = () => {
if (activeTab() <= 1) {
setActiveTab(TabsCount);
return;
}
setActiveTab(activeTab() - 1);
};
// Reset depth/pane/mode on tab change.
createEffect(
on(activeTab, () => {
batch(() => {
setActivePane(PaneSlot.CURRENT);
setMode(NavMode.NORMAL);
setCount(null);
setCommandBuffer("");
setCommandError(null);
setVisualAnchor(null);
});
}),
);
const nextPane = () => {
// Move to next pane within the current tab's pane structure
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev % count) + 1);
};
// ── 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)));
const prevPane = () => {
// Move to previous pane within the current tab's pane structure
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev - 2 + count) % count + 1);
};
// ── pane focus ──────────────────────────────────────────────────────────
const setPane = (pane: PaneId) => setActivePane(pane);
return {
activeTab,
activeDepth,
inputFocused,
setActiveTab,
setActiveDepth,
setInputFocused,
nextTab,
prevTab,
nextPane,
prevPane,
};
},
});
/** Move focus to the adjacent pane. `dir` = -1 (left/parent) or +1
* (right/preview). Clamped to [0, paneCount-1]. */
const swipe = (dir: -1 | 1, paneCount: number) => {
if (paneCount <= 1) return;
setActivePane((p) => {
const n = Math.max(0, Math.min(paneCount - 1, p + dir));
return n;
});
};
// ── per-pane focus index ────────────────────────────────────────────────
const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`;
const focusedIndex = (pane: PaneId = activePane()) =>
paneIndices()[paneKey(pane)] ?? 0;
const setFocusedIndex = (pane: PaneId, index: number) =>
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,
// 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,353 @@
/**
* DiscoverPage component - Main discover/browse interface for PodTUI
* DiscoverPage — yazi-style 3-pane view.
*
* pane 0 (parent) — category list (the "containers")
* pane 1 (current) — podcast results for the focused category (landing pane)
* pane 2 (preview) — detail of the focused podcast + subscribe action
*
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
* panes; j/k move within; Enter subscribes to the focused podcast; r refreshes.
* Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering.
*/
import { createSignal, For, Show, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { PodcastCard } from "./PodcastCard";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Podcast } from "@/types/podcast";
import { PANE_RATIO } from "@/utils/navigation";
enum DiscoverPagePaneType {
CATEGORIES = 1,
SHOWS = 2,
export const DiscoverPaneCount = 3;
function DiscoverPage() {
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const CATS = PaneSlot.PARENT; // 0 — categories (parent)
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane)
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe
const categories = () => DISCOVER_CATEGORIES;
const podcasts = () => discoverStore.filteredPodcasts();
const focusedCategory = createMemo(() => {
const list = categories();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
});
// ── keep category + results focus in range ───────────────────────────────
const ensureFocus = () => {
const cl = categories();
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length)
nav.setFocusedIndex(CATS, cl.length - 1);
const pl = podcasts();
if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length)
nav.setFocusedIndex(RESULTS, pl.length - 1);
};
onMount(ensureFocus);
const focusedPodcast = createMemo(() => {
const list = podcasts();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)];
});
// Register a resolver so visual-mode range selection grows by podcast id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${RESULTS}`,
(i) => podcasts()[i]?.id,
);
});
onCleanup(() => unsub());
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleSubscribe = (podcast: Podcast) => {
discoverStore.toggleSubscription(podcast.id);
};
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
nav.swipe(1, DiscoverPaneCount); // dive to results
return;
}
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) handleSubscribe(pod);
}
},
"toggle-select": (p) => {
if (p === RESULTS) {
const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id);
}
},
refresh: () => {
discoverStore.refresh().catch(() => {});
},
};
function len(pane: PaneId): number {
if (pane === CATS) return categories().length;
if (pane === RESULTS) return podcasts().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
if (pane === CATS) {
const c = focusedCategory();
if (c) discoverStore.setSelectedCategory(c.id);
}
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): categories ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Categories</text>
</box>
<scrollbox
height="100%"
focused={isActive(CATS)}
border
borderColor={border(CATS)}
backgroundColor={theme.background}
>
<For each={categories()}>
{(cat, index) => {
const selected = () =>
cat.id === discoverStore.selectedCategory();
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={
selected() && !isActive(CATS)
? theme.border
: focusBg(index(), CATS)
}
onMouseDown={() => {
nav.setActivePane(CATS);
nav.setFocusedIndex(CATS, index());
discoverStore.setSelectedCategory(cat.id);
}}
>
<text fg={focusFg(index(), CATS)}>
{index() === nav.focusedIndex(CATS) ? "" : " "}
</text>
<text fg={focusFg(index(), CATS)}>{cat.name}</text>
<Show when={selected()}>
<text
fg={
index() === nav.focusedIndex(CATS)
? theme.surface
: theme.accent
}
>
*
</text>
</Show>
</box>
);
}}
</For>
</scrollbox>
</box>
{/* ── pane 1 (current, center): results ───────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{focusedCategory()?.name ?? "Discover"} · {podcasts().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(RESULTS)}
border
borderColor={border(RESULTS)}
backgroundColor={theme.background}
>
<Show
when={podcasts().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No podcasts found. :refresh</text>
</box>
}
>
<For each={podcasts()}>
{(podcast, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), RESULTS)}
onMouseDown={() => {
nav.setActivePane(RESULTS);
nav.setFocusedIndex(RESULTS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), RESULTS)}>
{index() === nav.focusedIndex(RESULTS) ? "" : " "}
</text>
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
<Show when={podcast.isSubscribed}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: theme.success
}
>
[+]
</text>
</Show>
</box>
<Show when={podcast.author}>
<text
fg={
index() === nav.focusedIndex(RESULTS)
? theme.surface
: muted()
}
paddingLeft={2}
>
by {podcast.author}
</text>
</Show>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREVIEW)}
border
borderColor={border(PREVIEW)}
backgroundColor={theme.background}
>
<Show
when={focusedPodcast()}
fallback={
<box padding={1}>
<text fg={muted()}>No podcast focused</text>
</box>
}
>
{(pod) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{pod().title}</strong>
</text>
<Show when={pod().author}>
<text fg={muted()}>by {pod().author}</text>
</Show>
<Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text>
</Show>
<Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{pod().description?.slice(0, 400) ??
"No description available."}
{(pod().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show when={(pod().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
<For each={(pod().categories ?? []).slice(0, 4)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</box>
</Show>
<Show when={pod().feedUrl}>
<text fg={muted()}>Feed: {pod().feedUrl}</text>
</Show>
<text fg={muted()}>
Updated: {formatDate(pod().lastUpdated)}
</text>
<box height={1} />
<text fg={muted()}>enter: subscribe h/l: panes r: refresh</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
}
export const DiscoverPaneCount = 2;
export function 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>
);
}
export { DiscoverPage };

View File

@@ -1,195 +1,460 @@
/**
* FeedPage - Shows latest episodes across all subscribed shows
* Reverse chronological order, grouped by date
* FeedPage — yazi-style 3-pane view of all episodes across subscribed shows.
*
* pane 0 (parent) — subscribed feeds list (the "containers"); an implicit
* "All Feeds" entry at index 0 shows every episode.
* pane 1 (current) — flat episodes list for the focused feed (reverse
* chronological). This is the landing pane.
* pane 2 (preview) — detail of the focused episode.
*
* The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between
* panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio.
*/
import { createSignal, For, Show, onMount } from "solid-js";
import {
createMemo,
For,
Show,
onMount,
onCleanup,
createEffect,
} from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} 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 { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { TABS } from "@/utils/navigation";
import { useKeyboard } from "@opentui/solid";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum FeedPaneType {
FEED = 1,
export const FeedPaneCount = 3;
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
function FeedPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent)
const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane)
const PREV = PaneSlot.PREVIEW; // 2 — episode detail
// ── feeds pane data ──────────────────────────────────────────────────────
// Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first).
const feedList = createMemo<FeedListItem[]>(() => {
const all: FeedListItem[] = [{ kind: "all" }];
for (const f of feedStore.getFilteredFeeds())
all.push({ kind: "feed", feed: f });
return all;
});
const focusedFeedItem = createMemo(() => {
const list = feedList();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(FEEDS), list.length - 1)];
});
// ── episodes pane data (filtered by focused feed, or all) ────────────────
type EpItem = { episode: Episode; feed: Feed };
const episodes = createMemo<EpItem[]>(() => {
const item = focusedFeedItem();
if (!item || item.kind === "all")
return feedStore.getAllEpisodesChronological() as EpItem[];
return [...item.feed.episodes]
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
.map((episode) => ({ episode, feed: item.feed }));
});
// Reset episodes focus when the feed filter changes.
createEffect(() => {
focusedFeedItem();
nav.setFocusedIndex(EPS, 0);
});
const focusedItem = createMemo<EpItem | undefined>(() => {
const list = episodes();
if (list.length === 0) return undefined;
return list[Math.min(nav.focusedIndex(EPS), list.length - 1)];
});
// Keep resolvers fresh so visual-mode range selection grows by id.
onMount(() => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.episode.id,
);
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.episode.id,
);
});
onCleanup(() => unsub());
});
const ensureFocus = () => {
const eps = episodes();
if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length)
nav.setFocusedIndex(EPS, eps.length - 1);
const fl = feedList();
if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length)
nav.setFocusedIndex(FEEDS, fl.length - 1);
};
onMount(ensureFocus);
// ── helpers ────────────────────────────────────────────────────────────────
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);
};
// ── nav.action handler ────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes
if (p === EPS) playEpisode(focusedItem());
},
"toggle-select": (p) => {
if (p === EPS) {
const 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 len(pane: PaneId): number {
if (pane === FEEDS) return feedList().length;
if (pane === EPS) return episodes().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text>
</box>
<scrollbox
height="100%"
focused={isActive(FEEDS)}
border
borderColor={border(FEEDS)}
backgroundColor={theme.background}
>
<Show
when={feedList().length > 1}
fallback={
<box padding={1}>
<text fg={muted()}>
No feeds. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={feedList()}>
{(item, index) => {
const label = () =>
item.kind === "all"
? "All Feeds"
: item.feed.customName || item.feed.podcast.title;
const count = () =>
item.kind === "all"
? feedStore.getAllEpisodesChronological().length
: item.feed.episodes.length;
return (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), FEEDS)}
onMouseDown={() => {
nav.setActivePane(FEEDS);
nav.setFocusedIndex(FEEDS, index());
}}
>
<text fg={focusFg(index(), FEEDS)}>
{index() === nav.focusedIndex(FEEDS) ? "" : " "}
</text>
<text fg={focusFg(index(), FEEDS)}>{label()}</text>
<text
fg={
index() === nav.focusedIndex(FEEDS)
? theme.surface
: muted()
}
>
({count()})
</text>
</box>
);
}}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 1 (current, center): episodes ─────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{(() => {
const fi = focusedFeedItem();
if (fi?.kind === "feed")
return fi.feed.customName || fi.feed.podcast.title;
return "All Episodes";
})()} · {episodes().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(EPS)}
border
borderColor={border(EPS)}
backgroundColor={theme.background}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(item, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), EPS)}
onMouseDown={() => {
nav.setActivePane(EPS);
nav.setFocusedIndex(EPS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), EPS)}>
{index() === nav.focusedIndex(EPS) ? "" : " "}
</text>
<text fg={focusFg(index(), EPS)}>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: theme.info
}
>
{formatDate(item.episode.pubDate)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{formatDuration(item.episode.duration)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{item.feed.customName || item.feed.podcast.title}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
)}
</For>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</scrollbox>
</box>
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREV)}
border
borderColor={border(PREV)}
backgroundColor={theme.background}
>
<Show
when={focusedItem()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>
{formatDate(item().episode.pubDate)}
</text>
<text fg={muted()}>
{formatDuration(item().episode.duration)}
</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play space: select h/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</box>
</box>
);
}
export const FeedPaneCount = 1;
const ITEMS_PER_BATCH = 50;
export function FeedPage() {
const feedStore = useFeedStore();
const nav = useNavigation();
const { theme } = useTheme();
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
string | undefined
>();
const allEpisodes = () => feedStore.getAllEpisodesChronological();
const keybind = useKeybinds();
const [focusedIndex, setFocusedIndex] = createSignal(0);
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 episodes = allEpisodes();
if (episodes.length > 0 && episodes[focusedIndex()]) {
setSelectedEpisodeID(episodes[focusedIndex()].episode.id);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== FeedPaneType.FEED) return;
const episodes = allEpisodes();
if (episodes.length === 0) return;
if (isDown && !isInverting()) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if (isUp && isInverting()) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
}
},
{ release: false },
);
});
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const groupEpisodesByDate = () => {
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {};
for (const item of allEpisodes()) {
const dateKey = formatDate(new Date(item.episode.pubDate));
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(item);
}
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => {
// Convert date strings back to Date objects for proper chronological sorting
const dateA = new Date(a);
const dateB = new Date(b);
// Sort in descending order (newest first)
return dateB.getTime() - dateA.getTime();
});
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
return (
<box
border
borderColor={
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent
}
backgroundColor={theme.background}
flexDirection="column"
height="100%"
width="100%"
>
<Show
when={allEpisodes().length > 0}
fallback={
<box padding={2}>
<text fg={theme.textMuted}>
No episodes yet. Subscribe to shows from Discover or Search.
</text>
</box>
}
>
<scrollbox
height="100%"
focused={nav.activeDepth() == FeedPaneType.FEED}
>
<For each={groupEpisodesByDate()}>
{([date, items]) => (
<box flexDirection="column" gap={1} padding={1}>
<SelectableText selected={() => false} primary>
{date}
</SelectableText>
<For each={items}>
{(item) => {
const isSelected = () => {
if (
nav.activeTab() == TABS.FEED &&
nav.activeDepth() == FeedPaneType.FEED &&
selectedEpisodeID() &&
selectedEpisodeID() === item.episode.id
) {
return true;
}
return false;
};
const isFocused = () => {
const episodes = allEpisodes();
const currentIndex = episodes.findIndex(
(e: any) => e.episode.id === item.episode.id,
);
return currentIndex === focusedIndex();
};
return (
<SelectableBox
selected={isSelected}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
onMouseDown={() => {
setSelectedEpisodeID(item.episode.id);
const episodes = allEpisodes();
setFocusedIndex(
episodes.findIndex((e: any) => e.episode.id === item.episode.id),
);
}}
>
<SelectableText selected={isSelected} primary>
{item.episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={isSelected} primary>
{item.feed.podcast.title}
</SelectableText>
<SelectableText selected={isSelected} tertiary>
{formatDuration(item.episode.duration)}
</SelectableText>
</box>
</SelectableBox>
);
}}
</For>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
);
}
export { FeedPage };

View File

@@ -1,325 +1,431 @@
/**
* MyShowsPage - Two-panel file-explorer style view
* Left panel: list of subscribed shows
* Right panel: episodes for the selected show
* MyShowsPage — yazi-style 3-pane view (canonical reference migration).
*
* pane 0 (parent) — subscribed shows
* pane 1 (current) — episodes of the focused show
* pane 2 (preview) — detail of the focused episode
*
* Movement (j/k, gg/G, page-jumps) and selection (space, v) are driven by the
* Shell router via the `nav.action` event bus; this page only subscribes and
* translates actions against its own data. h/l swipe between panes is handled
* by the Shell (nav.swipe). The focused row is read from nav.focusedIndex(pane)
* so the page is purely reactive.
*/
import { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useNavigation } from "@/context/NavigationContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} 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 { LoadingIndicator } from "@/components/LoadingIndicator";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum MyShowsPaneType {
SHOWS = 1,
EPISODES = 2,
}
export const MyShowsPaneCount = 2;
export const MyShowsPaneCount = 3;
export function MyShowsPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const [isRefreshing, setIsRefreshing] = createSignal(false);
const [showIndex, setShowIndex] = createSignal(0);
const [episodeIndex, setEpisodeIndex] = createSignal(0);
const { theme } = useTheme();
const mutedColor = () => theme.muted || theme.text;
const nav = useNavigation();
const keybind = useKeybinds();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
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);
const SHOWS = PaneSlot.PARENT;
const EPS = PaneSlot.CURRENT;
const PREV = PaneSlot.PREVIEW;
const shows = feedStore.getFilteredFeeds();
const episodesList = episodes();
const shows = () => feedStore.getFilteredFeeds();
if (isSelect) {
if (shows.length > 0 && showIndex() < shows.length) {
setShowIndex(showIndex() + 1);
}
if (episodesList.length > 0 && episodeIndex() < episodesList.length) {
setEpisodeIndex(episodeIndex() + 1);
}
return;
}
// The selected show tracks the focused row of pane 0.
const selectedShow = createMemo(() => {
const list = shows();
if (list.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1);
return list[idx];
});
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return;
const episodes = createMemo(() => {
const show = selectedShow();
if (!show) return [] as Episode[];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
if (episodesList.length > 0) {
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 },
);
});
// Register a resolver so visual-mode range selection grows by episode id.
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id);
// keep the resolver fresh as the episode list changes
const unsub = on("nav.action", () => {
nav.registerResolver(
`${nav.activeTab()}:${EPS}`,
(i) => episodes()[i]?.id,
);
});
onCleanup(() => unsub());
});
/** Threshold: load more when within this many items of the end */
const LOAD_MORE_THRESHOLD = 5;
// Keep shows-focus in range after feeds load/change.
const ensureShowsFocus = () => {
const list = shows();
if (list.length === 0) return;
const cur = nav.focusedIndex(SHOWS);
if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1);
};
onMount(ensureShowsFocus);
const shows = () => feedStore.getFilteredFeeds();
// When the show changes, reset episode focus + set audio-nav source + show count.
const onShowChanged = () => {
const show = selectedShow();
if (!show) return;
if (nav.focusedIndex(EPS) > episodes().length - 1)
nav.setFocusedIndex(EPS, 0);
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
};
onMount(onShowChanged);
const selectedShow = createMemo(() => {
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation
});
const focusedEpisode = createMemo(() => {
const eps = episodes();
if (eps.length === 0) return undefined;
const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1);
return eps[idx];
});
const episodes = createMemo(() => {
const show = selectedShow();
if (!show) return [];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
// ── helpers ─────────────────────────────────────────────────────────────────
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 formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
// ── nav.action handler ──────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === SHOWS) {
nav.swipe(1, MyShowsPaneCount);
onShowChanged();
} else if (p === EPS) {
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
},
"toggle-select": (p) => {
if (p === EPS) {
const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id);
}
},
refresh: () => {
const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {});
},
};
/** Get download status label for an episode */
const downloadLabel = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING: {
const pct = downloadStore.getDownloadProgress(episodeId);
return `[${pct}%]`;
}
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
function len(pane: PaneId): number {
if (pane === SHOWS) return shows().length;
if (pane === EPS) return episodes().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
if (pane === SHOWS) {
// clamp episode focus + re-resolve after show change
nav.setFocusedIndex(
EPS,
Math.min(nav.focusedIndex(EPS), Math.max(0, episodes().length - 1)),
);
onShowChanged();
}
}
const handleRefresh = async () => {
const show = selectedShow();
if (!show) return;
setIsRefreshing(true);
await feedStore.refreshFeed(show.id);
setIsRefreshing(false);
};
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
// Only react when our tab is active.
// (Shell always emits; router guarantees our tab is active.)
ensureShowsFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
// visual selection growth is handled inside nav.move/registerResolver
};
const handleUnsubscribe = () => {
const show = selectedShow();
if (!show) return;
feedStore.removeFeed(show.id);
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
/** Get download status color */
const downloadColor = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return theme.warning.toString();
case DownloadStatus.DOWNLOADING:
return theme.primary.toString();
case DownloadStatus.COMPLETED:
return theme.success.toString();
case DownloadStatus.FAILED:
return theme.error.toString();
default:
return mutedColor().toString();
}
};
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
return (
<box flexDirection="row" flexGrow={1} width="100%">
<box flexDirection="column" height="100%">
<Show when={isRefreshing()}>
<text fg={theme.warning}>Refreshing...</text>
</Show>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={theme.muted}>
No shows yet. Subscribe from Discover or Search.
</text>
</box>
}
>
<scrollbox
border
height="100%"
borderColor={
nav.activeDepth() == MyShowsPaneType.SHOWS
? theme.accent
: theme.border
}
focused={nav.activeDepth() == MyShowsPaneType.SHOWS}
>
<For each={shows()}>
{(feed, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={
index() === showIndex() ? theme.primary : undefined
}
onMouseDown={() => {
setShowIndex(index());
setEpisodeIndex(0);
audioNav.setSource(
AudioSource.MY_SHOWS,
selectedShow()?.podcast.id,
);
}}
>
<text
fg={index() === showIndex() ? theme.surface : theme.text}
>
{index() === showIndex() ? ">" : " "}
</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>
);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: shows ─────────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
</box>
<scrollbox
height="100%"
focused={isActive(SHOWS)}
border
borderColor={border(SHOWS)}
backgroundColor={theme.background}
>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), SHOWS)}
onMouseDown={() => {
nav.setActivePane(SHOWS);
nav.setFocusedIndex(SHOWS, index());
onShowChanged();
}}
>
<text fg={focusFg(index(), SHOWS)}>
{index() === nav.focusedIndex(SHOWS) ? "" : " "}
</text>
<text fg={focusFg(index(), SHOWS)}>
{feed.customName || feed.podcast.title}
</text>
<text
fg={
index() === nav.focusedIndex(SHOWS)
? theme.surface
: muted()
}
>
({feed.episodes.length})
</text>
</box>
)}
</For>
</Show>
</scrollbox>
</box>
{/* ── pane 1: episodes ──────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>
{selectedShow()?.customName ||
selectedShow()?.podcast.title ||
"Episodes"}{" "}
· {episodes().length}
</text>
</box>
<scrollbox
height="100%"
focused={isActive(EPS)}
border
borderColor={border(EPS)}
backgroundColor={theme.background}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(ep, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), EPS)}
onMouseDown={() => {
nav.setActivePane(EPS);
nav.setFocusedIndex(EPS, index());
}}
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), EPS)}>
{index() === nav.focusedIndex(EPS) ? "" : " "}
</text>
<text fg={focusFg(index(), EPS)}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: theme.info
}
>
{formatDate(ep.pubDate)}
</text>
<text
fg={
index() === nav.focusedIndex(EPS)
? theme.surface
: muted()
}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text fg={theme.warning}></text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
)}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
</Show>
</scrollbox>
</box>
{/* ── pane 2: preview ───────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Preview</text>
</box>
<scrollbox
height="100%"
focused={isActive(PREV)}
border
borderColor={border(PREV)}
backgroundColor={theme.background}
>
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ??
"No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play space: select h/l: panes</text>
</box>
)}
</Show>
</scrollbox>
</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 { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext";
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 function PlayerPage() {
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const muted = () => theme.muted || theme.text;
const keybind = useKeybinds();
// Single pane — always active.
const isActive = () => true;
const border = () => theme.accent;
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isInverting = keybind.isInverting(keyEvent);
const progressPercent = () => {
const d = audio.duration();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
if (keybind.match("audio-toggle", keyEvent)) {
audio.togglePlayback();
return;
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
if (keybind.match("audio-seek-forward", keyEvent)) {
audio.seek(audio.currentEpisode()?.duration ?? 0);
return;
}
return (
<box flexDirection="column" width="100%" height="100%">
{/* ── 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)) {
audio.seek(0);
return;
}
},
{ release: false },
);
});
<Show when={audio.error()}>
{(err) => <text fg={theme.error}>{err()}</text>}
</Show>
const progressPercent = () => {
const d = audio.duration();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
<Show
when={audio.currentEpisode()}
fallback={
<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) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
)}
</Show>
return (
<box flexDirection="column" gap={1} width="100%">
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={theme.muted}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</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)}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
{audio.error() && <text fg={theme.error}>{audio.error()}</text>}
<box
border
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border}
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>
);
<box height={1} />
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
</box>
</scrollbox>
</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 { useKeyboard } from "@opentui/solid";
import {
createSignal,
createMemo,
createEffect,
For,
Show,
onMount,
onCleanup,
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults";
import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "@/types/source";
import { MyShowsPage } from "../MyShows/MyShowsPage";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import {
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 function SearchPage() {
const searchStore = useSearchStore();
const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0);
const [historyIndex, setHistoryIndex] = createSignal(0);
const { theme } = useTheme();
const nav = useNavigation();
const keybind = useKeybinds();
function SearchPage() {
const searchStore = useSearchStore();
const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
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);
const INPUT = PaneSlot.PARENT; // 0
const RESULTS = PaneSlot.CURRENT; // 1
const DETAIL = PaneSlot.PREVIEW; // 2
if (isSelect) {
const results = searchStore.results();
if (results.length > 0 && resultIndex() < results.length) {
setResultIndex(resultIndex() + 1);
}
return;
}
const results = () => searchStore.results();
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== SearchPaneType.RESULTS) return;
// The focused result tracks pane 1's focused row.
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();
if (results.length === 0) return;
// Register a resolver so visual-mode range selection grows by result id.
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()) {
setResultIndex((i) => (i + 1) % results.length);
} else if (isUp && isInverting()) {
setResultIndex((i) => (i - 1 + results.length) % results.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setResultIndex((i) => (i + 1) % results.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setResultIndex((i) => (i - 1 + results.length) % results.length);
}
},
{ release: false },
);
});
// Keep results focus in range after searches complete.
const ensureFocus = () => {
const list = results();
if (list.length === 0) return;
const cur = nav.focusedIndex(RESULTS);
if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1);
};
onMount(ensureFocus);
const handleSearch = async () => {
const query = inputValue().trim();
if (query) {
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
}
};
// ── input pane: set inputFocused so Shell router yields keys to <input> ─────
createEffect(() => {
const isInputPane = nav.activePane() === INPUT;
nav.setInputFocused(isInputPane);
});
onMount(() => {
onCleanup(() => nav.setInputFocused(false));
});
const handleHistorySelect = async (query: string) => {
setInputValue(query);
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
};
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const handleResultSelect = (result: SearchResult) => {
//props.onSubscribe?.(result);
searchStore.markSubscribed(result.podcast.id);
};
const handleSubmit = () => {
const query = inputValue().trim();
if (!query) return;
searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
};
return (
<box flexDirection="column" height="100%" gap={1} width="100%">
{/* Search Header */}
<box flexDirection="column" gap={1}>
<text fg={theme.text}>
<strong>Search Podcasts</strong>
</text>
const handleHistorySelect = (query: string) => {
setInputValue(query);
searchStore.search(query).catch(() => {});
nav.setFocusedIndex(RESULTS, 0);
nav.setActivePane(RESULTS);
};
{/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center">
<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>
const handleSubscribe = (result: SearchResult) => {
searchStore.markSubscribed(result.podcast.id);
};
{/* Status */}
<Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
</Show>
</box>
// ── nav.action handler ──────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === 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 */}
<box flexDirection="row" height="100%" gap={2}>
{/* Results Panel */}
<box
flexDirection="column"
flexGrow={1}
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>
function len(pane: PaneId): number {
if (pane === RESULTS) return results().length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
{/* History Sidebar */}
<box width={30} border borderColor={theme.border}>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text
fg={
nav.activeDepth() === SearchPaneType.HISTORY
? theme.primary
: theme.muted
}
>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={nav.activeDepth() === SearchPaneType.HISTORY}
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory}
onChange={setHistoryIndex}
/>
</box>
</box>
</box>
</box>
);
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
ensureFocus();
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
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,119 +1,185 @@
import { createSignal, For, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
/**
* SettingsPage — yazi-style 2-pane view.
*
* pane 0 (parent) — section list (Sync, Sources, Preferences, ...)
* pane 1 (current) — active panel for the focused section
*
* Movement (j/k, gg/G, page-jumps) on pane 0 navigates the section list.
* The panel (pane 1) reactively shows the focused section's content.
* Audio transport and tab/pane swipes are handled by the Shell router.
*/
import { For, Show, onMount, onCleanup } from "solid-js";
import { SourceManager } from "./SourceManager";
import { useTheme } from "@/context/ThemeContext";
import { PreferencesPanel } from "./PreferencesPanel";
import { SyncPanel } from "./SyncPanel";
import { VisualizerSettings } from "./VisualizerSettings";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
import { useTheme } from "@/context/ThemeContext";
import {
useNavigation,
NavMode,
PaneSlot,
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PANE_RATIO } from "@/utils/navigation";
enum SettingsPaneType {
SYNC = 1,
SOURCES = 2,
PREFERENCES = 3,
VISUALIZER = 4,
ACCOUNT = 5,
}
export const SettingsPaneCount = 5;
export const SettingsPaneCount = 2;
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
{ id: SettingsPaneType.SYNC, label: "Sync" },
{ id: SettingsPaneType.SOURCES, label: "Sources" },
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" },
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
{ id: SettingsPaneType.ACCOUNT, label: "Account" },
];
const SECTIONS = [
{ id: 0, label: "Sync" },
{ id: 1, label: "Sources" },
{ id: 2, label: "Preferences" },
{ id: 3, label: "Visualizer" },
{ id: 4, label: "Account" },
] as const;
export function SettingsPage() {
const { theme } = useTheme();
const nav = useNavigation();
const keybind = useKeybinds();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
// Helper function to check if a depth is active
const isActive = (depth: SettingsPaneType): boolean => {
return nav.activeDepth() === depth;
};
const SECTIONS_PANE = PaneSlot.PARENT; // 0
const PANEL = PaneSlot.CURRENT; // 1
// Helper function to get the current depth as a number
const currentDepth = () => nav.activeDepth() as number;
// The focused section tracks pane 0's focused index.
const focusedSection = () => {
const idx = nav.focusedIndex(SECTIONS_PANE);
return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0];
};
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);
// Register a resolver so visual-mode range selection grows by section id.
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) =>
SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(),
);
});
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return;
// ── nav.action handler ──────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<
Record<KeybindActionName, (pane: PaneId) => void>
> = {
"move-down": (p) => step(p, 1),
"move-up": (p) => step(p, -1),
"jump-down": (p) => step(p, 5),
"jump-up": (p) => step(p, -5),
"page-down": (p) => step(p, 10),
"page-up": (p) => step(p, -10),
"goto-top": (p) => nav.gotoIndex(0, len(p)),
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
open: (p) => {
if (p === SECTIONS_PANE) {
nav.swipe(1, SettingsPaneCount);
}
},
};
if (isDown && !isInverting()) {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
} else if (isUp && isInverting()) {
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
}
},
{ release: false },
);
});
function len(pane: PaneId): number {
if (pane === SECTIONS_PANE) return SECTIONS.length;
return 0;
}
function step(pane: PaneId, delta: number) {
nav.move(delta, len(pane));
}
return (
<box flexDirection="column" gap={1} height="100%" width="100%">
<box flexDirection="row" gap={1}>
<For each={SECTIONS}>
{(section, index) => (
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
currentDepth() === section.id ? theme.primary : undefined
}
onMouseDown={() => nav.setActiveDepth(section.id)}
>
<text
fg={
currentDepth() === section.id ? theme.text : theme.textMuted
}
>
[{index() + 1}] {section.label}
</text>
</box>
)}
</For>
</box>
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
const handler = PAGE_ACTIONS[data.action];
if (handler) handler(data.pane);
};
<box
border
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border}
flexGrow={1}
padding={1}
flexDirection="column"
gap={1}
>
{isActive(SettingsPaneType.SYNC) && <SyncPanel />}
{isActive(SettingsPaneType.SOURCES) && (
<SourceManager focused />
)}
{isActive(SettingsPaneType.PREFERENCES) && (
<PreferencesPanel />
)}
{isActive(SettingsPaneType.VISUALIZER) && (
<VisualizerSettings />
)}
{isActive(SettingsPaneType.ACCOUNT) && (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Account</text>
</box>
)}
</box>
</box>
);
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = (p: PaneId) => nav.activePane() === p;
const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border);
const focusBg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane)
? theme.primary
: i === nav.focusedIndex(pane)
? theme.border
: undefined;
const focusFg = (i: number, pane: PaneId) =>
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── pane 0: sections ─────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>Settings</text>
</box>
<scrollbox
height="100%"
focused={isActive(SECTIONS_PANE)}
border
borderColor={border(SECTIONS_PANE)}
backgroundColor={theme.background}
>
<For each={SECTIONS}>
{(section, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), SECTIONS_PANE)}
onMouseDown={() => {
nav.setActivePane(SECTIONS_PANE);
nav.setFocusedIndex(SECTIONS_PANE, index());
}}
>
<text fg={focusFg(index(), SECTIONS_PANE)}>
{index() === nav.focusedIndex(SECTIONS_PANE) ? "" : " "}
</text>
<text fg={focusFg(index(), SECTIONS_PANE)}>
{section.label}
</text>
</box>
)}
</For>
</scrollbox>
</box>
{/* ── pane 1: panel ─────────────────────────────────────────────────────── */}
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
<text fg={theme.textSecondary}>{focusedSection().label}</text>
</box>
<scrollbox
height="100%"
focused={isActive(PANEL)}
border
borderColor={border(PANEL)}
backgroundColor={theme.background}
>
<Show when={focusedSection().id === 0}>
<SyncPanel />
</Show>
<Show when={focusedSection().id === 1}>
<SourceManager focused />
</Show>
<Show when={focusedSection().id === 2}>
<PreferencesPanel />
</Show>
<Show when={focusedSection().id === 3}>
<VisualizerSettings />
</Show>
<Show when={focusedSection().id === 4}>
<box padding={1} flexDirection="column" gap={1}>
<text fg={muted()}>Account settings (not yet implemented)</text>
</box>
</Show>
</scrollbox>
</box>
</box>
);
}

View File

@@ -3,185 +3,201 @@
* Manages search state, history, and results
*/
import { createSignal } from "solid-js"
import { searchPodcasts } from "../utils/search"
import { useFeedStore } from "./feed"
import type { SearchResult } from "../types/source"
import { createSignal } from "solid-js";
import { searchPodcasts } from "../utils/search";
import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source";
const STORAGE_KEY = "podtui_search_history"
const MAX_HISTORY = 20
const STORAGE_KEY = "podtui_search_history";
const MAX_HISTORY = 20;
export interface SearchState {
query: string
isSearching: boolean
results: SearchResult[]
error: string | null
query: string;
isSearching: boolean;
results: SearchResult[];
error: string | null;
}
const CACHE_TTL = 1000 * 60 * 5
const CACHE_TTL = 1000 * 60 * 5;
/** Load search history from localStorage */
function loadHistory(): string[] {
if (typeof localStorage === "undefined") return []
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
if (typeof localStorage === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/** Save search history to localStorage */
function saveHistory(history: string[]): void {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
} catch {
// Ignore errors
}
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
} catch {
// Ignore errors
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore()
const [query, setQuery] = createSignal("")
const [isSearching, setIsSearching] = createSignal(false)
const [results, setResults] = createSignal<SearchResult[]>([])
const [error, setError] = createSignal<string | null>(null)
const [history, setHistory] = createSignal<string[]>(loadHistory())
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
const feedStore = useFeedStore();
const [query, setQuery] = createSignal("");
const [isSearching, setIsSearching] = createSignal(false);
const [results, setResults] = createSignal<SearchResult[]>([]);
const [error, setError] = createSignal<string | null>(null);
const [history, setHistory] = createSignal<string[]>(loadHistory());
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds()
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl))
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id))
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds();
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl));
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id));
return items.map((item) => ({
...item,
podcast: {
...item.podcast,
isSubscribed:
item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id),
},
}))
}
return items.map((item) => ({
...item,
podcast: {
...item.podcast,
isSubscribed:
item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id),
},
}));
};
/** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim()
if (!q) {
setResults([])
return
}
/** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim();
if (!q) {
setResults([]);
return;
}
setQuery(q)
setIsSearching(true)
setError(null)
setQuery(q);
setIsSearching(true);
setError(null);
// Add to history
addToHistory(q)
// Add to history
addToHistory(q);
try {
const sources = feedStore.sources()
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id)
const sourceIds = selectedSources().length > 0
? selectedSources()
: enabledSourceIds
try {
const sources = feedStore.sources();
const enabledSourceIds = sources
.filter((s) => s.enabled)
.map((s) => s.id);
const sourceIds =
selectedSources().length > 0 ? selectedSources() : enabledSourceIds;
const searchResults = await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
})
// Empty query guard already returned above; if there are no enabled
// 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))
} catch (e) {
setError("Search failed. Please try again.")
setResults([])
} finally {
setIsSearching(false)
}
}
const searchResults = await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
});
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
// 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
})
}
setResults(applySubscribedStatus(searchResults));
} catch (e) {
setError(
e instanceof Error && e.message
? e.message
: "Search failed. Please try again.",
);
setResults([]);
} finally {
setIsSearching(false);
}
};
/** Clear search history */
const clearHistory = () => {
setHistory([])
saveHistory([])
}
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
// 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 */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q)
saveHistory(updated)
return updated
})
}
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveHistory([]);
};
/** Clear results */
const clearResults = () => {
setResults([])
setQuery("")
setError(null)
}
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
saveHistory(updated);
return updated;
});
};
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
const matchesId = result.podcast.id === podcastId
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
}
}
return result
})
)
}
/** Clear results */
const clearResults = () => {
setResults([]);
setQuery("");
setError(null);
};
return {
// State
query,
isSearching,
results,
error,
history,
selectedSources,
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
const matchesId = result.podcast.id === podcastId;
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
if (matchesId || matchesUrl) {
return {
...result,
podcast: {
...result.podcast,
isSubscribed: true,
},
};
}
return result;
}),
);
};
// Actions
search,
setQuery,
clearResults,
clearHistory,
removeFromHistory,
setSelectedSources,
markSubscribed,
}
return {
// State
query,
isSearching,
results,
error,
history,
selectedSources,
// Actions
search,
setQuery,
clearResults,
clearHistory,
removeFromHistory,
setSelectedSources,
markSubscribed,
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {
if (!searchStoreInstance) {
searchStoreInstance = createSearchStore()
}
return searchStoreInstance
if (!searchStoreInstance) {
searchStoreInstance = createSearchStore();
}
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 type { EventHandler }
export type { EventHandler };
interface EventBusInstance {
on<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
emit<T = unknown>(event: string, data: T): void
clear(): void
on<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;
emit<T = unknown>(event: string, data: T): void;
clear(): void;
}
function createEventBus(): EventBusInstance {
const handlers = new Map<string, Set<EventHandler>>()
const handlers = new Map<string, Set<EventHandler>>();
return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) {
handlers.set(event, new Set())
}
handlers.get(event)!.add(handler as EventHandler)
return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) {
handlers.set(event, new Set());
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler)
}
},
// Return unsubscribe function
return () => {
this.off(event, handler);
};
},
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler)
handler(data)
}
return this.on(event, wrappedHandler)
},
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler);
handler(data);
};
return this.on(event, wrappedHandler);
},
off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event)
if (eventHandlers) {
eventHandlers.delete(handler as EventHandler)
if (eventHandlers.size === 0) {
handlers.delete(event)
}
}
},
off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event);
if (eventHandlers) {
eventHandlers.delete(handler as EventHandler);
if (eventHandlers.size === 0) {
handlers.delete(event);
}
}
},
emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event)
if (eventHandlers) {
for (const handler of eventHandlers) {
try {
handler(data)
} catch (error) {
console.error(`Error in event handler for "${event}":`, error)
}
}
}
},
emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event);
if (eventHandlers) {
for (const handler of eventHandlers) {
try {
handler(data);
} catch (error) {
console.error(`Error in event handler for "${event}":`, error);
}
}
}
},
clear(): void {
handlers.clear()
},
}
clear(): void {
handlers.clear();
},
};
}
// 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
export type AppEvents = {
"theme.changed": { theme: string; mode: "dark" | "light" }
"theme.mode.changed": { mode: "dark" | "light" }
"theme.reload": {}
"navigation.tab.changed": { tab: string; previousTab?: string }
"navigation.layer.changed": { depth: number; previousDepth: number }
"feed.subscribed": { feedId: string; feedUrl: string }
"feed.unsubscribed": { feedId: string }
"player.play": { episodeId: string }
"player.pause": { episodeId: string }
"player.stop": {}
"auth.login": { userId: string }
"auth.logout": {}
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number }
"dialog.open": { dialogId: string }
"dialog.close": { dialogId?: string }
"command.execute": { command: string; args?: unknown }
"clipboard.copied": { text: string }
"selection.start": { x: number; y: number }
"selection.end": { text: string }
"theme.changed": { theme: string; mode: "dark" | "light" };
"theme.mode.changed": { mode: "dark" | "light" };
"theme.reload": {};
"navigation.tab.changed": { tab: string; previousTab?: string };
"navigation.layer.changed": { depth: number; previousDepth: number };
"feed.subscribed": { feedId: string; feedUrl: string };
"feed.unsubscribed": { feedId: string };
"player.play": { episodeId: string };
"player.pause": { episodeId: string };
"player.stop": {};
"auth.login": { userId: string };
"auth.logout": {};
"toast.show": {
message: string;
variant: "info" | "success" | "warning" | "error";
title?: string;
duration?: number;
};
"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)
"media.toggle": {}
"media.volumeUp": {}
"media.volumeDown": {}
"media.seekForward": {}
"media.seekBackward": {}
"media.speedCycle": {}
}
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
"media.toggle": {};
"media.volumeUp": {};
"media.volumeDown": {};
"media.seekForward": {};
"media.seekBackward": {};
"media.speedCycle": {};
};
// Type-safe emit and on functions
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void {
EventBus.emit(event, data)
export function emit<K extends keyof AppEvents>(
event: K,
data: AppEvents[K],
): void {
EventBus.emit(event, data);
}
export function on<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
event: K,
handler: EventHandler<AppEvents[K]>,
): () => void {
return EventBus.on(event, handler)
return EventBus.on(event, handler);
}
export function once<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
event: K,
handler: EventHandler<AppEvents[K]>,
): () => void {
return EventBus.once(event, handler)
return EventBus.once(event, handler);
}
export function off<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
event: K,
handler: EventHandler<AppEvents[K]>,
): void {
EventBus.off(event, handler)
EventBus.off(event, handler);
}

View File

@@ -1,90 +1,121 @@
/**
* 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.
*/
import { copyFile, mkdir } from "fs/promises";
import { copyFile } from "fs/promises";
import path from "path";
import { parseJSONC } from "./jsonc";
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
import type { KeybindsResolved } from "../context/KeybindContext";
const KEYBINDS_SOURCE = path.join(
process.cwd(),
"src",
"config",
"keybind.jsonc",
process.cwd(),
"src",
"config",
"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 = {
up: ["up", "k"],
down: ["down", "j"],
left: ["left", "h"],
right: ["right", "l"],
cycle: ["tab"],
dive: ["return"],
select: ["return"],
out: ["esc"],
inverseModifier: "shift",
leader: ":",
quit: ["<leader>q"],
"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"],
// movement
"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"],
"jump-up": ["K"],
"goto-top": [["g", "g"]],
"goto-bottom": ["G"],
// pane swipe
"swipe-prev": ["h", "left"],
"swipe-next": ["l", "right"],
// open / select
open: ["return", "enter"],
"open-interactive": ["shift-return"],
"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> {
try {
const targetPath = getConfigFilePath(KEYBINDS_FILE);
try {
const targetPath = getConfigFilePath(KEYBINDS_FILE);
// Check if file already exists
const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return;
// Check if file already exists
const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return;
await ensureConfigDir();
await copyFile(KEYBINDS_SOURCE, targetPath);
} catch {
// Silently ignore errors
}
await ensureConfigDir();
await copyFile(KEYBINDS_SOURCE, targetPath);
} catch {
// Silently ignore errors
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);
const file = Bun.file(filePath);
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);
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 parsed = parseJSONC(raw);
const raw = await file.text();
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;
} catch {
return DEFAULT_KEYBINDS;
}
// Merge so partial user configs inherit defaults for missing keys.
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
} catch {
return DEFAULT_KEYBINDS;
}
}
/** Save keybinds to JSONC file */
export async function saveKeybindsToFile(
keybinds: KeybindsResolved,
keybinds: KeybindsResolved,
): Promise<void> {
try {
await ensureConfigDir();
const filePath = getConfigFilePath(KEYBINDS_FILE);
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
} catch {
// Silently ignore write errors
}
try {
await ensureConfigDir();
const filePath = getConfigFilePath(KEYBINDS_FILE);
await Bun.write(filePath, JSON.stringify(keybinds, null, 2));
} catch {
// Silently ignore write errors
}
}

View File

@@ -6,33 +6,55 @@ import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage";
import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage";
export enum DIRECTION {
Increment,
Decrement,
Increment,
Decrement,
}
export enum TABS {
FEED = 1,
MYSHOWS = 2,
DISCOVER = 3,
SEARCH = 4,
PLAYER = 5,
SETTINGS = 6,
FEED = 1,
MYSHOWS = 2,
DISCOVER = 3,
SEARCH = 4,
PLAYER = 5,
SETTINGS = 6,
}
export const TabsCount = 6;
export const LayerGraph = {
[TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage,
[TABS.FEED]: FeedPage,
[TABS.MYSHOWS]: MyShowsPage,
[TABS.DISCOVER]: DiscoverPage,
[TABS.SEARCH]: SearchPage,
[TABS.PLAYER]: PlayerPage,
[TABS.SETTINGS]: SettingsPage,
};
export const LayerDepths = {
[TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount,
[TABS.SETTINGS]: SettingsPaneCount,
[TABS.FEED]: FeedPaneCount,
[TABS.MYSHOWS]: MyShowsPaneCount,
[TABS.DISCOVER]: DiscoverPaneCount,
[TABS.SEARCH]: SearchPaneCount,
[TABS.PLAYER]: PlayerPaneCount,
[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 (for the yazi h/l swipe). Slots beyond
// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing
// the old NavigationContext top-level-init circular deadlock.
export const TabPaneCount: Record<TABS, number> = {
[TABS.FEED]: 3, // feeds | episodes | preview
[TABS.MYSHOWS]: 3, // shows | episodes | preview
[TABS.DISCOVER]: 3, // categories | results | detail
[TABS.SEARCH]: 3, // query | results | detail
[TABS.PLAYER]: 1, // single pane
[TABS.SETTINGS]: 2, // sections | panel
};

View File

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

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"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "tests/**/*"]
"include": ["src/**/*", "tests/**/*", "scripts/**/*"]
}