start revive
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,3 +32,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
.harness/
|
||||
|
||||
573
scripts/tui-harness.tsx
Normal file
573
scripts/tui-harness.tsx
Normal 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);
|
||||
});
|
||||
257
src/App.tsx
257
src/App.tsx
@@ -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
586
src/components/Shell.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
73
src/config/keybinds.jsonc
Normal 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+,)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
131
tests/keybind-matcher.test.ts
Normal file
131
tests/keybind-matcher.test.ts
Normal 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);
|
||||
@@ -12,8 +12,8 @@
|
||||
"types": ["bun-types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
"include": ["src/**/*", "tests/**/*", "scripts/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user