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
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.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);
|
||||||
|
});
|
||||||
151
src/App.tsx
151
src/App.tsx
@@ -1,137 +1,60 @@
|
|||||||
import { createMemo, ErrorBoundary, Accessor } from "solid-js";
|
import { ErrorBoundary } from "solid-js";
|
||||||
import { useKeyboard, useSelectionHandler } from "@opentui/solid";
|
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
||||||
import { TabNavigation } from "./components/TabNavigation";
|
|
||||||
import { CodeValidation } from "@/components/CodeValidation";
|
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
||||||
import { FeedVisibility } from "@/types/feed";
|
|
||||||
import { Clipboard } from "@/utils/clipboard";
|
import { Clipboard } from "@/utils/clipboard";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { useRenderer } from "@opentui/solid";
|
|
||||||
import type { AuthScreen } from "@/types/auth";
|
|
||||||
import type { Episode } from "@/types/episode";
|
|
||||||
import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation";
|
|
||||||
import { useTheme, ThemeProvider } from "./context/ThemeContext";
|
import { useTheme, ThemeProvider } from "./context/ThemeContext";
|
||||||
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
|
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
|
||||||
import { NavigationProvider, useNavigation } from "./context/NavigationContext";
|
import {
|
||||||
import { useAudioNavStore, AudioSource } from "./stores/audio-nav";
|
NavigationProvider,
|
||||||
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
} from "./context/NavigationContext";
|
||||||
|
import { TABS } from "./utils/navigation";
|
||||||
|
import { Shell } from "./components/Shell";
|
||||||
|
|
||||||
const DEBUG = import.meta.env.DEBUG;
|
const DEBUG = import.meta.env.DEBUG;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const feedStore = useFeedStore();
|
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
const themeContext = useTheme();
|
const themeContext = useTheme();
|
||||||
const theme = themeContext.theme;
|
const theme = themeContext.theme;
|
||||||
|
|
||||||
// Create a reactive expression for background color
|
|
||||||
const backgroundColor = () => {
|
|
||||||
return themeContext.selected === "system"
|
|
||||||
? "transparent"
|
|
||||||
: themeContext.theme.surface;
|
|
||||||
};
|
|
||||||
const keybind = useKeybinds();
|
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({
|
useMultimediaKeys({
|
||||||
playerFocused: () =>
|
playerFocused: () =>
|
||||||
nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0,
|
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
|
||||||
|
? true
|
||||||
|
: false,
|
||||||
inputFocused: () => nav.inputFocused(),
|
inputFocused: () => nav.inputFocused(),
|
||||||
hasEpisode: () => !!audio.currentEpisode(),
|
hasEpisode: () => !!audio.currentEpisode(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePlayEpisode = (episode: Episode) => {
|
// Mouse text-selection → clipboard (unchanged from the old shell).
|
||||||
audio.play(episode);
|
|
||||||
nav.setActiveTab(TABS.PLAYER);
|
|
||||||
nav.setActiveDepth(1);
|
|
||||||
audioNav.setSource(AudioSource.FEED);
|
|
||||||
};
|
|
||||||
|
|
||||||
useSelectionHandler((selection: any) => {
|
useSelectionHandler((selection: any) => {
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
const text = selection.getSelectedText?.();
|
const text = selection.getSelectedText?.();
|
||||||
if (!text || text.trim().length === 0) return;
|
if (!text || text.trim().length === 0) return;
|
||||||
|
|
||||||
Clipboard.copy(text)
|
Clipboard.copy(text)
|
||||||
.then(() => {
|
.then(() =>
|
||||||
toast.show({ message: "Copied to Clipboard!", variant: "info" });
|
toast.show({ message: "Copied to Clipboard!", variant: "info" }),
|
||||||
})
|
)
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
.finally(() => {
|
.finally(() => renderer.clearSelection());
|
||||||
renderer.clearSelection();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useKeyboard(
|
const backgroundColor = () =>
|
||||||
(keyEvent) => {
|
themeContext.selected === "system"
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
? "transparent"
|
||||||
const isUp = keybind.match("up", keyEvent);
|
: themeContext.theme.surface;
|
||||||
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 (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -140,7 +63,7 @@ export function App() {
|
|||||||
<text fg={theme.error}>
|
<text fg={theme.error}>
|
||||||
Error: {err?.message ?? String(err)}
|
Error: {err?.message ?? String(err)}
|
||||||
{"\n"}
|
{"\n"}
|
||||||
Press a number key (1-6) to switch tabs.
|
Press 1-6 to switch tabs, or : to open the command bar.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
@@ -149,13 +72,8 @@ export function App() {
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={
|
backgroundColor={backgroundColor()}
|
||||||
themeContext.selected === "system"
|
|
||||||
? "transparent"
|
|
||||||
: themeContext.theme.surface
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<LoadingIndicator />
|
|
||||||
{DEBUG && (
|
{DEBUG && (
|
||||||
<box flexDirection="row" width="100%" height={1}>
|
<box flexDirection="row" width="100%" height={1}>
|
||||||
<text fg={theme.primary}>█</text>
|
<text fg={theme.primary}>█</text>
|
||||||
@@ -168,26 +86,9 @@ export function App() {
|
|||||||
<text fg={theme.text}>█</text>
|
<text fg={theme.text}>█</text>
|
||||||
<text fg={theme.textMuted}>█</text>
|
<text fg={theme.textMuted}>█</text>
|
||||||
<text fg={theme.surface}>█</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>
|
||||||
)}
|
)}
|
||||||
<box flexDirection="row" width="100%" height="100%">
|
<Shell />
|
||||||
<TabNavigation />
|
|
||||||
{LayerGraph[nav.activeTab()]()}
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
</ErrorBoundary>
|
</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,27 +1,26 @@
|
|||||||
|
import { For } from "solid-js";
|
||||||
import { shortcuts } from "@/config/shortcuts";
|
import { shortcuts } from "@/config/shortcuts";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
|
||||||
|
/** Yazi-style keybind reference. The Shell has its own overlay; this component
|
||||||
|
* is kept for embedding inside Settings or other surfaces. */
|
||||||
export function ShortcutHelp() {
|
export function ShortcutHelp() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box border title="Shortcuts" style={{ padding: 1 }}>
|
<box
|
||||||
|
border
|
||||||
|
title="Shortcuts"
|
||||||
|
style={{ flexDirection: "column", padding: 1 }}
|
||||||
|
>
|
||||||
<box style={{ flexDirection: "column" }}>
|
<box style={{ flexDirection: "column" }}>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<For each={shortcuts}>
|
||||||
<text fg={theme.text}>{shortcuts[0]?.keys ?? ""} </text>
|
{(s) => (
|
||||||
<text fg={theme.text}>{shortcuts[0]?.action ?? ""}</text>
|
<box style={{ flexDirection: "row" }} gap={2}>
|
||||||
</box>
|
<text fg={theme.accent}>{s.keys}</text>
|
||||||
<box style={{ flexDirection: "row" }}>
|
<text fg={theme.text}>{s.action}</text>
|
||||||
<text fg={theme.text}>{shortcuts[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>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</box>
|
</box>
|
||||||
</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 = [
|
export const shortcuts = [
|
||||||
{ keys: "Ctrl+Q", action: "Quit" },
|
{ keys: "j / k", action: "Move down / up (within pane)" },
|
||||||
{ keys: "Ctrl+S", action: "Save" },
|
{ keys: "h / l", action: "Swipe to prev / next pane" },
|
||||||
{ keys: "Left/Right", action: "Switch tabs" },
|
{ keys: "J / K", action: "Jump 5 lines down / up" },
|
||||||
{ keys: "Esc", action: "Close modal" },
|
{ keys: "ctrl-d / u", action: "Half page down / up" },
|
||||||
] as const
|
{ keys: "g g / G", action: "Go to top / bottom of list" },
|
||||||
|
{ keys: "1-6", action: "Go to tab 1-6" },
|
||||||
|
{ keys: "[ / ]", action: "Previous / next tab" },
|
||||||
|
{ keys: "Enter", action: "Open / activate focused item" },
|
||||||
|
{ keys: "Space", action: "Toggle selection on item" },
|
||||||
|
{ keys: "v", action: "Enter visual (range) select mode" },
|
||||||
|
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
|
||||||
|
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
||||||
|
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
||||||
|
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
||||||
|
{ keys: ", / .", action: "Sort / toggle hidden" },
|
||||||
|
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
||||||
|
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
||||||
|
{ keys: "~ / F1", action: "Help" },
|
||||||
|
{ keys: "q", action: "Quit" },
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -7,115 +7,347 @@ import {
|
|||||||
} from "../utils/keybinds-persistence";
|
} from "../utils/keybinds-persistence";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
|
|
||||||
export type KeybindsResolved = {
|
// ── Keybind model ───────────────────────────────────────────────────────────
|
||||||
up: string[];
|
// Yazi-style: every binding is one or more "strokes". A stroke is a single
|
||||||
down: string[];
|
// key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a
|
||||||
left: string[];
|
// sequence (e.g. ["g","g"] = gg, ["space","n"] = <leader>n). The matcher
|
||||||
right: string[];
|
// buffers keystrokes, prefers the longest matching sequence, and exposes the
|
||||||
cycle: string[]; // this will cycle no matter the depth/orientation
|
// pending buffer reactively so the status bar can show it (very yazi).
|
||||||
dive: string[];
|
|
||||||
out: string[];
|
|
||||||
inverseModifier: string;
|
|
||||||
leader: string; // will not trigger while focused on input
|
|
||||||
quit: string[];
|
|
||||||
select: string[]; // for selecting/activating items
|
|
||||||
"audio-toggle": string[];
|
|
||||||
"audio-pause": string[];
|
|
||||||
"audio-play": string[];
|
|
||||||
"audio-next": string[];
|
|
||||||
"audio-prev": string[];
|
|
||||||
"audio-seek-forward": string[];
|
|
||||||
"audio-seek-backward": string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum KeybindAction {
|
/** A single key press. `key` is the lowercase logical key name
|
||||||
UP,
|
* ("j", "return", "space", "up", "f1", ...). */
|
||||||
DOWN,
|
export interface Stroke {
|
||||||
LEFT,
|
key: string;
|
||||||
RIGHT,
|
ctrl?: boolean;
|
||||||
CYCLE,
|
shift?: boolean;
|
||||||
DIVE,
|
meta?: boolean;
|
||||||
OUT,
|
}
|
||||||
QUIT,
|
|
||||||
SELECT,
|
/** Raw config spec for one action: a list of alternative sequences. Each
|
||||||
AUDIO_TOGGLE,
|
* alternative is itself a list of stroke-notation strings. So
|
||||||
AUDIO_PAUSE,
|
* "j" -> [[ {key:"j"} ]]
|
||||||
AUDIO_PLAY,
|
* ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]]
|
||||||
AUDIO_NEXT,
|
* [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */
|
||||||
AUDIO_PREV,
|
export type KeybindSpec = string | (string | string[])[];
|
||||||
AUDIO_SEEK_F,
|
|
||||||
AUDIO_SEEK_B,
|
/** Canonical action names. Must match keys in keybinds.jsonc. */
|
||||||
|
export type KeybindActionName =
|
||||||
|
| "move-down"
|
||||||
|
| "move-up"
|
||||||
|
| "page-down"
|
||||||
|
| "page-up"
|
||||||
|
| "full-down"
|
||||||
|
| "full-up"
|
||||||
|
| "jump-down"
|
||||||
|
| "jump-up"
|
||||||
|
| "goto-top"
|
||||||
|
| "goto-bottom"
|
||||||
|
| "swipe-prev"
|
||||||
|
| "swipe-next"
|
||||||
|
| "open"
|
||||||
|
| "open-interactive"
|
||||||
|
| "toggle-select"
|
||||||
|
| "visual-mode"
|
||||||
|
| "toggle-all"
|
||||||
|
| "invert-all"
|
||||||
|
| "escape"
|
||||||
|
| "tab-prev"
|
||||||
|
| "tab-next"
|
||||||
|
| "tab-goto-1"
|
||||||
|
| "tab-goto-2"
|
||||||
|
| "tab-goto-3"
|
||||||
|
| "tab-goto-4"
|
||||||
|
| "tab-goto-5"
|
||||||
|
| "tab-goto-6"
|
||||||
|
| "command"
|
||||||
|
| "quit"
|
||||||
|
| "help"
|
||||||
|
| "search"
|
||||||
|
| "filter"
|
||||||
|
| "sort"
|
||||||
|
| "toggle-hidden"
|
||||||
|
| "refresh"
|
||||||
|
| "audio-toggle"
|
||||||
|
| "audio-next"
|
||||||
|
| "audio-prev"
|
||||||
|
| "audio-seek-forward"
|
||||||
|
| "audio-seek-backward"
|
||||||
|
// legacy compat (kept so older callers don't crash)
|
||||||
|
| "select"
|
||||||
|
| "leader"
|
||||||
|
| "inverseModifier"
|
||||||
|
| "cycle"
|
||||||
|
| "dive"
|
||||||
|
| "out"
|
||||||
|
| "up"
|
||||||
|
| "down"
|
||||||
|
| "left"
|
||||||
|
| "right"
|
||||||
|
| "audio-pause"
|
||||||
|
| "audio-play";
|
||||||
|
|
||||||
|
/** Resolved config: action -> list of alternative stroke-sequences. */
|
||||||
|
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
||||||
|
|
||||||
|
const SEQ_TIMEOUT_MS = 600;
|
||||||
|
|
||||||
|
// ── Stroke parsing ───────────────────────────────────────────────────────────
|
||||||
|
// Notation: "ctrl-d", "shift-j", "meta-x", "C-d", "M-x", "S-j".
|
||||||
|
// uppercase letter "G" => {key:"g", shift:true}
|
||||||
|
// special: return/enter/escape/tab/space/backspace/up/down/left/right
|
||||||
|
|
||||||
|
export function parseStroke(notation: string): Stroke {
|
||||||
|
const raw = notation.trim();
|
||||||
|
let ctrl = false,
|
||||||
|
shift = false,
|
||||||
|
meta = false;
|
||||||
|
let key = raw;
|
||||||
|
const parts = raw.split(/[-+]/);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
key = parts[parts.length - 1];
|
||||||
|
for (const mod of parts.slice(0, -1)) {
|
||||||
|
const m = mod.toLowerCase();
|
||||||
|
if (m === "ctrl" || m === "c") ctrl = true;
|
||||||
|
else if (m === "shift" || m === "s") shift = true;
|
||||||
|
else if (m === "meta" || m === "alt" || m === "m") meta = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Uppercase single letter w/o modifier => shift+letter (vim convention)
|
||||||
|
if (
|
||||||
|
parts.length === 1 &&
|
||||||
|
key.length === 1 &&
|
||||||
|
key >= "A" &&
|
||||||
|
key <= "Z" &&
|
||||||
|
!ctrl &&
|
||||||
|
!shift &&
|
||||||
|
!meta
|
||||||
|
) {
|
||||||
|
shift = true;
|
||||||
|
key = key.toLowerCase();
|
||||||
|
}
|
||||||
|
return { key: key.toLowerCase(), ctrl, shift, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn one raw spec into a list of alternative stroke-sequences. */
|
||||||
|
export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
|
||||||
|
if (spec == null) return [];
|
||||||
|
const alts: Stroke[][] = [];
|
||||||
|
const push = (item: string | string[]) => {
|
||||||
|
const seq = Array.isArray(item) ? item : [item];
|
||||||
|
alts.push(seq.map(parseStroke));
|
||||||
|
};
|
||||||
|
if (Array.isArray(spec)) {
|
||||||
|
for (const item of spec) push(item);
|
||||||
|
} else {
|
||||||
|
push(spec);
|
||||||
|
}
|
||||||
|
return alts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
||||||
|
export function strokeFromEvent(evt: {
|
||||||
|
name: string;
|
||||||
|
ctrl?: boolean;
|
||||||
|
meta?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
}): Stroke {
|
||||||
|
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
||||||
|
return {
|
||||||
|
key: (evt.name ?? "").toLowerCase(),
|
||||||
|
ctrl: !!evt.ctrl,
|
||||||
|
shift: !!evt.shift,
|
||||||
|
meta: !!evt.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function strokeEq(a: Stroke, b: Stroke): boolean {
|
||||||
|
return (
|
||||||
|
a.key === b.key &&
|
||||||
|
!!a.ctrl === !!b.ctrl &&
|
||||||
|
!!a.shift === !!b.shift &&
|
||||||
|
!!a.meta === !!b.meta
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A human label for a stroke, for the status bar / help. */
|
||||||
|
export function strokeLabel(s: Stroke): string {
|
||||||
|
let out = "";
|
||||||
|
if (s.ctrl) out += "C-";
|
||||||
|
if (s.meta) out += "M-";
|
||||||
|
if (s.shift) out += "S-";
|
||||||
|
out += s.key;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sequenceLabel(seq: Stroke[]): string {
|
||||||
|
return seq.map(strokeLabel).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useKeybinds, provider: KeybindProvider } =
|
export const { use: useKeybinds, provider: KeybindProvider } =
|
||||||
createSimpleContext({
|
createSimpleContext({
|
||||||
name: "Keybinds",
|
name: "Keybinds",
|
||||||
init: () => {
|
init: () => {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore<KeybindsResolved>({});
|
||||||
up: [],
|
// Resolved sequences per action, recomputed when store changes.
|
||||||
down: [],
|
const [resolved, setResolved] = createSignal<Record<string, Stroke[][]>>(
|
||||||
left: [],
|
{},
|
||||||
right: [],
|
);
|
||||||
cycle: [],
|
|
||||||
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);
|
const [ready, setReady] = createSignal(false);
|
||||||
|
const [pending, setPending] = createSignal<Stroke[]>([]);
|
||||||
|
|
||||||
|
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
await copyKeybindsIfNeeded();
|
await copyKeybindsIfNeeded();
|
||||||
const keybinds = await loadKeybindsFromFile();
|
const keybinds = await loadKeybindsFromFile();
|
||||||
setStore(keybinds);
|
setStore(keybinds);
|
||||||
|
recompute();
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saveKeybindsToFile(store);
|
saveKeybindsToFile(store as KeybindsResolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
function print(input: keyof KeybindsResolved): string {
|
function print(input: KeybindActionName): string {
|
||||||
const keys = store[input] || [];
|
const alts = resolved()[input] ?? [];
|
||||||
return Array.isArray(keys) ? keys.join(", ") : keys;
|
return alts.map(sequenceLabel).join(" / ") || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPending() {
|
||||||
|
if (pendingTimer) {
|
||||||
|
clearTimeout(pendingTimer);
|
||||||
|
pendingTimer = undefined;
|
||||||
|
}
|
||||||
|
if (pending().length > 0) setPending([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function armTimer() {
|
||||||
|
if (pendingTimer) clearTimeout(pendingTimer);
|
||||||
|
pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(
|
function match(
|
||||||
keybind: keyof KeybindsResolved,
|
name: KeybindActionName,
|
||||||
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
|
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
|
||||||
): boolean {
|
): boolean {
|
||||||
const keys = store[keybind];
|
const alts = resolved()[name] ?? [];
|
||||||
if (!keys) return false;
|
const s = strokeFromEvent(evt);
|
||||||
|
// skip in command/input mode unless explicitly handled by caller
|
||||||
for (const key of keys) {
|
for (const seq of alts) {
|
||||||
if (evt.name === key) return true;
|
if (seq.length === 1 && strokeEq(seq[0], s)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInverting(evt: {
|
/** 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;
|
name: string;
|
||||||
ctrl?: boolean;
|
ctrl?: boolean;
|
||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
shift?: boolean;
|
shift?: boolean;
|
||||||
}) {
|
}): KeybindActionName | null {
|
||||||
if (store.inverseModifier === "ctrl" && evt.ctrl) return true;
|
const stroke = strokeFromEvent(evt);
|
||||||
if (store.inverseModifier === "meta" && evt.meta) return true;
|
const candidate = [...pending(), stroke];
|
||||||
if (store.inverseModifier === "shift" && evt.shift) return true;
|
|
||||||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on mount
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load().catch(() => {});
|
load().catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -127,10 +359,17 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
get keybinds() {
|
get keybinds() {
|
||||||
return store;
|
return store;
|
||||||
},
|
},
|
||||||
save,
|
get resolved() {
|
||||||
print,
|
return resolved();
|
||||||
|
},
|
||||||
|
pending,
|
||||||
match,
|
match,
|
||||||
|
tryMatch,
|
||||||
isInverting,
|
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 { createSimpleContext } from "./helper";
|
||||||
import { TABS, TabsCount, LayerDepths } from "@/utils/navigation";
|
import { TABS, TabsCount } from "@/utils/navigation";
|
||||||
|
|
||||||
// Page-specific pane counts
|
// ── Yazi-style navigation state ──────────────────────────────────────────────
|
||||||
const PANE_COUNTS = {
|
// PodTui's interaction model after the yazi redesign. A single source of truth
|
||||||
[TABS.FEED]: 1,
|
// for: which tab is active, which pane within a tab is focused (parent |
|
||||||
[TABS.MYSHOWS]: 2,
|
// current | preview), the current mode (normal/visual/command/input), the
|
||||||
[TABS.DISCOVER]: 2,
|
// count register (for `5j` style motions), and the command-bar buffer.
|
||||||
[TABS.SEARCH]: 3,
|
//
|
||||||
[TABS.PLAYER]: 1,
|
// Panes are addressed by index 0..N-1 within the active tab. Each tab declares
|
||||||
[TABS.SETTINGS]: 5,
|
// 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 } =
|
export const { use: useNavigation, provider: NavigationProvider } =
|
||||||
createSimpleContext({
|
createSimpleContext({
|
||||||
name: "Navigation",
|
name: "Navigation",
|
||||||
init: () => {
|
init: () => {
|
||||||
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
|
||||||
const [activeDepth, setActiveDepth] = createSignal(0);
|
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);
|
const [inputFocused, setInputFocused] = createSignal(false);
|
||||||
|
|
||||||
|
// 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 [commandBuffer, setCommandBuffer] = createSignal("");
|
||||||
|
const [commandError, setCommandError] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
// Reset depth/pane/mode on tab change.
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(activeTab, () => {
|
||||||
() => activeTab,
|
batch(() => {
|
||||||
() => setActiveDepth(0),
|
setActivePane(PaneSlot.CURRENT);
|
||||||
),
|
setMode(NavMode.NORMAL);
|
||||||
|
setCount(null);
|
||||||
|
setCommandBuffer("");
|
||||||
|
setCommandError(null);
|
||||||
|
setVisualAnchor(null);
|
||||||
|
});
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextTab = () => {
|
// ── tab switching ──────────────────────────────────────────────────────
|
||||||
if (activeTab() >= TabsCount) {
|
const gotoTab = (tab: TABS) => {
|
||||||
setActiveTab(1);
|
if (tab < 1 || tab > TabsCount) return;
|
||||||
return;
|
setActiveTab(tab);
|
||||||
|
};
|
||||||
|
const nextTab = () =>
|
||||||
|
setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS)));
|
||||||
|
const prevTab = () =>
|
||||||
|
setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS)));
|
||||||
|
|
||||||
|
// ── pane focus ──────────────────────────────────────────────────────────
|
||||||
|
const setPane = (pane: PaneId) => setActivePane(pane);
|
||||||
|
|
||||||
|
/** Move focus to the adjacent pane. `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);
|
||||||
}
|
}
|
||||||
setActiveTab(activeTab() + 1);
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevTab = () => {
|
const gotoIndex = (index: number, listLen: number): number => {
|
||||||
if (activeTab() <= 1) {
|
if (listLen <= 0) return 0;
|
||||||
setActiveTab(TabsCount);
|
const pane = activePane();
|
||||||
return;
|
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);
|
||||||
}
|
}
|
||||||
setActiveTab(activeTab() - 1);
|
const key = anchor.paneKey;
|
||||||
|
setSelections((m) => ({ ...m, [key]: new Set(ids) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextPane = () => {
|
// ── modes ────────────────────────────────────────────────────────────────
|
||||||
// Move to next pane within the current tab's pane structure
|
const enterCommand = () => {
|
||||||
const count = PANE_COUNTS[activeTab()];
|
setMode(NavMode.COMMAND);
|
||||||
if (count <= 1) return; // No panes to navigate (feed/player)
|
setCommandBuffer("");
|
||||||
setActiveDepth((prev) => (prev % count) + 1);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevPane = () => {
|
// ── command buffer ───────────────────────────────────────────────────────
|
||||||
// Move to previous pane within the current tab's pane structure
|
const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch);
|
||||||
const count = PANE_COUNTS[activeTab()];
|
const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1));
|
||||||
if (count <= 1) return; // No panes to navigate (feed/player)
|
const submitCommand = (): string => {
|
||||||
setActiveDepth((prev) => (prev - 2 + count) % count + 1);
|
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 {
|
return {
|
||||||
activeTab,
|
activeTab,
|
||||||
activeDepth,
|
activePane,
|
||||||
|
mode,
|
||||||
|
count,
|
||||||
inputFocused,
|
inputFocused,
|
||||||
setActiveTab,
|
commandBuffer,
|
||||||
setActiveDepth,
|
commandError,
|
||||||
setInputFocused,
|
visualAnchor,
|
||||||
|
selections,
|
||||||
|
selectedIds,
|
||||||
|
// tab
|
||||||
|
setActiveTab: gotoTab,
|
||||||
nextTab,
|
nextTab,
|
||||||
prevTab,
|
prevTab,
|
||||||
nextPane,
|
// pane focus
|
||||||
prevPane,
|
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 { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { PodcastCard } from "./PodcastCard";
|
import {
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
useNavigation,
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
NavMode,
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
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 {
|
export const DiscoverPaneCount = 3;
|
||||||
CATEGORIES = 1,
|
|
||||||
SHOWS = 2,
|
|
||||||
}
|
|
||||||
export const DiscoverPaneCount = 2;
|
|
||||||
|
|
||||||
export function DiscoverPage() {
|
function DiscoverPage() {
|
||||||
const discoverStore = useDiscoverStore();
|
const discoverStore = useDiscoverStore();
|
||||||
const [showIndex, setShowIndex] = createSignal(0);
|
const { theme } = useTheme();
|
||||||
const [categoryIndex, setCategoryIndex] = createSignal(0);
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
onMount(() => {
|
const CATS = PaneSlot.PARENT; // 0 — categories (parent)
|
||||||
useKeyboard(
|
const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane)
|
||||||
(keyEvent: any) => {
|
const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe
|
||||||
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 categories = () => DISCOVER_CATEGORIES;
|
||||||
const filteredPodcasts = discoverStore.filteredPodcasts();
|
const podcasts = () => discoverStore.filteredPodcasts();
|
||||||
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
|
|
||||||
setShowIndex(showIndex() + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't handle pane navigation here - unified in App.tsx
|
const focusedCategory = createMemo(() => {
|
||||||
if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return;
|
const list = categories();
|
||||||
|
if (list.length === 0) return undefined;
|
||||||
const filteredPodcasts = discoverStore.filteredPodcasts();
|
return list[Math.min(nav.focusedIndex(CATS), list.length - 1)];
|
||||||
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) => {
|
// ── keep category + results focus in range ───────────────────────────────
|
||||||
discoverStore.setSelectedCategory(categoryId);
|
const ensureFocus = () => {
|
||||||
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
|
const cl = categories();
|
||||||
if (index >= 0) setCategoryIndex(index);
|
if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length)
|
||||||
setShowIndex(0);
|
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 handleShowSelect = (index: number) => {
|
const focusedPodcast = createMemo(() => {
|
||||||
setShowIndex(index);
|
const list = podcasts();
|
||||||
};
|
if (list.length === 0) return undefined;
|
||||||
|
return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)];
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubscribe = (podcast: { id: string }) => {
|
// 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);
|
discoverStore.toggleSubscription(podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { theme } = useTheme();
|
// ── nav.action handler ────────────────────────────────────────────────────
|
||||||
return (
|
const PAGE_ACTIONS: Partial<
|
||||||
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
|
Record<KeybindActionName, (pane: PaneId) => void>
|
||||||
<box
|
> = {
|
||||||
border
|
"move-down": (p) => step(p, 1),
|
||||||
padding={1}
|
"move-up": (p) => step(p, -1),
|
||||||
borderColor={
|
"jump-down": (p) => step(p, 5),
|
||||||
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
|
"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
|
? theme.border
|
||||||
: theme.accent
|
: undefined;
|
||||||
}
|
const focusFg = (i: number, pane: PaneId) =>
|
||||||
flexDirection="column"
|
i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text;
|
||||||
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 (
|
return (
|
||||||
<SelectableBox
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
selected={isSelected}
|
{/* ── pane 0 (parent, left): categories ───────────────────────────── */}
|
||||||
onMouseDown={() => handleCategorySelect(category.id)}
|
<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}
|
||||||
>
|
>
|
||||||
<SelectableText selected={isSelected} primary>
|
<For each={categories()}>
|
||||||
{category.icon} {category.name}
|
{(cat, index) => {
|
||||||
</SelectableText>
|
const selected = () =>
|
||||||
</SelectableBox>
|
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>
|
</For>
|
||||||
|
</scrollbox>
|
||||||
</box>
|
</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>
|
</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
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
flexGrow={1}
|
gap={0}
|
||||||
border
|
paddingLeft={1}
|
||||||
borderColor={
|
paddingRight={1}
|
||||||
nav.activeDepth() == DiscoverPagePaneType.SHOWS
|
backgroundColor={focusBg(index(), RESULTS)}
|
||||||
? theme.accent
|
onMouseDown={() => {
|
||||||
: theme.border
|
nav.setActivePane(RESULTS);
|
||||||
}
|
nav.setFocusedIndex(RESULTS, index());
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<box padding={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<SelectableText
|
<text fg={focusFg(index(), RESULTS)}>
|
||||||
selected={() => false}
|
{index() === nav.focusedIndex(RESULTS) ? "❯" : " "}
|
||||||
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>
|
</text>
|
||||||
)}
|
<text fg={focusFg(index(), RESULTS)}>{podcast.title}</text>
|
||||||
</box>
|
<Show when={podcast.isSubscribed}>
|
||||||
}
|
<text
|
||||||
when={
|
fg={
|
||||||
!discoverStore.isLoading() &&
|
index() === nav.focusedIndex(RESULTS)
|
||||||
discoverStore.filteredPodcasts().length === 0
|
? theme.surface
|
||||||
|
: theme.success
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<scrollbox
|
[+]
|
||||||
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
|
</text>
|
||||||
>
|
|
||||||
<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>
|
</Show>
|
||||||
</box>
|
</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>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { DiscoverPage };
|
||||||
|
|||||||
@@ -1,195 +1,460 @@
|
|||||||
/**
|
/**
|
||||||
* FeedPage - Shows latest episodes across all subscribed shows
|
* FeedPage — yazi-style 3-pane view of all episodes across subscribed shows.
|
||||||
* Reverse chronological order, grouped by date
|
*
|
||||||
|
* 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 { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import {
|
||||||
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
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 { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
|
||||||
|
|
||||||
enum FeedPaneType {
|
export const FeedPaneCount = 3;
|
||||||
FEED = 1,
|
|
||||||
}
|
|
||||||
export const FeedPaneCount = 1;
|
|
||||||
|
|
||||||
const ITEMS_PER_BATCH = 50;
|
type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed };
|
||||||
|
|
||||||
export function FeedPage() {
|
function FeedPage() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const nav = useNavigation();
|
const downloadStore = useDownloadStore();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
|
const muted = () => theme.muted || theme.text;
|
||||||
string | undefined
|
const nav = useNavigation();
|
||||||
>();
|
|
||||||
const allEpisodes = () => feedStore.getAllEpisodesChronological();
|
|
||||||
const keybind = useKeybinds();
|
|
||||||
const [focusedIndex, setFocusedIndex] = createSignal(0);
|
|
||||||
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
useKeyboard(
|
nav.registerResolver(
|
||||||
(keyEvent: any) => {
|
`${nav.activeTab()}:${EPS}`,
|
||||||
const isDown = keybind.match("down", keyEvent);
|
(i) => episodes()[i]?.episode.id,
|
||||||
const isUp = keybind.match("up", keyEvent);
|
);
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
const unsub = on("nav.action", () => {
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
nav.registerResolver(
|
||||||
const isInverting = keybind.isInverting(keyEvent);
|
`${nav.activeTab()}:${EPS}`,
|
||||||
|
(i) => episodes()[i]?.episode.id,
|
||||||
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 },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
onCleanup(() => unsub());
|
||||||
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 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
// ── nav.action handler ────────────────────────────────────────────────────
|
||||||
const mins = Math.floor(seconds / 60);
|
const PAGE_ACTIONS: Partial<
|
||||||
const hrs = Math.floor(mins / 60);
|
Record<KeybindActionName, (pane: PaneId) => void>
|
||||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
> = {
|
||||||
return `${mins}m`;
|
"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 (
|
return (
|
||||||
<box
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
border
|
{/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */}
|
||||||
borderColor={
|
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||||
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent
|
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||||
}
|
<text fg={theme.textSecondary}>Feeds · {feedList().length - 1}</text>
|
||||||
backgroundColor={theme.background}
|
</box>
|
||||||
flexDirection="column"
|
<scrollbox
|
||||||
height="100%"
|
height="100%"
|
||||||
width="100%"
|
focused={isActive(FEEDS)}
|
||||||
|
border
|
||||||
|
borderColor={border(FEEDS)}
|
||||||
|
backgroundColor={theme.background}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={allEpisodes().length > 0}
|
when={feedList().length > 1}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={2}>
|
<box padding={1}>
|
||||||
<text fg={theme.textMuted}>
|
<text fg={muted()}>
|
||||||
No episodes yet. Subscribe to shows from Discover or Search.
|
No feeds. Subscribe from Discover/Search.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</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
|
<scrollbox
|
||||||
height="100%"
|
height="100%"
|
||||||
focused={nav.activeDepth() == FeedPaneType.FEED}
|
focused={isActive(EPS)}
|
||||||
|
border
|
||||||
|
borderColor={border(EPS)}
|
||||||
|
backgroundColor={theme.background}
|
||||||
>
|
>
|
||||||
<For each={groupEpisodesByDate()}>
|
<Show
|
||||||
{([date, items]) => (
|
when={episodes().length > 0}
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
fallback={
|
||||||
<SelectableText selected={() => false} primary>
|
<box padding={1}>
|
||||||
{date}
|
<text fg={muted()}>No episodes. :refresh</text>
|
||||||
</SelectableText>
|
</box>
|
||||||
<For each={items}>
|
|
||||||
{(item) => {
|
|
||||||
const isSelected = () => {
|
|
||||||
if (
|
|
||||||
nav.activeTab() == TABS.FEED &&
|
|
||||||
nav.activeDepth() == FeedPaneType.FEED &&
|
|
||||||
selectedEpisodeID() &&
|
|
||||||
selectedEpisodeID() === item.episode.id
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
>
|
||||||
};
|
<For each={episodes()}>
|
||||||
const isFocused = () => {
|
{(item, index) => (
|
||||||
const episodes = allEpisodes();
|
<box
|
||||||
const currentIndex = episodes.findIndex(
|
|
||||||
(e: any) => e.episode.id === item.episode.id,
|
|
||||||
);
|
|
||||||
return currentIndex === focusedIndex();
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={isSelected}
|
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
paddingTop={0}
|
backgroundColor={focusBg(index(), EPS)}
|
||||||
paddingBottom={0}
|
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
setSelectedEpisodeID(item.episode.id);
|
nav.setActivePane(EPS);
|
||||||
const episodes = allEpisodes();
|
nav.setFocusedIndex(EPS, index());
|
||||||
setFocusedIndex(
|
|
||||||
episodes.findIndex((e: any) => e.episode.id === item.episode.id),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectableText selected={isSelected} primary>
|
<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}
|
{item.episode.title}
|
||||||
</SelectableText>
|
</text>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
</box>
|
||||||
<SelectableText selected={isSelected} primary>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
{item.feed.podcast.title}
|
<text
|
||||||
</SelectableText>
|
fg={
|
||||||
<SelectableText selected={isSelected} tertiary>
|
index() === nav.focusedIndex(EPS)
|
||||||
{formatDuration(item.episode.duration)}
|
? theme.surface
|
||||||
</SelectableText>
|
: 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>
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</scrollbox>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
{/* ── pane 2 (preview, right): episode detail ───────────────────────── */}
|
||||||
|
<box flexDirection="column" flexGrow={PANE_RATIO.preview} height="100%">
|
||||||
|
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||||
|
<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>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { FeedPage };
|
||||||
|
|||||||
@@ -1,119 +1,122 @@
|
|||||||
/**
|
/**
|
||||||
* MyShowsPage - Two-panel file-explorer style view
|
* MyShowsPage — yazi-style 3-pane view (canonical reference migration).
|
||||||
* Left panel: list of subscribed shows
|
*
|
||||||
* Right panel: episodes for the selected show
|
* 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 { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import {
|
||||||
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
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 { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
enum MyShowsPaneType {
|
export const MyShowsPaneCount = 3;
|
||||||
SHOWS = 1,
|
|
||||||
EPISODES = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MyShowsPaneCount = 2;
|
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const [isRefreshing, setIsRefreshing] = createSignal(false);
|
const audio = useAudio();
|
||||||
const [showIndex, setShowIndex] = createSignal(0);
|
|
||||||
const [episodeIndex, setEpisodeIndex] = createSignal(0);
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const mutedColor = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
onMount(() => {
|
const SHOWS = PaneSlot.PARENT;
|
||||||
useKeyboard(
|
const EPS = PaneSlot.CURRENT;
|
||||||
(keyEvent: any) => {
|
const PREV = PaneSlot.PREVIEW;
|
||||||
const isDown = keybind.match("down", keyEvent);
|
|
||||||
const isUp = keybind.match("up", keyEvent);
|
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
|
||||||
const isInverting = keybind.isInverting(keyEvent);
|
|
||||||
|
|
||||||
const shows = feedStore.getFilteredFeeds();
|
|
||||||
const episodesList = episodes();
|
|
||||||
|
|
||||||
if (isSelect) {
|
|
||||||
if (shows.length > 0 && showIndex() < shows.length) {
|
|
||||||
setShowIndex(showIndex() + 1);
|
|
||||||
}
|
|
||||||
if (episodesList.length > 0 && episodeIndex() < episodesList.length) {
|
|
||||||
setEpisodeIndex(episodeIndex() + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't handle pane navigation here - unified in App.tsx
|
|
||||||
if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return;
|
|
||||||
|
|
||||||
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 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Threshold: load more when within this many items of the end */
|
|
||||||
const LOAD_MORE_THRESHOLD = 5;
|
|
||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds();
|
const shows = () => feedStore.getFilteredFeeds();
|
||||||
|
|
||||||
|
// The selected show tracks the focused row of pane 0.
|
||||||
const selectedShow = createMemo(() => {
|
const selectedShow = createMemo(() => {
|
||||||
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation
|
const list = shows();
|
||||||
|
if (list.length === 0) return undefined;
|
||||||
|
const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1);
|
||||||
|
return list[idx];
|
||||||
});
|
});
|
||||||
|
|
||||||
const episodes = createMemo(() => {
|
const episodes = createMemo(() => {
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (!show) return [];
|
if (!show) return [] as Episode[];
|
||||||
return [...show.episodes].sort(
|
return [...show.episodes].sort(
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
// Register a resolver so visual-mode range selection grows by episode id.
|
||||||
return format(date, "MMM d, yyyy");
|
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());
|
||||||
|
});
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
// Keep shows-focus in range after feeds load/change.
|
||||||
const mins = Math.floor(seconds / 60);
|
const ensureShowsFocus = () => {
|
||||||
|
const list = shows();
|
||||||
|
if (list.length === 0) return;
|
||||||
|
const cur = nav.focusedIndex(SHOWS);
|
||||||
|
if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1);
|
||||||
|
};
|
||||||
|
onMount(ensureShowsFocus);
|
||||||
|
|
||||||
|
// When the show changes, reset episode focus + set audio-nav source + show count.
|
||||||
|
const onShowChanged = () => {
|
||||||
|
const show = selectedShow();
|
||||||
|
if (!show) return;
|
||||||
|
if (nav.focusedIndex(EPS) > episodes().length - 1)
|
||||||
|
nav.setFocusedIndex(EPS, 0);
|
||||||
|
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
|
||||||
|
};
|
||||||
|
onMount(onShowChanged);
|
||||||
|
|
||||||
|
const focusedEpisode = createMemo(() => {
|
||||||
|
const eps = episodes();
|
||||||
|
if (eps.length === 0) return undefined;
|
||||||
|
const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1);
|
||||||
|
return eps[idx];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
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);
|
const hrs = Math.floor(mins / 60);
|
||||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
||||||
return `${mins}m`;
|
|
||||||
};
|
};
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
/** Get download status label for an episode */
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
const downloadLabel = (episodeId: string): string => {
|
|
||||||
const status = downloadStore.getDownloadStatus(episodeId);
|
|
||||||
switch (status) {
|
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
return "[Q]";
|
return "[Q]";
|
||||||
case DownloadStatus.DOWNLOADING: {
|
case DownloadStatus.DOWNLOADING:
|
||||||
const pct = downloadStore.getDownloadProgress(episodeId);
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
return `[${pct}%]`;
|
|
||||||
}
|
|
||||||
case DownloadStatus.COMPLETED:
|
case DownloadStatus.COMPLETED:
|
||||||
return "[DL]";
|
return "[DL]";
|
||||||
case DownloadStatus.FAILED:
|
case DownloadStatus.FAILED:
|
||||||
@@ -122,65 +125,130 @@ export function MyShowsPage() {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
const handleRefresh = async () => {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
const show = selectedShow();
|
|
||||||
if (!show) return;
|
|
||||||
setIsRefreshing(true);
|
|
||||||
await feedStore.refreshFeed(show.id);
|
|
||||||
setIsRefreshing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnsubscribe = () => {
|
|
||||||
const show = selectedShow();
|
|
||||||
if (!show) return;
|
|
||||||
feedStore.removeFeed(show.id);
|
|
||||||
setShowIndex((i) => Math.max(0, i - 1));
|
|
||||||
setEpisodeIndex(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Get download status color */
|
|
||||||
const downloadColor = (episodeId: string): string => {
|
|
||||||
const status = downloadStore.getDownloadStatus(episodeId);
|
|
||||||
switch (status) {
|
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
return theme.warning.toString();
|
return theme.warning;
|
||||||
case DownloadStatus.DOWNLOADING:
|
case DownloadStatus.DOWNLOADING:
|
||||||
return theme.primary.toString();
|
return theme.primary;
|
||||||
case DownloadStatus.COMPLETED:
|
case DownloadStatus.COMPLETED:
|
||||||
return theme.success.toString();
|
return theme.success;
|
||||||
case DownloadStatus.FAILED:
|
case DownloadStatus.FAILED:
|
||||||
return theme.error.toString();
|
return theme.error;
|
||||||
default:
|
default:
|
||||||
return mutedColor().toString();
|
return muted();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playEpisode = (ep: Episode) => {
|
||||||
|
audio.play(ep).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
|
const PAGE_ACTIONS: Partial<
|
||||||
|
Record<KeybindActionName, (pane: PaneId) => void>
|
||||||
|
> = {
|
||||||
|
"move-down": (p) => step(p, 1),
|
||||||
|
"move-up": (p) => step(p, -1),
|
||||||
|
"jump-down": (p) => step(p, 5),
|
||||||
|
"jump-up": (p) => step(p, -5),
|
||||||
|
"page-down": (p) => step(p, 10),
|
||||||
|
"page-up": (p) => step(p, -10),
|
||||||
|
"goto-top": (p) => nav.gotoIndex(0, len(p)),
|
||||||
|
"goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)),
|
||||||
|
open: (p) => {
|
||||||
|
if (p === SHOWS) {
|
||||||
|
nav.swipe(1, MyShowsPaneCount);
|
||||||
|
onShowChanged();
|
||||||
|
} else if (p === EPS) {
|
||||||
|
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(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%">
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
<box flexDirection="column" height="100%">
|
{/* ── pane 0: shows ─────────────────────────────────────────────────────── */}
|
||||||
<Show when={isRefreshing()}>
|
<box flexDirection="column" flexGrow={PANE_RATIO.parent} height="100%">
|
||||||
<text fg={theme.warning}>Refreshing...</text>
|
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||||
</Show>
|
<text fg={theme.textSecondary}>Shows ({shows().length})</text>
|
||||||
|
</box>
|
||||||
|
<scrollbox
|
||||||
|
height="100%"
|
||||||
|
focused={isActive(SHOWS)}
|
||||||
|
border
|
||||||
|
borderColor={border(SHOWS)}
|
||||||
|
backgroundColor={theme.background}
|
||||||
|
>
|
||||||
<Show
|
<Show
|
||||||
when={shows().length > 0}
|
when={shows().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={theme.muted}>
|
<text fg={muted()}>
|
||||||
No shows yet. Subscribe from Discover or Search.
|
No shows. Subscribe from Discover/Search.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<scrollbox
|
|
||||||
border
|
|
||||||
height="100%"
|
|
||||||
borderColor={
|
|
||||||
nav.activeDepth() == MyShowsPaneType.SHOWS
|
|
||||||
? theme.accent
|
|
||||||
: theme.border
|
|
||||||
}
|
|
||||||
focused={nav.activeDepth() == MyShowsPaneType.SHOWS}
|
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => (
|
{(feed, index) => (
|
||||||
@@ -189,111 +257,107 @@ export function MyShowsPage() {
|
|||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={focusBg(index(), SHOWS)}
|
||||||
index() === showIndex() ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
setShowIndex(index());
|
nav.setActivePane(SHOWS);
|
||||||
setEpisodeIndex(0);
|
nav.setFocusedIndex(SHOWS, index());
|
||||||
audioNav.setSource(
|
onShowChanged();
|
||||||
AudioSource.MY_SHOWS,
|
|
||||||
selectedShow()?.podcast.id,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text
|
<text fg={focusFg(index(), SHOWS)}>
|
||||||
fg={index() === showIndex() ? theme.surface : theme.text}
|
{index() === nav.focusedIndex(SHOWS) ? "❯" : " "}
|
||||||
>
|
|
||||||
{index() === showIndex() ? ">" : " "}
|
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text fg={focusFg(index(), SHOWS)}>
|
||||||
fg={index() === showIndex() ? theme.surface : theme.text}
|
|
||||||
>
|
|
||||||
{feed.customName || feed.podcast.title}
|
{feed.customName || feed.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={index() === showIndex() ? undefined : theme.text}>
|
<text
|
||||||
|
fg={
|
||||||
|
index() === nav.focusedIndex(SHOWS)
|
||||||
|
? theme.surface
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
({feed.episodes.length})
|
({feed.episodes.length})
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="column" height="100%">
|
|
||||||
<Show
|
{/* ── pane 1: episodes ──────────────────────────────────────────────────── */}
|
||||||
when={selectedShow()}
|
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||||
fallback={
|
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||||
<box padding={1}>
|
<text fg={theme.textSecondary}>
|
||||||
<text fg={theme.muted}>Select a show</text>
|
{selectedShow()?.customName ||
|
||||||
|
selectedShow()?.podcast.title ||
|
||||||
|
"Episodes"}{" "}
|
||||||
|
· {episodes().length}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
<scrollbox
|
||||||
|
height="100%"
|
||||||
|
focused={isActive(EPS)}
|
||||||
|
border
|
||||||
|
borderColor={border(EPS)}
|
||||||
|
backgroundColor={theme.background}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={theme.muted}>No episodes. Press [r] to refresh.</text>
|
<text fg={muted()}>No episodes. :refresh</text>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<scrollbox
|
|
||||||
border
|
|
||||||
height="100%"
|
|
||||||
borderColor={
|
|
||||||
nav.activeDepth() == MyShowsPaneType.EPISODES
|
|
||||||
? theme.accent
|
|
||||||
: theme.border
|
|
||||||
}
|
|
||||||
focused={nav.activeDepth() == MyShowsPaneType.EPISODES}
|
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(episode, index) => (
|
{(ep, index) => (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={focusBg(index(), EPS)}
|
||||||
index() === episodeIndex() ? theme.primary : undefined
|
onMouseDown={() => {
|
||||||
}
|
nav.setActivePane(EPS);
|
||||||
onMouseDown={() => setEpisodeIndex(index())}
|
nav.setFocusedIndex(EPS, index());
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text
|
<text fg={focusFg(index(), EPS)}>
|
||||||
fg={
|
{index() === nav.focusedIndex(EPS) ? "❯" : " "}
|
||||||
index() === episodeIndex()
|
|
||||||
? theme.surface
|
|
||||||
: theme.text
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{index() === episodeIndex() ? ">" : " "}
|
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text fg={focusFg(index(), EPS)}>
|
||||||
fg={
|
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||||
index() === episodeIndex()
|
{ep.title}
|
||||||
? theme.surface
|
|
||||||
: theme.text
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{episode.episodeNumber
|
|
||||||
? `#${episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{episode.title}
|
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text
|
<text
|
||||||
fg={index() === episodeIndex() ? undefined : theme.info}
|
fg={
|
||||||
|
index() === nav.focusedIndex(EPS)
|
||||||
|
? theme.surface
|
||||||
|
: theme.info
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{formatDate(episode.pubDate)}
|
{formatDate(ep.pubDate)}
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.muted}>
|
<text
|
||||||
{formatDuration(episode.duration)}
|
fg={
|
||||||
|
index() === nav.focusedIndex(EPS)
|
||||||
|
? theme.surface
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatDuration(ep.duration)}
|
||||||
</text>
|
</text>
|
||||||
<Show when={downloadLabel(episode.id)}>
|
<Show when={nav.isSelected(ep.id)}>
|
||||||
<text fg={downloadColor(episode.id)}>
|
<text fg={theme.warning}>●</text>
|
||||||
{downloadLabel(episode.id)}
|
</Show>
|
||||||
|
<Show when={downloadLabel(ep.id)}>
|
||||||
|
<text fg={downloadColor(ep.id)}>
|
||||||
|
{downloadLabel(ep.id)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -305,20 +369,62 @@ export function MyShowsPage() {
|
|||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</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>
|
</Show>
|
||||||
</scrollbox>
|
</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>
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={selectedShow()?.podcast.author}>
|
||||||
|
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
||||||
</Show>
|
</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>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* PlayerPage — single-pane audio now-playing view.
|
||||||
|
*
|
||||||
|
* Audio transport (play/pause, next/prev, seek) is handled globally by the
|
||||||
|
* Shell router (P/N/B/</>). This page renders a single rich pane showing the
|
||||||
|
* current episode, waveform, and playback controls. Panes/swipe do nothing
|
||||||
|
* (PaneCount=1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
import { useKeybinds } from "@/context/KeybindContext";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { onMount } from "solid-js";
|
|
||||||
|
|
||||||
enum PlayerPaneType {
|
|
||||||
PLAYER = 1,
|
|
||||||
}
|
|
||||||
export const PlayerPaneCount = 1;
|
export const PlayerPaneCount = 1;
|
||||||
|
|
||||||
export function PlayerPage() {
|
export function PlayerPage() {
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
|
||||||
const keybind = useKeybinds();
|
// Single pane — always active.
|
||||||
|
const isActive = () => true;
|
||||||
onMount(() => {
|
const border = () => theme.accent;
|
||||||
useKeyboard(
|
|
||||||
(keyEvent: any) => {
|
|
||||||
const isInverting = keybind.isInverting(keyEvent);
|
|
||||||
|
|
||||||
if (keybind.match("audio-toggle", keyEvent)) {
|
|
||||||
audio.togglePlayback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keybind.match("audio-seek-forward", keyEvent)) {
|
|
||||||
audio.seek(audio.currentEpisode()?.duration ?? 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keybind.match("audio-seek-backward", keyEvent)) {
|
|
||||||
audio.seek(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ release: false },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const progressPercent = () => {
|
const progressPercent = () => {
|
||||||
const d = audio.duration();
|
const d = audio.duration();
|
||||||
@@ -57,30 +40,50 @@ export function PlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" gap={1} width="100%">
|
<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">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<strong>Now Playing</strong>
|
<strong>Now Playing</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.muted}>
|
<text fg={muted()}>
|
||||||
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
{formatTime(audio.position())} / {formatTime(audio.duration())} (
|
||||||
{progressPercent()}%)
|
{progressPercent()}%)
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{audio.error() && <text fg={theme.error}>{audio.error()}</text>}
|
<Show when={audio.error()}>
|
||||||
|
{(err) => <text fg={theme.error}>{err()}</text>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<box
|
<Show
|
||||||
border
|
when={audio.currentEpisode()}
|
||||||
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border}
|
fallback={
|
||||||
padding={1}
|
<box padding={1}>
|
||||||
flexDirection="column"
|
<text fg={muted()}>No episode loaded.</text>
|
||||||
gap={1}
|
</box>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{(ep) => (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
<strong>{audio.currentEpisode()?.title}</strong>
|
<strong>{ep().title}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{ep().description?.slice(0, 500) ??
|
||||||
|
"No description available."}
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.muted}>{audio.currentEpisode()?.description}</text>
|
|
||||||
|
|
||||||
<RealtimeWaveform
|
<RealtimeWaveform
|
||||||
visualizerConfig={(() => {
|
visualizerConfig={(() => {
|
||||||
@@ -94,6 +97,8 @@ export function PlayerPage() {
|
|||||||
})()}
|
})()}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
isPlaying={audio.isPlaying()}
|
isPlaying={audio.isPlaying()}
|
||||||
@@ -103,10 +108,15 @@ export function PlayerPage() {
|
|||||||
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
|
||||||
onToggle={audio.togglePlayback}
|
onToggle={audio.togglePlayback}
|
||||||
onPrev={() => audio.seek(0)}
|
onPrev={() => audio.seek(0)}
|
||||||
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows)
|
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
|
||||||
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
onSpeedChange={(s: number) => audio.setSpeed(s)}
|
||||||
onVolumeChange={(v: number) => audio.setVolume(v)}
|
onVolumeChange={(v: number) => audio.setVolume(v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>{"P play/pause N next B prev </ seek"}</text>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +1,264 @@
|
|||||||
/**
|
/**
|
||||||
* SearchPage component - Main search interface for PodTUI
|
* SearchPage — yazi-style 3-pane view.
|
||||||
|
*
|
||||||
|
* pane 0 (parent) — query input with recent-search history (clickable)
|
||||||
|
* pane 1 (current) — search results list (navigate j/k)
|
||||||
|
* pane 2 (preview) — detail of the focused search result
|
||||||
|
*
|
||||||
|
* The Shell resets activePane to CURRENT(1) on tab enter so the user lands on
|
||||||
|
* the results pane. Swipe left (h) to pane 0 to type a query — the Shell
|
||||||
|
* router skips keys while `nav.inputFocused()` is true so the `<input>`
|
||||||
|
* element captures typing natively. Press Enter (onSubmit) to search and
|
||||||
|
* auto-swipe to the results pane.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, Show, onMount } from "solid-js";
|
import {
|
||||||
import { useKeyboard } from "@opentui/solid";
|
createSignal,
|
||||||
|
createMemo,
|
||||||
|
createEffect,
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
onMount,
|
||||||
|
onCleanup,
|
||||||
|
} from "solid-js";
|
||||||
import { useSearchStore } from "@/stores/search";
|
import { useSearchStore } from "@/stores/search";
|
||||||
import { SearchResults } from "./SearchResults";
|
import { format } from "date-fns";
|
||||||
import { SearchHistory } from "./SearchHistory";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { MyShowsPage } from "../MyShows/MyShowsPage";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import {
|
||||||
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
|
useNavigation,
|
||||||
|
NavMode,
|
||||||
|
PaneSlot,
|
||||||
|
type PaneId,
|
||||||
|
} from "@/context/NavigationContext";
|
||||||
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import type { SearchResult } from "@/types/source";
|
||||||
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
enum SearchPaneType {
|
|
||||||
INPUT = 1,
|
|
||||||
RESULTS = 2,
|
|
||||||
HISTORY = 3,
|
|
||||||
}
|
|
||||||
export const SearchPaneCount = 3;
|
export const SearchPaneCount = 3;
|
||||||
|
|
||||||
export function SearchPage() {
|
function SearchPage() {
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
const [resultIndex, setResultIndex] = createSignal(0);
|
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(0);
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
onMount(() => {
|
const INPUT = PaneSlot.PARENT; // 0
|
||||||
useKeyboard(
|
const RESULTS = PaneSlot.CURRENT; // 1
|
||||||
(keyEvent: any) => {
|
const DETAIL = PaneSlot.PREVIEW; // 2
|
||||||
const isDown = keybind.match("down", keyEvent);
|
|
||||||
const isUp = keybind.match("up", keyEvent);
|
|
||||||
const isCycle = keybind.match("cycle", keyEvent);
|
|
||||||
const isSelect = keybind.match("select", keyEvent);
|
|
||||||
const isInverting = keybind.isInverting(keyEvent);
|
|
||||||
|
|
||||||
if (isSelect) {
|
const results = () => searchStore.results();
|
||||||
const results = searchStore.results();
|
|
||||||
if (results.length > 0 && resultIndex() < results.length) {
|
|
||||||
setResultIndex(resultIndex() + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't handle pane navigation here - unified in App.tsx
|
// The focused result tracks pane 1's focused row.
|
||||||
if (nav.activeDepth() !== SearchPaneType.RESULTS) return;
|
const focusedResult = createMemo(() => {
|
||||||
|
const list = results();
|
||||||
const results = searchStore.results();
|
if (list.length === 0) return undefined;
|
||||||
if (results.length === 0) return;
|
const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1);
|
||||||
|
return list[idx];
|
||||||
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 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearch = async () => {
|
// 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());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// ── 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));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
const query = inputValue().trim();
|
const query = inputValue().trim();
|
||||||
if (query) {
|
if (!query) return;
|
||||||
await searchStore.search(query);
|
searchStore.search(query).catch(() => {});
|
||||||
if (searchStore.results().length > 0) {
|
nav.setFocusedIndex(RESULTS, 0);
|
||||||
//setFocusArea("results"); //TODO: move level
|
nav.setActivePane(RESULTS);
|
||||||
setResultIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHistorySelect = async (query: string) => {
|
const handleHistorySelect = (query: string) => {
|
||||||
setInputValue(query);
|
setInputValue(query);
|
||||||
await searchStore.search(query);
|
searchStore.search(query).catch(() => {});
|
||||||
if (searchStore.results().length > 0) {
|
nav.setFocusedIndex(RESULTS, 0);
|
||||||
//setFocusArea("results"); //TODO: move level
|
nav.setActivePane(RESULTS);
|
||||||
setResultIndex(0);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResultSelect = (result: SearchResult) => {
|
const handleSubscribe = (result: SearchResult) => {
|
||||||
//props.onSubscribe?.(result);
|
|
||||||
searchStore.markSubscribed(result.podcast.id);
|
searchStore.markSubscribed(result.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
<box flexDirection="column" height="100%" gap={1} width="100%">
|
const PAGE_ACTIONS: Partial<
|
||||||
{/* Search Header */}
|
Record<KeybindActionName, (pane: PaneId) => void>
|
||||||
<box flexDirection="column" gap={1}>
|
> = {
|
||||||
<text fg={theme.text}>
|
"move-down": (p) => step(p, 1),
|
||||||
<strong>Search Podcasts</strong>
|
"move-up": (p) => step(p, -1),
|
||||||
</text>
|
"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(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
{/* Search Input */}
|
function len(pane: PaneId): number {
|
||||||
|
if (pane === RESULTS) return results().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: 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">
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
<text fg="gray">Search:</text>
|
<text fg={muted()}>Query:</text>
|
||||||
<input
|
<input
|
||||||
value={inputValue()}
|
value={inputValue()}
|
||||||
onInput={(value) => {
|
onInput={setInputValue}
|
||||||
setInputValue(value);
|
onSubmit={() => handleSubmit()}
|
||||||
}}
|
placeholder="Enter podcast name..."
|
||||||
placeholder="Enter podcast name, topic, or author..."
|
focused={isActive(INPUT)}
|
||||||
focused={nav.activeDepth() === SearchPaneType.INPUT}
|
width={28}
|
||||||
width={50}
|
|
||||||
/>
|
/>
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={handleSearch}
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>[Enter] Search</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
|
<text fg={muted()}>Enter to search · h/l: panes</text>
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<Show when={searchStore.isSearching()}>
|
<Show when={searchStore.isSearching()}>
|
||||||
<text fg={theme.warning}>Searching...</text>
|
<text fg={theme.warning}>Searching...</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={searchStore.error()}>
|
<Show when={searchStore.error()}>
|
||||||
<text fg={theme.error}>{searchStore.error()}</text>
|
<text fg={theme.error}>{searchStore.error()}</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Main Content - Results or History */}
|
<box height={1} />
|
||||||
<box flexDirection="row" height="100%" gap={2}>
|
<text fg={theme.textSecondary}>Recent</text>
|
||||||
{/* Results Panel */}
|
<Show
|
||||||
|
when={searchStore.history().length > 0}
|
||||||
|
fallback={<text fg={muted()}>No recent searches</text>}
|
||||||
|
>
|
||||||
|
<For each={searchStore.history().slice(0, 12)}>
|
||||||
|
{(query) => (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="row"
|
||||||
flexGrow={1}
|
paddingLeft={1}
|
||||||
border
|
onMouseDown={() => handleHistorySelect(query)}
|
||||||
borderColor={
|
|
||||||
nav.activeDepth() === SearchPaneType.RESULTS
|
|
||||||
? theme.accent
|
|
||||||
: theme.border
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<box padding={1}>
|
<text fg={muted()}>
|
||||||
<text
|
{">"} {query}
|
||||||
fg={
|
|
||||||
nav.activeDepth() === SearchPaneType.RESULTS
|
|
||||||
? theme.primary
|
|
||||||
: theme.muted
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Results ({searchStore.results().length})
|
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</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
|
<Show
|
||||||
when={searchStore.results().length > 0}
|
when={results().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={2}>
|
<box padding={1}>
|
||||||
<text fg={theme.muted}>
|
<text fg={muted()}>
|
||||||
{searchStore.query()
|
{searchStore.query()
|
||||||
? "No results found"
|
? "No results found"
|
||||||
: "Enter a search term to find podcasts"}
|
: "Enter a search term to find podcasts"}
|
||||||
@@ -167,44 +266,130 @@ export function SearchPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SearchResults
|
<For each={results()}>
|
||||||
results={searchStore.results()}
|
{(result, index) => (
|
||||||
selectedIndex={resultIndex()}
|
<box
|
||||||
focused={nav.activeDepth() === SearchPaneType.RESULTS}
|
flexDirection="column"
|
||||||
onSelect={handleResultSelect}
|
gap={0}
|
||||||
onChange={setResultIndex}
|
paddingLeft={1}
|
||||||
isSearching={searchStore.isSearching()}
|
paddingRight={1}
|
||||||
error={searchStore.error()}
|
backgroundColor={focusBg(index(), RESULTS)}
|
||||||
/>
|
onMouseDown={() => {
|
||||||
</Show>
|
nav.setActivePane(RESULTS);
|
||||||
</box>
|
nav.setFocusedIndex(RESULTS, index());
|
||||||
|
}}
|
||||||
{/* History Sidebar */}
|
>
|
||||||
<box width={30} border borderColor={theme.border}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box padding={1} flexDirection="column">
|
<text fg={focusFg(index(), RESULTS)}>
|
||||||
<box paddingBottom={1}>
|
{index() === nav.focusedIndex(RESULTS) ? "❯" : " "}
|
||||||
|
</text>
|
||||||
|
<text fg={focusFg(index(), RESULTS)}>
|
||||||
|
{result.podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={result.podcast.isSubscribed}>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
nav.activeDepth() === SearchPaneType.HISTORY
|
index() === nav.focusedIndex(RESULTS)
|
||||||
? theme.primary
|
? theme.surface
|
||||||
: theme.muted
|
: theme.success
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
History
|
[+]
|
||||||
</text>
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<SearchHistory
|
<Show when={result.podcast.author}>
|
||||||
history={searchStore.history()}
|
<text
|
||||||
selectedIndex={historyIndex()}
|
fg={
|
||||||
focused={nav.activeDepth() === SearchPaneType.HISTORY}
|
index() === nav.focusedIndex(RESULTS)
|
||||||
onSelect={handleHistorySelect}
|
? theme.surface
|
||||||
onRemove={searchStore.removeFromHistory}
|
: muted()
|
||||||
onClear={searchStore.clearHistory}
|
}
|
||||||
onChange={setHistoryIndex}
|
paddingLeft={2}
|
||||||
/>
|
>
|
||||||
|
by {result.podcast.author}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</scrollbox>
|
||||||
</box>
|
</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>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { SearchPage };
|
||||||
|
|||||||
@@ -1,118 +1,184 @@
|
|||||||
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 { SourceManager } from "./SourceManager";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { PreferencesPanel } from "./PreferencesPanel";
|
import { PreferencesPanel } from "./PreferencesPanel";
|
||||||
import { SyncPanel } from "./SyncPanel";
|
import { SyncPanel } from "./SyncPanel";
|
||||||
import { VisualizerSettings } from "./VisualizerSettings";
|
import { VisualizerSettings } from "./VisualizerSettings";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
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 { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
enum SettingsPaneType {
|
export const SettingsPaneCount = 2;
|
||||||
SYNC = 1,
|
|
||||||
SOURCES = 2,
|
|
||||||
PREFERENCES = 3,
|
|
||||||
VISUALIZER = 4,
|
|
||||||
ACCOUNT = 5,
|
|
||||||
}
|
|
||||||
export const SettingsPaneCount = 5;
|
|
||||||
|
|
||||||
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
|
const SECTIONS = [
|
||||||
{ id: SettingsPaneType.SYNC, label: "Sync" },
|
{ id: 0, label: "Sync" },
|
||||||
{ id: SettingsPaneType.SOURCES, label: "Sources" },
|
{ id: 1, label: "Sources" },
|
||||||
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" },
|
{ id: 2, label: "Preferences" },
|
||||||
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
|
{ id: 3, label: "Visualizer" },
|
||||||
{ id: SettingsPaneType.ACCOUNT, label: "Account" },
|
{ id: 4, label: "Account" },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const keybind = useKeybinds();
|
|
||||||
|
|
||||||
// Helper function to check if a depth is active
|
const SECTIONS_PANE = PaneSlot.PARENT; // 0
|
||||||
const isActive = (depth: SettingsPaneType): boolean => {
|
const PANEL = PaneSlot.CURRENT; // 1
|
||||||
return nav.activeDepth() === depth;
|
|
||||||
|
// 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];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get the current depth as a number
|
// Register a resolver so visual-mode range selection grows by section id.
|
||||||
const currentDepth = () => nav.activeDepth() as number;
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
useKeyboard(
|
nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) =>
|
||||||
(keyEvent: any) => {
|
SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(),
|
||||||
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);
|
|
||||||
|
|
||||||
// don't handle pane navigation here - unified in App.tsx
|
|
||||||
if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return;
|
|
||||||
|
|
||||||
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 },
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAction = (data: {
|
||||||
|
action: KeybindActionName;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
}) => {
|
||||||
|
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 (
|
return (
|
||||||
<box flexDirection="column" gap={1} height="100%" width="100%">
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
<box flexDirection="row" gap={1}>
|
{/* ── 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}>
|
<For each={SECTIONS}>
|
||||||
{(section, index) => (
|
{(section, index) => (
|
||||||
<box
|
<box
|
||||||
border
|
flexDirection="row"
|
||||||
borderColor={theme.border}
|
gap={1}
|
||||||
padding={0}
|
paddingLeft={1}
|
||||||
backgroundColor={
|
paddingRight={1}
|
||||||
currentDepth() === section.id ? theme.primary : undefined
|
backgroundColor={focusBg(index(), SECTIONS_PANE)}
|
||||||
}
|
onMouseDown={() => {
|
||||||
onMouseDown={() => nav.setActiveDepth(section.id)}
|
nav.setActivePane(SECTIONS_PANE);
|
||||||
|
nav.setFocusedIndex(SECTIONS_PANE, index());
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<text
|
<text fg={focusFg(index(), SECTIONS_PANE)}>
|
||||||
fg={
|
{index() === nav.focusedIndex(SECTIONS_PANE) ? "❯" : " "}
|
||||||
currentDepth() === section.id ? theme.text : theme.textMuted
|
</text>
|
||||||
}
|
<text fg={focusFg(index(), SECTIONS_PANE)}>
|
||||||
>
|
{section.label}
|
||||||
[{index() + 1}] {section.label}
|
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<box
|
{/* ── pane 1: panel ─────────────────────────────────────────────────────── */}
|
||||||
border
|
<box flexDirection="column" flexGrow={PANE_RATIO.current} height="100%">
|
||||||
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border}
|
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
||||||
flexGrow={1}
|
<text fg={theme.textSecondary}>{focusedSection().label}</text>
|
||||||
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>
|
||||||
)}
|
<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>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,39 +3,39 @@
|
|||||||
* Manages search state, history, and results
|
* Manages search state, history, and results
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import { searchPodcasts } from "../utils/search"
|
import { searchPodcasts } from "../utils/search";
|
||||||
import { useFeedStore } from "./feed"
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult } from "../types/source"
|
import type { SearchResult } from "../types/source";
|
||||||
|
|
||||||
const STORAGE_KEY = "podtui_search_history"
|
const STORAGE_KEY = "podtui_search_history";
|
||||||
const MAX_HISTORY = 20
|
const MAX_HISTORY = 20;
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
query: string
|
query: string;
|
||||||
isSearching: boolean
|
isSearching: boolean;
|
||||||
results: SearchResult[]
|
results: SearchResult[];
|
||||||
error: string | null
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL = 1000 * 60 * 5
|
const CACHE_TTL = 1000 * 60 * 5;
|
||||||
|
|
||||||
/** Load search history from localStorage */
|
/** Load search history from localStorage */
|
||||||
function loadHistory(): string[] {
|
function loadHistory(): string[] {
|
||||||
if (typeof localStorage === "undefined") return []
|
if (typeof localStorage === "undefined") return [];
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
return stored ? JSON.parse(stored) : []
|
return stored ? JSON.parse(stored) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save search history to localStorage */
|
/** Save search history to localStorage */
|
||||||
function saveHistory(history: string[]): void {
|
function saveHistory(history: string[]): void {
|
||||||
if (typeof localStorage === "undefined") return
|
if (typeof localStorage === "undefined") return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
@@ -43,18 +43,18 @@ function saveHistory(history: string[]): void {
|
|||||||
|
|
||||||
/** Create search store */
|
/** Create search store */
|
||||||
export function createSearchStore() {
|
export function createSearchStore() {
|
||||||
const feedStore = useFeedStore()
|
const feedStore = useFeedStore();
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("");
|
||||||
const [isSearching, setIsSearching] = createSignal(false)
|
const [isSearching, setIsSearching] = createSignal(false);
|
||||||
const [results, setResults] = createSignal<SearchResult[]>([])
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [history, setHistory] = createSignal<string[]>(loadHistory())
|
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
||||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||||
|
|
||||||
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
||||||
const feeds = feedStore.feeds()
|
const feeds = feedStore.feeds();
|
||||||
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl))
|
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl));
|
||||||
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id))
|
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id));
|
||||||
|
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -65,83 +65,99 @@ export function createSearchStore() {
|
|||||||
subscribedUrls.has(item.podcast.feedUrl) ||
|
subscribedUrls.has(item.podcast.feedUrl) ||
|
||||||
subscribedIds.has(item.podcast.id),
|
subscribedIds.has(item.podcast.id),
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Perform search (multi-source implementation) */
|
/** Perform search (multi-source implementation) */
|
||||||
const search = async (searchQuery: string): Promise<void> => {
|
const search = async (searchQuery: string): Promise<void> => {
|
||||||
const q = searchQuery.trim()
|
const q = searchQuery.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
setResults([])
|
setResults([]);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuery(q)
|
setQuery(q);
|
||||||
setIsSearching(true)
|
setIsSearching(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
// Add to history
|
// Add to history
|
||||||
addToHistory(q)
|
addToHistory(q);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sources = feedStore.sources()
|
const sources = feedStore.sources();
|
||||||
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id)
|
const enabledSourceIds = sources
|
||||||
const sourceIds = selectedSources().length > 0
|
.filter((s) => s.enabled)
|
||||||
? selectedSources()
|
.map((s) => s.id);
|
||||||
: enabledSourceIds
|
const sourceIds =
|
||||||
|
selectedSources().length > 0 ? selectedSources() : enabledSourceIds;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
||||||
cacheTtl: CACHE_TTL,
|
cacheTtl: CACHE_TTL,
|
||||||
})
|
});
|
||||||
|
|
||||||
setResults(applySubscribedStatus(searchResults))
|
setResults(applySubscribedStatus(searchResults));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError("Search failed. Please try again.")
|
setError(
|
||||||
setResults([])
|
e instanceof Error && e.message
|
||||||
|
? e.message
|
||||||
|
: "Search failed. Please try again.",
|
||||||
|
);
|
||||||
|
setResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false)
|
setIsSearching(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Add query to history */
|
/** Add query to history */
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
// Remove duplicates and add to front
|
// Remove duplicates and add to front
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase())
|
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY)
|
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
||||||
saveHistory(updated)
|
saveHistory(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Clear search history */
|
/** Clear search history */
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
setHistory([])
|
setHistory([]);
|
||||||
saveHistory([])
|
saveHistory([]);
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Remove single history item */
|
/** Remove single history item */
|
||||||
const removeFromHistory = (q: string) => {
|
const removeFromHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = prev.filter((h) => h !== q)
|
const updated = prev.filter((h) => h !== q);
|
||||||
saveHistory(updated)
|
saveHistory(updated);
|
||||||
return updated
|
return updated;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Clear results */
|
/** Clear results */
|
||||||
const clearResults = () => {
|
const clearResults = () => {
|
||||||
setResults([])
|
setResults([]);
|
||||||
setQuery("")
|
setQuery("");
|
||||||
setError(null)
|
setError(null);
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Mark a podcast as subscribed in results */
|
/** Mark a podcast as subscribed in results */
|
||||||
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
setResults((prev) =>
|
setResults((prev) =>
|
||||||
prev.map((result) => {
|
prev.map((result) => {
|
||||||
const matchesId = result.podcast.id === podcastId
|
const matchesId = result.podcast.id === podcastId;
|
||||||
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false
|
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
|
||||||
if (matchesId || matchesUrl) {
|
if (matchesId || matchesUrl) {
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -149,12 +165,12 @@ export function createSearchStore() {
|
|||||||
...result.podcast,
|
...result.podcast,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
return result;
|
||||||
return result
|
}),
|
||||||
})
|
);
|
||||||
)
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
@@ -173,15 +189,15 @@ export function createSearchStore() {
|
|||||||
removeFromHistory,
|
removeFromHistory,
|
||||||
setSelectedSources,
|
setSelectedSources,
|
||||||
markSubscribed,
|
markSubscribed,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton search store */
|
/** Singleton search store */
|
||||||
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null
|
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
||||||
|
|
||||||
export function useSearchStore() {
|
export function useSearchStore() {
|
||||||
if (!searchStoreInstance) {
|
if (!searchStoreInstance) {
|
||||||
searchStoreInstance = createSearchStore()
|
searchStoreInstance = createSearchStore();
|
||||||
}
|
}
|
||||||
return searchStoreInstance
|
return searchStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,128 +20,148 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type EventHandler<T = unknown> = (data: T) => void
|
type EventHandler<T = unknown> = (data: T) => void;
|
||||||
|
|
||||||
// Export EventHandler type for external use
|
// Export EventHandler type for external use
|
||||||
export type { EventHandler }
|
export type { EventHandler };
|
||||||
|
|
||||||
interface EventBusInstance {
|
interface EventBusInstance {
|
||||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
||||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
||||||
off<T = unknown>(event: string, handler: EventHandler<T>): void
|
off<T = unknown>(event: string, handler: EventHandler<T>): void;
|
||||||
emit<T = unknown>(event: string, data: T): void
|
emit<T = unknown>(event: string, data: T): void;
|
||||||
clear(): void
|
clear(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEventBus(): EventBusInstance {
|
function createEventBus(): EventBusInstance {
|
||||||
const handlers = new Map<string, Set<EventHandler>>()
|
const handlers = new Map<string, Set<EventHandler>>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||||
if (!handlers.has(event)) {
|
if (!handlers.has(event)) {
|
||||||
handlers.set(event, new Set())
|
handlers.set(event, new Set());
|
||||||
}
|
}
|
||||||
handlers.get(event)!.add(handler as EventHandler)
|
handlers.get(event)!.add(handler as EventHandler);
|
||||||
|
|
||||||
// Return unsubscribe function
|
// Return unsubscribe function
|
||||||
return () => {
|
return () => {
|
||||||
this.off(event, handler)
|
this.off(event, handler);
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||||
const wrappedHandler: EventHandler<T> = (data) => {
|
const wrappedHandler: EventHandler<T> = (data) => {
|
||||||
this.off(event, wrappedHandler)
|
this.off(event, wrappedHandler);
|
||||||
handler(data)
|
handler(data);
|
||||||
}
|
};
|
||||||
return this.on(event, wrappedHandler)
|
return this.on(event, wrappedHandler);
|
||||||
},
|
},
|
||||||
|
|
||||||
off<T = unknown>(event: string, handler: EventHandler<T>): void {
|
off<T = unknown>(event: string, handler: EventHandler<T>): void {
|
||||||
const eventHandlers = handlers.get(event)
|
const eventHandlers = handlers.get(event);
|
||||||
if (eventHandlers) {
|
if (eventHandlers) {
|
||||||
eventHandlers.delete(handler as EventHandler)
|
eventHandlers.delete(handler as EventHandler);
|
||||||
if (eventHandlers.size === 0) {
|
if (eventHandlers.size === 0) {
|
||||||
handlers.delete(event)
|
handlers.delete(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
emit<T = unknown>(event: string, data: T): void {
|
emit<T = unknown>(event: string, data: T): void {
|
||||||
const eventHandlers = handlers.get(event)
|
const eventHandlers = handlers.get(event);
|
||||||
if (eventHandlers) {
|
if (eventHandlers) {
|
||||||
for (const handler of eventHandlers) {
|
for (const handler of eventHandlers) {
|
||||||
try {
|
try {
|
||||||
handler(data)
|
handler(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error in event handler for "${event}":`, error)
|
console.error(`Error in event handler for "${event}":`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
handlers.clear()
|
handlers.clear();
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton event bus instance
|
// Singleton event bus instance
|
||||||
export const EventBus = createEventBus()
|
export const EventBus = createEventBus();
|
||||||
|
|
||||||
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
|
import type { TABS } from "@/utils/navigation";
|
||||||
|
import type { PaneId, NavMode } from "@/context/NavigationContext";
|
||||||
|
|
||||||
// Common event types for the application
|
// Common event types for the application
|
||||||
export type AppEvents = {
|
export type AppEvents = {
|
||||||
"theme.changed": { theme: string; mode: "dark" | "light" }
|
"theme.changed": { theme: string; mode: "dark" | "light" };
|
||||||
"theme.mode.changed": { mode: "dark" | "light" }
|
"theme.mode.changed": { mode: "dark" | "light" };
|
||||||
"theme.reload": {}
|
"theme.reload": {};
|
||||||
"navigation.tab.changed": { tab: string; previousTab?: string }
|
"navigation.tab.changed": { tab: string; previousTab?: string };
|
||||||
"navigation.layer.changed": { depth: number; previousDepth: number }
|
"navigation.layer.changed": { depth: number; previousDepth: number };
|
||||||
"feed.subscribed": { feedId: string; feedUrl: string }
|
"feed.subscribed": { feedId: string; feedUrl: string };
|
||||||
"feed.unsubscribed": { feedId: string }
|
"feed.unsubscribed": { feedId: string };
|
||||||
"player.play": { episodeId: string }
|
"player.play": { episodeId: string };
|
||||||
"player.pause": { episodeId: string }
|
"player.pause": { episodeId: string };
|
||||||
"player.stop": {}
|
"player.stop": {};
|
||||||
"auth.login": { userId: string }
|
"auth.login": { userId: string };
|
||||||
"auth.logout": {}
|
"auth.logout": {};
|
||||||
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number }
|
"toast.show": {
|
||||||
"dialog.open": { dialogId: string }
|
message: string;
|
||||||
"dialog.close": { dialogId?: string }
|
variant: "info" | "success" | "warning" | "error";
|
||||||
"command.execute": { command: string; args?: unknown }
|
title?: string;
|
||||||
"clipboard.copied": { text: string }
|
duration?: number;
|
||||||
"selection.start": { x: number; y: number }
|
};
|
||||||
"selection.end": { text: string }
|
"dialog.open": { dialogId: string };
|
||||||
|
"dialog.close": { dialogId?: string };
|
||||||
|
"command.execute": { command: string; args?: unknown };
|
||||||
|
// Yazi-style unified router → active page dispatch. The Shell router
|
||||||
|
// emits these; each page subscribes to the subset it implements.
|
||||||
|
"nav.action": {
|
||||||
|
action: KeybindActionName;
|
||||||
|
tab: TABS;
|
||||||
|
pane: PaneId;
|
||||||
|
mode: NavMode;
|
||||||
|
};
|
||||||
|
"clipboard.copied": { text: string };
|
||||||
|
"selection.start": { x: number; y: number };
|
||||||
|
"selection.end": { text: string };
|
||||||
|
|
||||||
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
// Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio)
|
||||||
"media.toggle": {}
|
"media.toggle": {};
|
||||||
"media.volumeUp": {}
|
"media.volumeUp": {};
|
||||||
"media.volumeDown": {}
|
"media.volumeDown": {};
|
||||||
"media.seekForward": {}
|
"media.seekForward": {};
|
||||||
"media.seekBackward": {}
|
"media.seekBackward": {};
|
||||||
"media.speedCycle": {}
|
"media.speedCycle": {};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Type-safe emit and on functions
|
// Type-safe emit and on functions
|
||||||
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void {
|
export function emit<K extends keyof AppEvents>(
|
||||||
EventBus.emit(event, data)
|
event: K,
|
||||||
|
data: AppEvents[K],
|
||||||
|
): void {
|
||||||
|
EventBus.emit(event, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function on<K extends keyof AppEvents>(
|
export function on<K extends keyof AppEvents>(
|
||||||
event: K,
|
event: K,
|
||||||
handler: EventHandler<AppEvents[K]>
|
handler: EventHandler<AppEvents[K]>,
|
||||||
): () => void {
|
): () => void {
|
||||||
return EventBus.on(event, handler)
|
return EventBus.on(event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function once<K extends keyof AppEvents>(
|
export function once<K extends keyof AppEvents>(
|
||||||
event: K,
|
event: K,
|
||||||
handler: EventHandler<AppEvents[K]>
|
handler: EventHandler<AppEvents[K]>,
|
||||||
): () => void {
|
): () => void {
|
||||||
return EventBus.once(event, handler)
|
return EventBus.once(event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function off<K extends keyof AppEvents>(
|
export function off<K extends keyof AppEvents>(
|
||||||
event: K,
|
event: K,
|
||||||
handler: EventHandler<AppEvents[K]>
|
handler: EventHandler<AppEvents[K]>,
|
||||||
): void {
|
): void {
|
||||||
EventBus.off(event, handler)
|
EventBus.off(event, handler);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
|
* Keybinds persistence via JSONC file in XDG_CONFIG_HOME
|
||||||
*
|
*
|
||||||
* Handles copying keybind.jsonc from package to user config directory
|
* Handles copying keybinds.jsonc from package to user config directory
|
||||||
* and loading/saving keybind configurations.
|
* and loading/saving keybind configurations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { copyFile, mkdir } from "fs/promises";
|
import { copyFile } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { parseJSONC } from "./jsonc";
|
import { parseJSONC } from "./jsonc";
|
||||||
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
|
import { getConfigFilePath, ensureConfigDir } from "./config-dir";
|
||||||
@@ -15,33 +15,63 @@ const KEYBINDS_SOURCE = path.join(
|
|||||||
process.cwd(),
|
process.cwd(),
|
||||||
"src",
|
"src",
|
||||||
"config",
|
"config",
|
||||||
"keybind.jsonc",
|
"keybinds.jsonc",
|
||||||
);
|
);
|
||||||
const KEYBINDS_FILE = "keybinds.jsonc";
|
const KEYBINDS_FILE = "keybinds.jsonc";
|
||||||
|
|
||||||
/** Default keybinds from package */
|
/** Default keybinds (yazi-style) — mirrors src/config/keybinds.jsonc so the
|
||||||
|
* app works before a user keybinds file is copied into place. */
|
||||||
const DEFAULT_KEYBINDS: KeybindsResolved = {
|
const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||||
up: ["up", "k"],
|
// movement
|
||||||
down: ["down", "j"],
|
"move-down": ["j", "down"],
|
||||||
left: ["left", "h"],
|
"move-up": ["k", "up"],
|
||||||
right: ["right", "l"],
|
"page-down": ["ctrl-d"],
|
||||||
cycle: ["tab"],
|
"page-up": ["ctrl-u"],
|
||||||
dive: ["return"],
|
"full-down": ["ctrl-f"],
|
||||||
select: ["return"],
|
"full-up": ["ctrl-b"],
|
||||||
out: ["esc"],
|
"jump-down": ["J"],
|
||||||
inverseModifier: "shift",
|
"jump-up": ["K"],
|
||||||
leader: ":",
|
"goto-top": [["g", "g"]],
|
||||||
quit: ["<leader>q"],
|
"goto-bottom": ["G"],
|
||||||
"audio-toggle": ["<leader>p"],
|
// pane swipe
|
||||||
"audio-pause": [],
|
"swipe-prev": ["h", "left"],
|
||||||
"audio-play": [],
|
"swipe-next": ["l", "right"],
|
||||||
"audio-next": ["<leader>n"],
|
// open / select
|
||||||
"audio-prev": ["<leader>l"],
|
open: ["return", "enter"],
|
||||||
"audio-seek-forward": ["<leader>sf"],
|
"open-interactive": ["shift-return"],
|
||||||
"audio-seek-backward": ["<leader>sb"],
|
"toggle-select": ["space"],
|
||||||
|
"visual-mode": ["v"],
|
||||||
|
"toggle-all": ["ctrl-a"],
|
||||||
|
"invert-all": ["ctrl-r"],
|
||||||
|
escape: ["escape", "ctrl-["],
|
||||||
|
// tabs
|
||||||
|
"tab-prev": ["["],
|
||||||
|
"tab-next": ["]"],
|
||||||
|
"tab-goto-1": ["1"],
|
||||||
|
"tab-goto-2": ["2"],
|
||||||
|
"tab-goto-3": ["3"],
|
||||||
|
"tab-goto-4": ["4"],
|
||||||
|
"tab-goto-5": ["5"],
|
||||||
|
"tab-goto-6": ["6"],
|
||||||
|
// command / help / quit
|
||||||
|
command: [":"],
|
||||||
|
quit: ["q", "ctrl-c"],
|
||||||
|
help: ["~", "f1"],
|
||||||
|
// list ops
|
||||||
|
search: ["s"],
|
||||||
|
filter: ["f"],
|
||||||
|
sort: [","],
|
||||||
|
"toggle-hidden": ["."],
|
||||||
|
refresh: ["r"],
|
||||||
|
// audio transport (preserved; shifted single keys, no collisions)
|
||||||
|
"audio-toggle": ["P"],
|
||||||
|
"audio-next": ["N"],
|
||||||
|
"audio-prev": ["B"],
|
||||||
|
"audio-seek-forward": ["shift-."],
|
||||||
|
"audio-seek-backward": ["shift-,"],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Copy keybind.jsonc to user config directory on first run */
|
/** Copy keybinds.jsonc to user config directory on first run */
|
||||||
export async function copyKeybindsIfNeeded(): Promise<void> {
|
export async function copyKeybindsIfNeeded(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
@@ -70,6 +100,7 @@ export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
|||||||
|
|
||||||
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
|
if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS;
|
||||||
|
|
||||||
|
// Merge so partial user configs inherit defaults for missing keys.
|
||||||
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
|
return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved;
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_KEYBINDS;
|
return DEFAULT_KEYBINDS;
|
||||||
|
|||||||
@@ -36,3 +36,25 @@ export const LayerDepths = {
|
|||||||
[TABS.PLAYER]: PlayerPaneCount,
|
[TABS.PLAYER]: PlayerPaneCount,
|
||||||
[TABS.SETTINGS]: SettingsPaneCount,
|
[TABS.SETTINGS]: SettingsPaneCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3].
|
||||||
|
// Panes use flexGrow (Yoga) so columns always sum to the row width regardless
|
||||||
|
// of terminal size — more robust than fixed percentages and exactly mirrors
|
||||||
|
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||||
|
export const PANE_RATIO = {
|
||||||
|
parent: 1,
|
||||||
|
current: 4,
|
||||||
|
preview: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Number of interactive panes per tab (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,146 +1,162 @@
|
|||||||
import { searchSourceByType } from "./source-searcher"
|
import { searchSourceByType } from "./source-searcher";
|
||||||
import type { PodcastSource, SearchResult } from "../types/source"
|
import type { PodcastSource, SearchResult } from "../types/source";
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode";
|
||||||
|
|
||||||
type SearchCacheEntry = {
|
type SearchCacheEntry = {
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
results: SearchResult[]
|
results: SearchResult[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type SearchOptions = {
|
type SearchOptions = {
|
||||||
cacheTtl?: number
|
cacheTtl?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const searchCache = new Map<string, SearchCacheEntry>()
|
const searchCache = new Map<string, SearchCacheEntry>();
|
||||||
const rateLimitState = new Map<string, number[]>()
|
const rateLimitState = new Map<string, number[]>();
|
||||||
const RATE_LIMIT_WINDOW_MS = 60000
|
const RATE_LIMIT_WINDOW_MS = 60000;
|
||||||
const RATE_LIMIT_MAX_CALLS = 20
|
const RATE_LIMIT_MAX_CALLS = 20;
|
||||||
|
|
||||||
const throttleSource = async (sourceId: string) => {
|
const throttleSource = async (sourceId: string) => {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
const windowStart = now - RATE_LIMIT_WINDOW_MS
|
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
||||||
const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
const timestamps =
|
||||||
|
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
||||||
|
|
||||||
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
if (timestamps.length >= RATE_LIMIT_MAX_CALLS) {
|
||||||
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now
|
const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now;
|
||||||
if (waitMs > 0) {
|
if (waitMs > 0) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []
|
const updated =
|
||||||
updated.push(Date.now())
|
rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [];
|
||||||
rateLimitState.set(sourceId, updated)
|
updated.push(Date.now());
|
||||||
}
|
rateLimitState.set(sourceId, updated);
|
||||||
|
};
|
||||||
|
|
||||||
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
||||||
const keySources = [...sourceIds].sort().join(",")
|
const keySources = [...sourceIds].sort().join(",");
|
||||||
return `${query.toLowerCase()}::${keySources}`
|
return `${query.toLowerCase()}::${keySources}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||||
Date.now() - entry.timestamp < ttl
|
Date.now() - entry.timestamp < ttl;
|
||||||
|
|
||||||
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||||
const map = new Map<string, SearchResult>()
|
const map = new Map<string, SearchResult>();
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title
|
const key =
|
||||||
const existing = map.get(key)
|
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
|
||||||
|
const existing = map.get(key);
|
||||||
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||||
map.set(key, result)
|
map.set(key, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(map.values())
|
return Array.from(map.values());
|
||||||
}
|
};
|
||||||
|
|
||||||
export const searchPodcasts = async (
|
export const searchPodcasts = async (
|
||||||
query: string,
|
query: string,
|
||||||
sourceIds: string[],
|
sourceIds: string[],
|
||||||
sources: PodcastSource[],
|
sources: PodcastSource[],
|
||||||
options: SearchOptions = {}
|
options: SearchOptions = {},
|
||||||
): Promise<SearchResult[]> => {
|
): Promise<SearchResult[]> => {
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return []
|
if (!trimmed) return [];
|
||||||
|
|
||||||
const activeSources = sources.filter(
|
const activeSources = sources.filter(
|
||||||
(source) => sourceIds.includes(source.id) && source.enabled
|
(source) => sourceIds.includes(source.id) && source.enabled,
|
||||||
)
|
);
|
||||||
|
|
||||||
if (activeSources.length === 0) return []
|
if (activeSources.length === 0) {
|
||||||
|
// No enabled sources — surface a clear cause instead of returning empty,
|
||||||
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5
|
// which otherwise looks indistinguishable from a network failure.
|
||||||
const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id))
|
if (sourceIds.length === 0) {
|
||||||
const cached = searchCache.get(cacheKey)
|
throw new Error("No search sources are enabled");
|
||||||
if (cached && isCacheValid(cached, cacheTtl)) {
|
}
|
||||||
return cached.results
|
throw new Error("No enabled sources match the selected search sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: SearchResult[] = []
|
const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5;
|
||||||
const errors: Error[] = []
|
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[] = [];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
activeSources.map(async (source) => {
|
activeSources.map(async (source) => {
|
||||||
try {
|
try {
|
||||||
await throttleSource(source.id)
|
await throttleSource(source.id);
|
||||||
const sourceResults = await searchSourceByType(trimmed, source)
|
const sourceResults = await searchSourceByType(trimmed, source);
|
||||||
results.push(...sourceResults)
|
results.push(...sourceResults);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(error as Error)
|
errors.push(error as Error);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
|
|
||||||
const deduped = dedupeResults(results)
|
const deduped = dedupeResults(results);
|
||||||
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||||
|
|
||||||
if (sorted.length === 0 && errors.length > 0) {
|
if (sorted.length === 0 && errors.length > 0) {
|
||||||
throw new Error("Search failed for all sources")
|
throw new Error("Search failed for all sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted })
|
searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted });
|
||||||
return sorted
|
return sorted;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ItunesEpisodeResult = {
|
type ItunesEpisodeResult = {
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
trackName?: string
|
trackName?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
shortDescription?: string
|
shortDescription?: string;
|
||||||
releaseDate?: string
|
releaseDate?: string;
|
||||||
trackTimeMillis?: number
|
trackTimeMillis?: number;
|
||||||
episodeUrl?: string
|
episodeUrl?: string;
|
||||||
previewUrl?: string
|
previewUrl?: string;
|
||||||
trackViewUrl?: string
|
trackViewUrl?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ItunesEpisodeResponse = {
|
type ItunesEpisodeResponse = {
|
||||||
resultCount: number
|
resultCount: number;
|
||||||
results: ItunesEpisodeResult[]
|
results: ItunesEpisodeResult[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export const searchEpisodes = async (
|
export const searchEpisodes = async (
|
||||||
query: string,
|
query: string,
|
||||||
feedId: string
|
feedId: string,
|
||||||
): Promise<Episode[]> => {
|
): Promise<Episode[]> => {
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return []
|
if (!trimmed) return [];
|
||||||
|
|
||||||
const url = new URL("https://itunes.apple.com/search")
|
const url = new URL("https://itunes.apple.com/search");
|
||||||
url.searchParams.set("term", trimmed)
|
url.searchParams.set("term", trimmed);
|
||||||
url.searchParams.set("media", "podcast")
|
url.searchParams.set("media", "podcast");
|
||||||
url.searchParams.set("entity", "podcastEpisode")
|
url.searchParams.set("entity", "podcastEpisode");
|
||||||
url.searchParams.set("country", "US")
|
url.searchParams.set("country", "US");
|
||||||
url.searchParams.set("lang", "en_us")
|
url.searchParams.set("lang", "en_us");
|
||||||
|
|
||||||
const response = await fetch(url.toString())
|
const response = await fetch(url.toString());
|
||||||
if (!response.ok) return []
|
if (!response.ok) return [];
|
||||||
|
|
||||||
const data = (await response.json()) as ItunesEpisodeResponse
|
const data = (await response.json()) as ItunesEpisodeResponse;
|
||||||
return data.results
|
return data.results
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (!item.trackName) return null
|
if (!item.trackName) return null;
|
||||||
const id = item.trackId ? `episode-${item.trackId}` : `episode-${item.trackName}`
|
const id = item.trackId
|
||||||
const audioUrl = item.episodeUrl || item.previewUrl || item.trackViewUrl || ""
|
? `episode-${item.trackId}`
|
||||||
|
: `episode-${item.trackName}`;
|
||||||
|
const audioUrl =
|
||||||
|
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -148,9 +164,11 @@ export const searchEpisodes = async (
|
|||||||
title: item.trackName,
|
title: item.trackName,
|
||||||
description: item.description || item.shortDescription || "",
|
description: item.description || item.shortDescription || "",
|
||||||
audioUrl,
|
audioUrl,
|
||||||
duration: item.trackTimeMillis ? Math.round(item.trackTimeMillis / 1000) : 0,
|
duration: item.trackTimeMillis
|
||||||
|
? Math.round(item.trackTimeMillis / 1000)
|
||||||
|
: 0,
|
||||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
||||||
}
|
};
|
||||||
})
|
})
|
||||||
.filter((item): item is Episode => Boolean(item))
|
.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"],
|
"types": ["bun-types"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "tests/**/*"]
|
"include": ["src/**/*", "tests/**/*", "scripts/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user