diff --git a/.gitignore b/.gitignore index 4c3a1d8..976b75d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Finder (MacOS) folder config .DS_Store +.harness/ diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..4ab240c Binary files /dev/null and b/bun.lockb differ diff --git a/scripts/tui-harness.tsx b/scripts/tui-harness.tsx new file mode 100644 index 0000000..4e79665 --- /dev/null +++ b/scripts/tui-harness.tsx @@ -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 [mods...] # mods: ctrl shift meta + * bun scripts/tui-harness.tsx arrow [mods...] + * bun scripts/tui-harness.tsx enter|escape|tab|space|backspace + * bun scripts/tui-harness.tsx type "" + * bun scripts/tui-harness.tsx wait + * bun scripts/tui-harness.tsx resize + * 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 = { + 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(); + 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; + positional: string[]; +} { + const flags: Record = {}; + 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 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 "); + // 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 "); + return { t: "wait", ms: parseInt(first, 10) || 0 }; + case "resize": + if (!first || !positional[1]) throw new Error("resize requires "); + 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 | undefined { + if (!mods || !mods.length) return undefined; + const o: Record = {}; + for (const m of mods) o[m] = true; + return o; +} + +async function execAction(setup: any, a: Action): Promise { + 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 = () => ( + + + + + + + + + + + + + + + + ); + + const setup = await testRender(() => , { + 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 = {}; + 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); +}); diff --git a/src/App.tsx b/src/App.tsx index e494c68..1f93678 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,194 +1,95 @@ -import { createMemo, ErrorBoundary, Accessor } from "solid-js"; -import { useKeyboard, useSelectionHandler } from "@opentui/solid"; -import { TabNavigation } from "./components/TabNavigation"; -import { CodeValidation } from "@/components/CodeValidation"; -import { LoadingIndicator } from "@/components/LoadingIndicator"; +import { ErrorBoundary } from "solid-js"; +import { useSelectionHandler, useRenderer } from "@opentui/solid"; import { useAuthStore } from "@/stores/auth"; -import { useFeedStore } from "@/stores/feed"; import { useAudio } from "@/hooks/useAudio"; import { useMultimediaKeys } from "@/hooks/useMultimediaKeys"; -import { FeedVisibility } from "@/types/feed"; import { Clipboard } from "@/utils/clipboard"; import { useToast } from "@/ui/toast"; -import { useRenderer } from "@opentui/solid"; -import type { AuthScreen } from "@/types/auth"; -import type { Episode } from "@/types/episode"; -import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation"; import { useTheme, ThemeProvider } from "./context/ThemeContext"; import { KeybindProvider, useKeybinds } from "./context/KeybindContext"; -import { NavigationProvider, useNavigation } from "./context/NavigationContext"; -import { useAudioNavStore, AudioSource } from "./stores/audio-nav"; +import { + NavigationProvider, + useNavigation, + NavMode, +} from "./context/NavigationContext"; +import { TABS } from "./utils/navigation"; +import { Shell } from "./components/Shell"; const DEBUG = import.meta.env.DEBUG; export function App() { - const nav = useNavigation(); - const auth = useAuthStore(); - const feedStore = useFeedStore(); - const audio = useAudio(); - const toast = useToast(); - const renderer = useRenderer(); - const themeContext = useTheme(); - const theme = themeContext.theme; + const nav = useNavigation(); + const auth = useAuthStore(); + const audio = useAudio(); + const toast = useToast(); + const renderer = useRenderer(); + const themeContext = useTheme(); + const theme = themeContext.theme; + const keybind = useKeybinds(); - // Create a reactive expression for background color - const backgroundColor = () => { - return themeContext.selected === "system" - ? "transparent" - : themeContext.theme.surface; - }; - const keybind = useKeybinds(); - const audioNav = useAudioNavStore(); + // Multimedia keys (physical play/seek keys) still feed the audio backend + // regardless of the on-screen yazi keybinds. + useMultimediaKeys({ + playerFocused: () => + nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL + ? true + : false, + inputFocused: () => nav.inputFocused(), + hasEpisode: () => !!audio.currentEpisode(), + }); - useMultimediaKeys({ - playerFocused: () => - nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0, - inputFocused: () => nav.inputFocused(), - hasEpisode: () => !!audio.currentEpisode(), - }); + // Mouse text-selection → clipboard (unchanged from the old shell). + useSelectionHandler((selection: any) => { + if (!selection) return; + const text = selection.getSelectedText?.(); + if (!text || text.trim().length === 0) return; + Clipboard.copy(text) + .then(() => + toast.show({ message: "Copied to Clipboard!", variant: "info" }), + ) + .catch(toast.error) + .finally(() => renderer.clearSelection()); + }); - const handlePlayEpisode = (episode: Episode) => { - audio.play(episode); - nav.setActiveTab(TABS.PLAYER); - nav.setActiveDepth(1); - audioNav.setSource(AudioSource.FEED); - }; + const backgroundColor = () => + themeContext.selected === "system" + ? "transparent" + : themeContext.theme.surface; - useSelectionHandler((selection: any) => { - if (!selection) return; - const text = selection.getSelectedText?.(); - if (!text || text.trim().length === 0) return; - - Clipboard.copy(text) - .then(() => { - toast.show({ message: "Copied to Clipboard!", variant: "info" }); - }) - .catch(toast.error) - .finally(() => { - renderer.clearSelection(); - }); - }); - - useKeyboard( - (keyEvent) => { - const isCycle = keybind.match("cycle", keyEvent); - const isUp = keybind.match("up", keyEvent); - const isDown = keybind.match("down", keyEvent); - const isLeft = keybind.match("left", keyEvent); - const isRight = keybind.match("right", keyEvent); - const isDive = keybind.match("dive", keyEvent); - const isOut = keybind.match("out", keyEvent); - const isToggle = keybind.match("audio-toggle", keyEvent); - const isNext = keybind.match("audio-next", keyEvent); - const isPrev = keybind.match("audio-prev", keyEvent); - const isSeekForward = keybind.match("audio-seek-forward", keyEvent); - const isSeekBackward = keybind.match("audio-seek-backward", keyEvent); - const isQuit = keybind.match("quit", keyEvent); - const isInverting = keybind.isInverting(keyEvent); - - // unified navigation: left->right, top->bottom across all tabs - if (nav.activeDepth() == 0) { - // at top level: cycle through tabs - if ( - (isCycle && !isInverting) || - (isDown && !isInverting) || - (isUp && isInverting) - ) { - nav.nextTab(); - return; - } - if ( - (isCycle && isInverting) || - (isDown && isInverting) || - (isUp && !isInverting) - ) { - nav.prevTab(); - return; - } - // dive out to first pane - if ( - (isDive && !isInverting) || - (isOut && isInverting) || - (isRight && !isInverting) || - (isLeft && isInverting) - ) { - nav.setActiveDepth(1); - } - } else { - // in panes: navigate between them - if ( - (isDive && isInverting) || - (isOut && !isInverting) || - (isRight && isInverting) || - (isLeft && !isInverting) - ) { - nav.setActiveDepth(0); - } else if (isDown && !isInverting) { - nav.nextPane(); - } else if (isUp && isInverting) { - nav.prevPane(); - } - } - }, - { release: false }, - ); - - return ( - ( - - - Error: {err?.message ?? String(err)} - {"\n"} - Press a number key (1-6) to switch tabs. - - - )} - > - - - {DEBUG && ( - - - - - - - - - - - - - - - - - - - - - - - - - - - )} - - - {LayerGraph[nav.activeTab()]()} - - - - ); + return ( + ( + + + Error: {err?.message ?? String(err)} + {"\n"} + Press 1-6 to switch tabs, or : to open the command bar. + + + )} + > + + {DEBUG && ( + + + + + + + + + + + + + )} + + + + ); } diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx new file mode 100644 index 0000000..c228bcc --- /dev/null +++ b/src/components/Shell.tsx @@ -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.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 = new Set( + [ + "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 ( + + {/* ── Top tab bar ─────────────────────────────────────────────────────── */} + + typeof v === "number", + )} + > + {(tab) => { + const active = () => nav.activeTab() === tab; + return ( + nav.setActiveTab(tab)} + > + + {tab}. {TAB_LABEL[tab]} + + + ); + }} + + + + {nowPlaying() ?? ""} + + + + {/* ── Active page (owns its panes) ────────────────────────────────────── */} + + {LayerGraph[nav.activeTab()]()} + + + {/* ── Bottom status / command bar ─────────────────────────────────────── */} + + + + {modeLabel()} + + + {TAB_LABEL[nav.activeTab()]} · pane {nav.activePane() + 1}/ + {TabPaneCount[nav.activeTab()]} + + 0}> + + ● {nav.selectedIds().length} + + + + + {pendingLabel()} + + + :cmd ~help q quit + + + } + > + + : + + {nav.commandBuffer()} + + + + {nav.commandError()} + + + + + + {/* ── Help overlay ─────────────────────────────────────────────────────── */} + + setShowHelp(false)} + sections={helpSections(k)} + theme={t as any} + /> + + + ); +} + +function helpSections(k: ReturnType) { + 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 ( + props.onClose()} + > + e.stopPropagation()} + > + + Yazi-style keybinds — press ~ or Esc to close + + + {(sec) => ( + + {sec.group} + + {(it) => ( + + {String(it[0]).padEnd(14, " ")} + {it[1]} + + )} + + + )} + + + + Edit ~/.config/podtui/keybinds.jsonc to remap. + + + + + ); +} + +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, + audio: ReturnType, + 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 }; diff --git a/src/components/ShortcutHelp.tsx b/src/components/ShortcutHelp.tsx index d561662..519cc28 100644 --- a/src/components/ShortcutHelp.tsx +++ b/src/components/ShortcutHelp.tsx @@ -1,28 +1,27 @@ +import { For } from "solid-js"; import { shortcuts } from "@/config/shortcuts"; import { useTheme } from "@/context/ThemeContext"; +/** Yazi-style keybind reference. The Shell has its own overlay; this component + * is kept for embedding inside Settings or other surfaces. */ export function ShortcutHelp() { - const { theme } = useTheme(); - return ( - - - - {shortcuts[0]?.keys ?? ""} - {shortcuts[0]?.action ?? ""} - - - {shortcuts[1]?.keys ?? ""} - {shortcuts[1]?.action ?? ""} - - - {shortcuts[2]?.keys ?? ""} - {shortcuts[2]?.action ?? ""} - - - {shortcuts[3]?.keys ?? ""} - {shortcuts[3]?.action ?? ""} - - - - ); + const { theme } = useTheme(); + return ( + + + + {(s) => ( + + {s.keys} + {s.action} + + )} + + + + ); } diff --git a/src/config/keybind.jsonc b/src/config/keybind.jsonc deleted file mode 100644 index f607a97..0000000 --- a/src/config/keybind.jsonc +++ /dev/null @@ -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": ["q"], - "refresh": ["r"], - "audio-toggle": ["p"], - "audio-pause": [], - "audio-play": [], - "audio-next": ["n"], - "audio-prev": ["l"], - "audio-seek-forward": ["sf"], - "audio-seek-backward": ["sb"], -} diff --git a/src/config/keybinds.jsonc b/src/config/keybinds.jsonc new file mode 100644 index 0000000..d351c8b --- /dev/null +++ b/src/config/keybinds.jsonc @@ -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+,) +} diff --git a/src/config/shortcuts.ts b/src/config/shortcuts.ts index 2da2785..67907ee 100644 --- a/src/config/shortcuts.ts +++ b/src/config/shortcuts.ts @@ -1,6 +1,26 @@ +/** + * Yazi-style keybind reference (mirrors src/config/keybinds.jsonc). + * Shown in help overlays; the canonical source remains keybinds.jsonc. + * Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap. + */ export const shortcuts = [ - { keys: "Ctrl+Q", action: "Quit" }, - { keys: "Ctrl+S", action: "Save" }, - { keys: "Left/Right", action: "Switch tabs" }, - { keys: "Esc", action: "Close modal" }, -] as const + { keys: "j / k", action: "Move down / up (within pane)" }, + { keys: "h / l", action: "Swipe to prev / next pane" }, + { keys: "J / K", action: "Jump 5 lines down / up" }, + { keys: "ctrl-d / u", action: "Half page down / up" }, + { keys: "g g / G", action: "Go to top / bottom of list" }, + { keys: "1-6", action: "Go to tab 1-6" }, + { keys: "[ / ]", action: "Previous / next tab" }, + { keys: "Enter", action: "Open / activate focused item" }, + { keys: "Space", action: "Toggle selection on item" }, + { keys: "v", action: "Enter visual (range) select mode" }, + { keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" }, + { keys: "Esc", action: "Clear selection / exit visual / cancel" }, + { keys: ":", action: "Open command bar (:quit :refresh :play …)" }, + { keys: "r / s / f", action: "Refresh / search / filter" }, + { keys: ", / .", action: "Sort / toggle hidden" }, + { keys: "P / N / B", action: "Play-pause / next / prev episode" }, + { keys: "< / >", action: "Seek backward / forward 10s" }, + { keys: "~ / F1", action: "Help" }, + { keys: "q", action: "Quit" }, +] as const; diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index f396a36..b7ac7d8 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -1,136 +1,375 @@ import { createSignal, onMount } from "solid-js"; import { createSimpleContext } from "./helper"; import { - copyKeybindsIfNeeded, - loadKeybindsFromFile, - saveKeybindsToFile, + copyKeybindsIfNeeded, + loadKeybindsFromFile, + saveKeybindsToFile, } from "../utils/keybinds-persistence"; import { createStore } from "solid-js/store"; -export type KeybindsResolved = { - up: string[]; - down: string[]; - left: string[]; - right: string[]; - cycle: string[]; // this will cycle no matter the depth/orientation - dive: string[]; - out: string[]; - inverseModifier: string; - leader: string; // will not trigger while focused on input - quit: string[]; - select: string[]; // for selecting/activating items - "audio-toggle": string[]; - "audio-pause": string[]; - "audio-play": string[]; - "audio-next": string[]; - "audio-prev": string[]; - "audio-seek-forward": string[]; - "audio-seek-backward": string[]; -}; +// ── Keybind model ─────────────────────────────────────────────────────────── +// Yazi-style: every binding is one or more "strokes". A stroke is a single +// key press (key + optional ctrl/shift/meta). Multi-stroke bindings form a +// sequence (e.g. ["g","g"] = gg, ["space","n"] = n). The matcher +// buffers keystrokes, prefers the longest matching sequence, and exposes the +// pending buffer reactively so the status bar can show it (very yazi). -export enum KeybindAction { - UP, - DOWN, - LEFT, - RIGHT, - CYCLE, - DIVE, - OUT, - QUIT, - SELECT, - AUDIO_TOGGLE, - AUDIO_PAUSE, - AUDIO_PLAY, - AUDIO_NEXT, - AUDIO_PREV, - AUDIO_SEEK_F, - AUDIO_SEEK_B, +/** A single key press. `key` is the lowercase logical key name + * ("j", "return", "space", "up", "f1", ...). */ +export interface Stroke { + key: string; + ctrl?: boolean; + shift?: boolean; + meta?: boolean; +} + +/** Raw config spec for one action: a list of alternative sequences. Each + * alternative is itself a list of stroke-notation strings. So + * "j" -> [[ {key:"j"} ]] + * ["j","down"] -> [[ {key:"j"} ], [ {key:"down"} ]] + * [["g","g"],"G"] -> [[ {key:"g"},{key:"g"} ], [ {key:"g",shift:true} ]] */ +export type KeybindSpec = string | (string | string[])[]; + +/** Canonical action names. Must match keys in keybinds.jsonc. */ +export type KeybindActionName = + | "move-down" + | "move-up" + | "page-down" + | "page-up" + | "full-down" + | "full-up" + | "jump-down" + | "jump-up" + | "goto-top" + | "goto-bottom" + | "swipe-prev" + | "swipe-next" + | "open" + | "open-interactive" + | "toggle-select" + | "visual-mode" + | "toggle-all" + | "invert-all" + | "escape" + | "tab-prev" + | "tab-next" + | "tab-goto-1" + | "tab-goto-2" + | "tab-goto-3" + | "tab-goto-4" + | "tab-goto-5" + | "tab-goto-6" + | "command" + | "quit" + | "help" + | "search" + | "filter" + | "sort" + | "toggle-hidden" + | "refresh" + | "audio-toggle" + | "audio-next" + | "audio-prev" + | "audio-seek-forward" + | "audio-seek-backward" + // legacy compat (kept so older callers don't crash) + | "select" + | "leader" + | "inverseModifier" + | "cycle" + | "dive" + | "out" + | "up" + | "down" + | "left" + | "right" + | "audio-pause" + | "audio-play"; + +/** Resolved config: action -> list of alternative stroke-sequences. */ +export type KeybindsResolved = Partial>; + +const SEQ_TIMEOUT_MS = 600; + +// ── Stroke parsing ─────────────────────────────────────────────────────────── +// Notation: "ctrl-d", "shift-j", "meta-x", "C-d", "M-x", "S-j". +// uppercase letter "G" => {key:"g", shift:true} +// special: return/enter/escape/tab/space/backspace/up/down/left/right + +export function parseStroke(notation: string): Stroke { + const raw = notation.trim(); + let ctrl = false, + shift = false, + meta = false; + let key = raw; + const parts = raw.split(/[-+]/); + if (parts.length > 1) { + key = parts[parts.length - 1]; + for (const mod of parts.slice(0, -1)) { + const m = mod.toLowerCase(); + if (m === "ctrl" || m === "c") ctrl = true; + else if (m === "shift" || m === "s") shift = true; + else if (m === "meta" || m === "alt" || m === "m") meta = true; + } + } + // Uppercase single letter w/o modifier => shift+letter (vim convention) + if ( + parts.length === 1 && + key.length === 1 && + key >= "A" && + key <= "Z" && + !ctrl && + !shift && + !meta + ) { + shift = true; + key = key.toLowerCase(); + } + return { key: key.toLowerCase(), ctrl, shift, meta }; +} + +/** Turn one raw spec into a list of alternative stroke-sequences. */ +export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] { + if (spec == null) return []; + const alts: Stroke[][] = []; + const push = (item: string | string[]) => { + const seq = Array.isArray(item) ? item : [item]; + alts.push(seq.map(parseStroke)); + }; + if (Array.isArray(spec)) { + for (const item of spec) push(item); + } else { + push(spec); + } + return alts; +} + +/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */ +export function strokeFromEvent(evt: { + name: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; +}): Stroke { + // Uppercase letter events from opentui arrive as name="q" + shift; normalize. + return { + key: (evt.name ?? "").toLowerCase(), + ctrl: !!evt.ctrl, + shift: !!evt.shift, + meta: !!evt.meta, + }; +} + +function strokeEq(a: Stroke, b: Stroke): boolean { + return ( + a.key === b.key && + !!a.ctrl === !!b.ctrl && + !!a.shift === !!b.shift && + !!a.meta === !!b.meta + ); +} + +/** A human label for a stroke, for the status bar / help. */ +export function strokeLabel(s: Stroke): string { + let out = ""; + if (s.ctrl) out += "C-"; + if (s.meta) out += "M-"; + if (s.shift) out += "S-"; + out += s.key; + return out; +} + +export function sequenceLabel(seq: Stroke[]): string { + return seq.map(strokeLabel).join(" "); } export const { use: useKeybinds, provider: KeybindProvider } = - createSimpleContext({ - name: "Keybinds", - init: () => { - const [store, setStore] = createStore({ - up: [], - down: [], - left: [], - right: [], - cycle: [], - dive: [], - out: [], - inverseModifier: "", - leader: "", - quit: [], - select: [], - refresh: [], - "audio-toggle": [], - "audio-pause": [], - "audio-play": [], - "audio-next": [], - "audio-prev": [], - "audio-seek-forward": [], - "audio-seek-backward": [], - } as KeybindsResolved); - const [ready, setReady] = createSignal(false); + createSimpleContext({ + name: "Keybinds", + init: () => { + const [store, setStore] = createStore({}); + // Resolved sequences per action, recomputed when store changes. + const [resolved, setResolved] = createSignal>( + {}, + ); + const [ready, setReady] = createSignal(false); + const [pending, setPending] = createSignal([]); - async function load() { - await copyKeybindsIfNeeded(); - const keybinds = await loadKeybindsFromFile(); - setStore(keybinds); - setReady(true); - } + let pendingTimer: ReturnType | undefined; - async function save() { - saveKeybindsToFile(store); - } + function recompute() { + const out: Record = {}; + for (const name of Object.keys(store) as string[]) { + out[name] = parseBindingSpec((store as any)[name]); + } + setResolved(out); + } - function print(input: keyof KeybindsResolved): string { - const keys = store[input] || []; - return Array.isArray(keys) ? keys.join(", ") : keys; - } + async function load() { + await copyKeybindsIfNeeded(); + const keybinds = await loadKeybindsFromFile(); + setStore(keybinds); + recompute(); + setReady(true); + } - function match( - keybind: keyof KeybindsResolved, - evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean }, - ): boolean { - const keys = store[keybind]; - if (!keys) return false; + async function save() { + saveKeybindsToFile(store as KeybindsResolved); + } - for (const key of keys) { - if (evt.name === key) return true; - } - return false; - } + function print(input: KeybindActionName): string { + const alts = resolved()[input] ?? []; + return alts.map(sequenceLabel).join(" / ") || "—"; + } - function isInverting(evt: { - name: string; - ctrl?: boolean; - meta?: boolean; - shift?: boolean; - }) { - if (store.inverseModifier === "ctrl" && evt.ctrl) return true; - if (store.inverseModifier === "meta" && evt.meta) return true; - if (store.inverseModifier === "shift" && evt.shift) return true; - return false; - } + function clearPending() { + if (pendingTimer) { + clearTimeout(pendingTimer); + pendingTimer = undefined; + } + if (pending().length > 0) setPending([]); + } - // Load on mount - onMount(() => { - load().catch(() => {}); - }); + function armTimer() { + if (pendingTimer) clearTimeout(pendingTimer); + pendingTimer = setTimeout(() => clearPending(), SEQ_TIMEOUT_MS); + } - return { - get ready() { - return ready(); - }, - get keybinds() { - return store; - }, - save, - print, - match, - isInverting, - }; - }, - }); + /** Look up every action whose sequence-list the candidate is a prefix of + * (i.e. a longer match is still possible) and every action that the + * candidate exactly equals. */ + function classify(candidate: Stroke[]) { + const exact: KeybindActionName[] = []; + const prefix: KeybindActionName[] = []; + const map = resolved(); + for (const name of Object.keys(map) as KeybindActionName[]) { + for (const seq of map[name] ?? []) { + if (seq.length < candidate.length) continue; + let isPrefix = true; + for (let i = 0; i < candidate.length; i++) { + if (!strokeEq(seq[i], candidate[i])) { + isPrefix = false; + break; + } + } + if (!isPrefix) continue; + if (seq.length === candidate.length) exact.push(name); + else prefix.push(name); + } + } + return { exact, prefix }; + } + + /** Legacy single-key matcher (used by command-palette registrations and + * older call sites). Returns true iff `name`'s sequence list contains a + * single-stroke alternative equal to the event. */ + function match( + name: KeybindActionName, + evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean }, + ): boolean { + const alts = resolved()[name] ?? []; + const s = strokeFromEvent(evt); + // skip in command/input mode unless explicitly handled by caller + for (const seq of alts) { + if (seq.length === 1 && strokeEq(seq[0], s)) return true; + } + return false; + } + + /** New sequence-aware matcher. Returns the resolved action or null. + * Callers should invoke this once per keypress in a single router. */ + function tryMatch(evt: { + name: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; + }): KeybindActionName | null { + const stroke = strokeFromEvent(evt); + const candidate = [...pending(), stroke]; + + const { exact, prefix } = classify(candidate); + + // Still mid-sequence: wait for more keys (unless this stroke also + // exactly matches something AND nothing depends on a longer prefix). + if (prefix.length > 0) { + setPending(candidate); + armTimer(); + // If there's also an exact match, we *could* fire now — but yazi + // prefers to wait for the longer sequence within the timeout, then + // falls through. We honor that: only fire exact if no prefix. + if (exact.length > 0) { + // ambiguous prefix+exact: keep waiting (e.g. `g` could be gg) + } + return null; + } + + // No longer-match possible: decide on exact. + clearPending(); + if (exact.length === 0) { + // The new stroke might itself begin a fresh sequence. + const fresh = classify([stroke]); + if (fresh.prefix.length > 0) { + setPending([stroke]); + armTimer(); + return null; + } + if (fresh.exact.length > 0) { + // prefer longest-sequence match among fresh.exact + return pickLongest(fresh.exact); + } + return null; + } + return pickLongest(exact); + } + + function pickLongest(names: KeybindActionName[]): KeybindActionName { + const map = resolved(); + let best = names[0]; + let bestLen = 0; + for (const n of names) { + for (const seq of map[n] ?? []) { + if (seq.length > bestLen) { + bestLen = seq.length; + best = n; + } + } + } + return best; + } + + // `isInverting` kept for legacy callers; yazi model has no inverse mod, + // so it always reports false. Migrated callers should use tryMatch(). + function isInverting(_evt: { + name: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; + }): boolean { + return false; + } + + onMount(() => { + load().catch(() => {}); + }); + + return { + get ready() { + return ready(); + }, + get keybinds() { + return store; + }, + get resolved() { + return resolved(); + }, + pending, + match, + tryMatch, + isInverting, + print, + save, + load, + clearPending, + }; + }, + }); diff --git a/src/context/NavigationContext.tsx b/src/context/NavigationContext.tsx index a5e7307..4cf03d3 100644 --- a/src/context/NavigationContext.tsx +++ b/src/context/NavigationContext.tsx @@ -1,73 +1,305 @@ -import { createEffect, createSignal, on } from "solid-js"; +import { createEffect, createSignal, on, batch, createMemo } from "solid-js"; import { createSimpleContext } from "./helper"; -import { TABS, TabsCount, LayerDepths } from "@/utils/navigation"; +import { TABS, TabsCount } from "@/utils/navigation"; -// Page-specific pane counts -const PANE_COUNTS = { - [TABS.FEED]: 1, - [TABS.MYSHOWS]: 2, - [TABS.DISCOVER]: 2, - [TABS.SEARCH]: 3, - [TABS.PLAYER]: 1, - [TABS.SETTINGS]: 5, -}; +// ── Yazi-style navigation state ────────────────────────────────────────────── +// PodTui's interaction model after the yazi redesign. A single source of truth +// for: which tab is active, which pane within a tab is focused (parent | +// current | preview), the current mode (normal/visual/command/input), the +// count register (for `5j` style motions), and the command-bar buffer. +// +// Panes are addressed by index 0..N-1 within the active tab. Each tab declares +// how many panes it has via the PaneSystem registry (see navigation.ts). h/l +// (swipe-prev / swipe-next) move pane focus; j/k move within the focused pane's +// list (handled per-pane via the focusedIndex accessors below). + +export enum NavMode { + NORMAL = "NORMAL", + VISUAL = "VISUAL", + COMMAND = "COMMAND", + INPUT = "INPUT", +} + +/** Slot semantics mirror yazi's three columns. Slots beyond 2 exist for + * tabs that need more panes (e.g. search = query/results/detail). */ +export enum PaneSlot { + PARENT = 0, // left — the container list (e.g. shows) + CURRENT = 1, // middle — the items (e.g. episodes) + PREVIEW = 2, // right — detail of the hovered item +} + +export type PaneId = number; // 0-based index into the active tab's pane list + +// ── Selection store ─────────────────────────────────────────────────────────── +// A Set per (tab, paneKey). `paneKey` is a string each pane uses to namespace +// its selection (e.g. "myshows:episodes"). Visual mode toggles into range +// selection anchored at the focused index. + +type SelectionMap = Record>; + +const HAS_VISUAL = (mode: NavMode) => mode === NavMode.VISUAL; export const { use: useNavigation, provider: NavigationProvider } = - createSimpleContext({ - name: "Navigation", - init: () => { - const [activeTab, setActiveTab] = createSignal(TABS.FEED); - const [activeDepth, setActiveDepth] = createSignal(0); - const [inputFocused, setInputFocused] = createSignal(false); + createSimpleContext({ + name: "Navigation", + init: () => { + const [activeTab, setActiveTab] = createSignal(TABS.FEED); + const [activePane, setActivePane] = createSignal( + PaneSlot.CURRENT, + ); + const [mode, setMode] = createSignal(NavMode.NORMAL); + const [count, setCount] = createSignal(null); + const [inputFocused, setInputFocused] = createSignal(false); - createEffect( - on( - () => activeTab, - () => setActiveDepth(0), - ), - ); + // per-pane focused index (for j/k movement). Keyed by `${tab}:${pane}`. + const [paneIndices, setPaneIndices] = createSignal< + Record + >({}); + const [selections, setSelections] = createSignal({}); + const [visualAnchor, setVisualAnchor] = createSignal<{ + paneKey: string; + index: number; + } | null>(null); - const nextTab = () => { - if (activeTab() >= TabsCount) { - setActiveTab(1); - return; - } - setActiveTab(activeTab() + 1); - }; + const [commandBuffer, setCommandBuffer] = createSignal(""); + const [commandError, setCommandError] = createSignal(null); - const prevTab = () => { - if (activeTab() <= 1) { - setActiveTab(TabsCount); - return; - } - setActiveTab(activeTab() - 1); - }; + // Reset depth/pane/mode on tab change. + createEffect( + on(activeTab, () => { + batch(() => { + setActivePane(PaneSlot.CURRENT); + setMode(NavMode.NORMAL); + setCount(null); + setCommandBuffer(""); + setCommandError(null); + setVisualAnchor(null); + }); + }), + ); - const nextPane = () => { - // Move to next pane within the current tab's pane structure - const count = PANE_COUNTS[activeTab()]; - if (count <= 1) return; // No panes to navigate (feed/player) - setActiveDepth((prev) => (prev % count) + 1); - }; + // ── tab switching ────────────────────────────────────────────────────── + const gotoTab = (tab: TABS) => { + if (tab < 1 || tab > TabsCount) return; + setActiveTab(tab); + }; + const nextTab = () => + setActiveTab((t) => (t >= TabsCount ? 1 : ((t + 1) as TABS))); + const prevTab = () => + setActiveTab((t) => (t <= 1 ? TabsCount : ((t - 1) as TABS))); - const prevPane = () => { - // Move to previous pane within the current tab's pane structure - const count = PANE_COUNTS[activeTab()]; - if (count <= 1) return; // No panes to navigate (feed/player) - setActiveDepth((prev) => (prev - 2 + count) % count + 1); - }; + // ── pane focus ────────────────────────────────────────────────────────── + const setPane = (pane: PaneId) => setActivePane(pane); - return { - activeTab, - activeDepth, - inputFocused, - setActiveTab, - setActiveDepth, - setInputFocused, - nextTab, - prevTab, - nextPane, - prevPane, - }; - }, - }); + /** Move focus to the adjacent pane. `dir` = -1 (left/parent) or +1 + * (right/preview). Clamped to [0, paneCount-1]. */ + const swipe = (dir: -1 | 1, paneCount: number) => { + if (paneCount <= 1) return; + setActivePane((p) => { + const n = Math.max(0, Math.min(paneCount - 1, p + dir)); + return n; + }); + }; + + // ── per-pane focus index ──────────────────────────────────────────────── + const paneKey = (pane: PaneId = activePane()) => `${activeTab()}:${pane}`; + + const focusedIndex = (pane: PaneId = activePane()) => + paneIndices()[paneKey(pane)] ?? 0; + + const setFocusedIndex = (pane: PaneId, index: number) => + setPaneIndices((m) => ({ ...m, [`${activeTab()}:${pane}`]: index })); + + /** Apply a clamped relative motion to the active pane's focus. Returns + * the new index so callers can update their own scroll state. */ + const move = ( + delta: number, + listLen: number, + countOverride?: number, + ): number => { + if (listLen <= 0) return 0; + const steps = countOverride ?? count() ?? 1; + const pane = activePane(); + const cur = focusedIndex(pane); + let next = cur + delta * steps; + // wrap-around like yazi (arrow wraps top<->bottom) + next = ((next % listLen) + listLen) % listLen; + setFocusedIndex(pane, next); + // visual-mode range selection: add newly-traversed items to selection + if (HAS_VISUAL(mode()) && visualAnchor()) { + growVisualSelection(next); + } + return next; + }; + + const gotoIndex = (index: number, listLen: number): number => { + if (listLen <= 0) return 0; + const pane = activePane(); + const next = Math.max(0, Math.min(listLen - 1, index)); + setFocusedIndex(pane, next); + if (HAS_VISUAL(mode()) && visualAnchor()) growVisualSelection(next); + return next; + }; + + // ── selection ─────────────────────────────────────────────────────────── + const selSet = (key: string): Set => + 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 | undefined> = {}; + const registerResolver = ( + key: string, + fn: (i: number) => string | undefined, + ) => { + resolvers[key] = fn; + }; + const growVisualSelection = (index: number) => { + const anchor = visualAnchor(); + if (!anchor) return; + const resolve = resolvers[anchor.paneKey]; + if (!resolve) return; + const lo = Math.min(anchor.index, index); + const hi = Math.max(anchor.index, index); + const ids: string[] = []; + for (let i = lo; i <= hi; i++) { + const id = resolve(i); + if (id) ids.push(id); + } + const key = anchor.paneKey; + setSelections((m) => ({ ...m, [key]: new Set(ids) })); + }; + + // ── modes ──────────────────────────────────────────────────────────────── + const enterCommand = () => { + setMode(NavMode.COMMAND); + setCommandBuffer(""); + setCommandError(null); + }; + const enterInput = () => setMode(NavMode.INPUT); + const exitCommand = () => { + batch(() => { + setMode(NavMode.NORMAL); + setCommandBuffer(""); + setCommandError(null); + }); + }; + const exitVisual = () => { + batch(() => { + setMode(NavMode.NORMAL); + setVisualAnchor(null); + }); + }; + const toNormal = () => { + if (mode() === NavMode.VISUAL) { + clearSelection(); + exitVisual(); + } else { + setMode(NavMode.NORMAL); + } + }; + + // ── command buffer ─────────────────────────────────────────────────────── + const appendCommand = (ch: string) => setCommandBuffer((b) => b + ch); + const backspaceCommand = () => setCommandBuffer((b) => b.slice(0, -1)); + const submitCommand = (): string => { + const cmd = commandBuffer().trim(); + exitCommand(); + return cmd; + }; + + // ── count register ─────────────────────────────────────────────────────── + const pushCountDigit = (d: number) => setCount((c) => (c ?? 0) * 10 + d); + const consumeCount = (): number => { + const c = count(); + setCount(null); + return c ?? 1; + }; + + return { + activeTab, + activePane, + mode, + count, + inputFocused, + commandBuffer, + commandError, + visualAnchor, + selections, + selectedIds, + // tab + setActiveTab: gotoTab, + nextTab, + prevTab, + // pane focus + setActivePane: setPane, + swipe, + // focus index + focusedIndex, + setFocusedIndex, + move, + gotoIndex, + // selection + isSelected, + toggleSelected, + clearSelection, + selectedIdsFor: (key: string) => [...selSet(key)], + registerResolver, + enterVisual, + exitVisual, + // modes + setActiveTabSignal: setActiveTab, + setActiveDepth: setPane, // legacy alias + activeDepth: activePane, // legacy alias + setInputFocused, + nextPane: () => {}, // legacy noop; swipe() replaces this + prevPane: () => {}, + setMode, + enterCommand, + enterInput, + exitCommand, + toNormal, + // command buffer + setCommandBuffer, + appendCommand, + backspaceCommand, + submitCommand, + setCommandError, + // count + pushCountDigit, + consumeCount, + }; + }, + }); diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index 47f9473..a2ab50f 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -1,185 +1,353 @@ /** - * DiscoverPage component - Main discover/browse interface for PodTUI + * DiscoverPage — yazi-style 3-pane view. + * + * pane 0 (parent) — category list (the "containers") + * pane 1 (current) — podcast results for the focused category (landing pane) + * pane 2 (preview) — detail of the focused podcast + subscribe action + * + * The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between + * panes; j/k move within; Enter subscribes to the focused podcast; r refreshes. + * Yazi [1,4,3] grow ratio. yazi-authentic parent|current|preview ordering. */ -import { createSignal, For, Show, onMount } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; +import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover"; +import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; -import { PodcastCard } from "./PodcastCard"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; -import { useNavigation } from "@/context/NavigationContext"; -import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; +import { + useNavigation, + NavMode, + PaneSlot, + type PaneId, +} from "@/context/NavigationContext"; +import { on, off } from "@/utils/event-bus"; +import type { KeybindActionName } from "@/context/KeybindContext"; +import type { Podcast } from "@/types/podcast"; +import { PANE_RATIO } from "@/utils/navigation"; -enum DiscoverPagePaneType { - CATEGORIES = 1, - SHOWS = 2, +export const DiscoverPaneCount = 3; + +function DiscoverPage() { + const discoverStore = useDiscoverStore(); + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const nav = useNavigation(); + + const CATS = PaneSlot.PARENT; // 0 — categories (parent) + const RESULTS = PaneSlot.CURRENT; // 1 — podcast results (landing pane) + const PREVIEW = PaneSlot.PREVIEW; // 2 — detail + subscribe + + const categories = () => DISCOVER_CATEGORIES; + const podcasts = () => discoverStore.filteredPodcasts(); + + const focusedCategory = createMemo(() => { + const list = categories(); + if (list.length === 0) return undefined; + return list[Math.min(nav.focusedIndex(CATS), list.length - 1)]; + }); + + // ── keep category + results focus in range ─────────────────────────────── + const ensureFocus = () => { + const cl = categories(); + if (cl.length > 0 && nav.focusedIndex(CATS) >= cl.length) + nav.setFocusedIndex(CATS, cl.length - 1); + const pl = podcasts(); + if (pl.length > 0 && nav.focusedIndex(RESULTS) >= pl.length) + nav.setFocusedIndex(RESULTS, pl.length - 1); + }; + onMount(ensureFocus); + + const focusedPodcast = createMemo(() => { + const list = podcasts(); + if (list.length === 0) return undefined; + return list[Math.min(nav.focusedIndex(RESULTS), list.length - 1)]; + }); + + // Register a resolver so visual-mode range selection grows by podcast id. + onMount(() => { + nav.registerResolver( + `${nav.activeTab()}:${RESULTS}`, + (i) => podcasts()[i]?.id, + ); + const unsub = on("nav.action", () => { + nav.registerResolver( + `${nav.activeTab()}:${RESULTS}`, + (i) => podcasts()[i]?.id, + ); + }); + onCleanup(() => unsub()); + }); + + // ── helpers ──────────────────────────────────────────────────────────────── + const formatDate = (d: Date) => format(d, "MMM d, yyyy"); + const handleSubscribe = (podcast: Podcast) => { + discoverStore.toggleSubscription(podcast.id); + }; + + // ── nav.action handler ──────────────────────────────────────────────────── + const PAGE_ACTIONS: Partial< + Record void> + > = { + "move-down": (p) => step(p, 1), + "move-up": (p) => step(p, -1), + "jump-down": (p) => step(p, 5), + "jump-up": (p) => step(p, -5), + "page-down": (p) => step(p, 10), + "page-up": (p) => step(p, -10), + "goto-top": (p) => nav.gotoIndex(0, len(p)), + "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), + open: (p) => { + if (p === CATS) { + const c = focusedCategory(); + if (c) discoverStore.setSelectedCategory(c.id); + nav.swipe(1, DiscoverPaneCount); // dive to results + return; + } + if (p === RESULTS) { + const pod = focusedPodcast(); + if (pod) handleSubscribe(pod); + } + }, + "toggle-select": (p) => { + if (p === RESULTS) { + const pod = focusedPodcast(); + if (pod) nav.toggleSelected(pod.id); + } + }, + refresh: () => { + discoverStore.refresh().catch(() => {}); + }, + }; + + function len(pane: PaneId): number { + if (pane === CATS) return categories().length; + if (pane === RESULTS) return podcasts().length; + return 0; + } + function step(pane: PaneId, delta: number) { + nav.move(delta, len(pane)); + if (pane === CATS) { + const c = focusedCategory(); + if (c) discoverStore.setSelectedCategory(c.id); + } + } + + const onAction = (data: { + action: KeybindActionName; + pane: PaneId; + mode: NavMode; + }) => { + ensureFocus(); + const handler = PAGE_ACTIONS[data.action]; + if (handler) handler(data.pane); + }; + onMount(() => { + on("nav.action", onAction); + onCleanup(() => off("nav.action", onAction)); + }); + + // ── render ────────────────────────────────────────────────────────────────── + const isActive = (p: PaneId) => nav.activePane() === p; + const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); + const focusBg = (i: number, pane: PaneId) => + i === nav.focusedIndex(pane) && isActive(pane) + ? theme.primary + : i === nav.focusedIndex(pane) + ? theme.border + : undefined; + const focusFg = (i: number, pane: PaneId) => + i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; + + return ( + + {/* ── pane 0 (parent, left): categories ───────────────────────────── */} + + + Categories + + + + {(cat, index) => { + const selected = () => + cat.id === discoverStore.selectedCategory(); + return ( + { + nav.setActivePane(CATS); + nav.setFocusedIndex(CATS, index()); + discoverStore.setSelectedCategory(cat.id); + }} + > + + {index() === nav.focusedIndex(CATS) ? "❯" : " "} + + {cat.name} + + + * + + + + ); + }} + + + + + {/* ── pane 1 (current, center): results ───────────────────────────── */} + + + + {focusedCategory()?.name ?? "Discover"} · {podcasts().length} + + + + 0} + fallback={ + + No podcasts found. :refresh + + } + > + + {(podcast, index) => ( + { + nav.setActivePane(RESULTS); + nav.setFocusedIndex(RESULTS, index()); + }} + > + + + {index() === nav.focusedIndex(RESULTS) ? "❯" : " "} + + {podcast.title} + + + [+] + + + + + + by {podcast.author} + + + + )} + + + + + + {/* ── pane 2 (preview, right): detail + subscribe ──────────────────── */} + + + Preview + + + + No podcast focused + + } + > + {(pod) => ( + + + {pod().title} + + + by {pod().author} + + + ✓ Subscribed + + + [+] Subscribe (enter) + + + + {pod().description?.slice(0, 400) ?? + "No description available."} + {(pod().description?.length ?? 0) > 400 ? "…" : ""} + + 0}> + + + {(cat) => [{cat}]} + + + + + Feed: {pod().feedUrl} + + + Updated: {formatDate(pod().lastUpdated)} + + + enter: subscribe h/l: panes r: refresh + + )} + + + + + ); } -export const DiscoverPaneCount = 2; -export function DiscoverPage() { - const discoverStore = useDiscoverStore(); - const [showIndex, setShowIndex] = createSignal(0); - const [categoryIndex, setCategoryIndex] = createSignal(0); - const nav = useNavigation(); - const keybind = useKeybinds(); - - onMount(() => { - useKeyboard( - (keyEvent: any) => { - const isDown = keybind.match("down", keyEvent); - const isUp = keybind.match("up", keyEvent); - const isCycle = keybind.match("cycle", keyEvent); - const isSelect = keybind.match("select", keyEvent); - const isInverting = keybind.isInverting(keyEvent); - - if (isSelect) { - const filteredPodcasts = discoverStore.filteredPodcasts(); - if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) { - setShowIndex(showIndex() + 1); - } - return; - } - - // don't handle pane navigation here - unified in App.tsx - if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return; - - const filteredPodcasts = discoverStore.filteredPodcasts(); - if (filteredPodcasts.length === 0) return; - - if (isDown && !isInverting()) { - setShowIndex((i) => (i + 1) % filteredPodcasts.length); - } else if (isUp && isInverting()) { - setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length); - } else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { - setShowIndex((i) => (i + 1) % filteredPodcasts.length); - } else if ((isCycle && isInverting()) || (isUp && isInverting())) { - setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length); - } - }, - { release: false }, - ); - }); - - const handleCategorySelect = (categoryId: string) => { - discoverStore.setSelectedCategory(categoryId); - const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId); - if (index >= 0) setCategoryIndex(index); - setShowIndex(0); - }; - - const handleShowSelect = (index: number) => { - setShowIndex(index); - }; - - const handleSubscribe = (podcast: { id: string }) => { - discoverStore.toggleSubscription(podcast.id); - }; - - const { theme } = useTheme(); - return ( - - - - Categories: - - - - {(category) => { - const isSelected = () => - discoverStore.selectedCategory() === category.id; - - return ( - handleCategorySelect(category.id)} - > - - {category.icon} {category.name} - - - ); - }} - - - - - - false} - primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS} - > - Trending in{" "} - {DISCOVER_CATEGORIES.find( - (c) => c.id === discoverStore.selectedCategory(), - )?.name ?? "All"} - - - - - {discoverStore.filteredPodcasts().length !== 0 ? ( - Loading trending shows... - ) : ( - - No podcasts found in this category. - - )} - - } - when={ - !discoverStore.isLoading() && - discoverStore.filteredPodcasts().length === 0 - } - > - - - - {(podcast, index) => ( - handleShowSelect(index())} - onSubscribe={() => handleSubscribe(podcast)} - /> - )} - - - - - - - - ); -} +export { DiscoverPage }; diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index fbbd6fb..33245d7 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -1,195 +1,460 @@ /** - * FeedPage - Shows latest episodes across all subscribed shows - * Reverse chronological order, grouped by date + * FeedPage — yazi-style 3-pane view of all episodes across subscribed shows. + * + * pane 0 (parent) — subscribed feeds list (the "containers"); an implicit + * "All Feeds" entry at index 0 shows every episode. + * pane 1 (current) — flat episodes list for the focused feed (reverse + * chronological). This is the landing pane. + * pane 2 (preview) — detail of the focused episode. + * + * The Shell resets activePane to CURRENT(1) on tab enter. h/l swipe between + * panes; j/k move within; Enter plays; Space selects. Yazi [1,4,3] grow ratio. */ -import { createSignal, For, Show, onMount } from "solid-js"; +import { + createMemo, + For, + Show, + onMount, + onCleanup, + createEffect, +} from "solid-js"; import { useFeedStore } from "@/stores/feed"; +import { useDownloadStore } from "@/stores/download"; +import { DownloadStatus } from "@/types/episode"; import { format } from "date-fns"; +import { useTheme } from "@/context/ThemeContext"; +import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; +import { + useNavigation, + NavMode, + PaneSlot, + type PaneId, +} from "@/context/NavigationContext"; +import { useAudio } from "@/hooks/useAudio"; +import { on, off } from "@/utils/event-bus"; +import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; -import { useTheme } from "@/context/ThemeContext"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; -import { useNavigation } from "@/context/NavigationContext"; import { LoadingIndicator } from "@/components/LoadingIndicator"; -import { TABS } from "@/utils/navigation"; -import { useKeyboard } from "@opentui/solid"; -import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; +import { PANE_RATIO } from "@/utils/navigation"; -enum FeedPaneType { - FEED = 1, +export const FeedPaneCount = 3; + +type FeedListItem = { kind: "all" } | { kind: "feed"; feed: Feed }; + +function FeedPage() { + const feedStore = useFeedStore(); + const downloadStore = useDownloadStore(); + const audioNav = useAudioNavStore(); + const audio = useAudio(); + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const nav = useNavigation(); + + const FEEDS = PaneSlot.PARENT; // 0 — subscribed feeds (parent) + const EPS = PaneSlot.CURRENT; // 1 — episodes list (landing pane) + const PREV = PaneSlot.PREVIEW; // 2 — episode detail + + // ── feeds pane data ────────────────────────────────────────────────────── + // Index 0 = virtual "All Feeds"; 1..N = subscribed feeds (sorted, pinned first). + const feedList = createMemo(() => { + 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(() => { + 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(() => { + const list = episodes(); + if (list.length === 0) return undefined; + return list[Math.min(nav.focusedIndex(EPS), list.length - 1)]; + }); + + // Keep resolvers fresh so visual-mode range selection grows by id. + onMount(() => { + nav.registerResolver( + `${nav.activeTab()}:${EPS}`, + (i) => episodes()[i]?.episode.id, + ); + const unsub = on("nav.action", () => { + nav.registerResolver( + `${nav.activeTab()}:${EPS}`, + (i) => episodes()[i]?.episode.id, + ); + }); + onCleanup(() => unsub()); + }); + + const ensureFocus = () => { + const eps = episodes(); + if (eps.length > 0 && nav.focusedIndex(EPS) >= eps.length) + nav.setFocusedIndex(EPS, eps.length - 1); + const fl = feedList(); + if (fl.length > 0 && nav.focusedIndex(FEEDS) >= fl.length) + nav.setFocusedIndex(FEEDS, fl.length - 1); + }; + onMount(ensureFocus); + + // ── helpers ──────────────────────────────────────────────────────────────── + const formatDate = (d: Date) => format(d, "MMM d, yyyy"); + const formatDuration = (s: number) => { + const mins = Math.floor(s / 60); + const hrs = Math.floor(mins / 60); + return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; + }; + const downloadLabel = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return "[Q]"; + case DownloadStatus.DOWNLOADING: + return `[${downloadStore.getDownloadProgress(id)}%]`; + case DownloadStatus.COMPLETED: + return "[DL]"; + case DownloadStatus.FAILED: + return "[ERR]"; + default: + return ""; + } + }; + const downloadColor = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return theme.warning; + case DownloadStatus.DOWNLOADING: + return theme.primary; + case DownloadStatus.COMPLETED: + return theme.success; + case DownloadStatus.FAILED: + return theme.error; + default: + return muted(); + } + }; + const playEpisode = (item: EpItem | undefined) => { + if (!item) return; + audio.play(item.episode).catch(() => {}); + audioNav.setSource(AudioSource.FEED); + }; + + // ── nav.action handler ──────────────────────────────────────────────────── + const PAGE_ACTIONS: Partial< + Record void> + > = { + "move-down": (p) => step(p, 1), + "move-up": (p) => step(p, -1), + "jump-down": (p) => step(p, 5), + "jump-up": (p) => step(p, -5), + "page-down": (p) => step(p, 10), + "page-up": (p) => step(p, -10), + "goto-top": (p) => nav.gotoIndex(0, len(p)), + "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), + open: (p) => { + if (p === FEEDS) nav.swipe(1, FeedPaneCount); // dive into episodes + if (p === EPS) playEpisode(focusedItem()); + }, + "toggle-select": (p) => { + if (p === EPS) { + const item = focusedItem(); + if (item) nav.toggleSelected(item.episode.id); + } + }, + refresh: () => { + const item = focusedFeedItem(); + if (item?.kind === "feed") + feedStore.refreshFeed(item.feed.id).catch(() => {}); + else feedStore.refreshAllFeeds().catch(() => {}); + }, + }; + + function len(pane: PaneId): number { + if (pane === FEEDS) return feedList().length; + if (pane === EPS) return episodes().length; + return 0; + } + function step(pane: PaneId, delta: number) { + nav.move(delta, len(pane)); + } + + const onAction = (data: { + action: KeybindActionName; + pane: PaneId; + mode: NavMode; + }) => { + ensureFocus(); + const handler = PAGE_ACTIONS[data.action]; + if (handler) handler(data.pane); + }; + onMount(() => { + on("nav.action", onAction); + onCleanup(() => off("nav.action", onAction)); + }); + + // ── render ────────────────────────────────────────────────────────────────── + const isActive = (p: PaneId) => nav.activePane() === p; + const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); + const focusBg = (i: number, pane: PaneId) => + i === nav.focusedIndex(pane) && isActive(pane) + ? theme.primary + : i === nav.focusedIndex(pane) + ? theme.border + : undefined; + const focusFg = (i: number, pane: PaneId) => + i === nav.focusedIndex(pane) && isActive(pane) ? theme.surface : theme.text; + + return ( + + {/* ── pane 0 (parent, left): feeds ───────────────────────────────────── */} + + + Feeds · {feedList().length - 1} + + + 1} + fallback={ + + + No feeds. Subscribe from Discover/Search. + + + } + > + + {(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 ( + { + nav.setActivePane(FEEDS); + nav.setFocusedIndex(FEEDS, index()); + }} + > + + {index() === nav.focusedIndex(FEEDS) ? "❯" : " "} + + {label()} + + ({count()}) + + + ); + }} + + + + + + {/* ── pane 1 (current, center): episodes ─────────────────────────────── */} + + + + {(() => { + const fi = focusedFeedItem(); + if (fi?.kind === "feed") + return fi.feed.customName || fi.feed.podcast.title; + return "All Episodes"; + })()} · {episodes().length} + + + + 0} + fallback={ + + No episodes. :refresh + + } + > + + {(item, index) => ( + { + nav.setActivePane(EPS); + nav.setFocusedIndex(EPS, index()); + }} + > + + + {index() === nav.focusedIndex(EPS) ? "❯" : " "} + + + {item.episode.episodeNumber + ? `#${item.episode.episodeNumber} ` + : ""} + {item.episode.title} + + + + + {formatDate(item.episode.pubDate)} + + + {formatDuration(item.episode.duration)} + + + {item.feed.customName || item.feed.podcast.title} + + + + + + + {downloadLabel(item.episode.id)} + + + + + )} + + + + + + + + + + + {/* ── pane 2 (preview, right): episode detail ───────────────────────── */} + + + Preview + + + + No episode focused + + } + > + {(item) => ( + + + + {item().episode.episodeNumber + ? `#${item().episode.episodeNumber} ` + : ""} + {item().episode.title} + + + + + {formatDate(item().episode.pubDate)} + + + {formatDuration(item().episode.duration)} + + + + {downloadLabel(item().episode.id)} + + + + + {item().feed.customName || item().feed.podcast.title} + + + by {item().feed.podcast.author} + + + + {item().episode.description?.slice(0, 400) ?? + "No description available."} + {(item().episode.description?.length ?? 0) > 400 ? "…" : ""} + + + enter: play space: select h/l: panes + + )} + + + + + ); } -export const FeedPaneCount = 1; -const ITEMS_PER_BATCH = 50; - -export function FeedPage() { - const feedStore = useFeedStore(); - const nav = useNavigation(); - const { theme } = useTheme(); - const [selectedEpisodeID, setSelectedEpisodeID] = createSignal< - string | undefined - >(); - const allEpisodes = () => feedStore.getAllEpisodesChronological(); - const keybind = useKeybinds(); - const [focusedIndex, setFocusedIndex] = createSignal(0); - - onMount(() => { - useKeyboard( - (keyEvent: any) => { - const isDown = keybind.match("down", keyEvent); - const isUp = keybind.match("up", keyEvent); - const isCycle = keybind.match("cycle", keyEvent); - const isSelect = keybind.match("select", keyEvent); - const isInverting = keybind.isInverting(keyEvent); - - if (isSelect) { - const episodes = allEpisodes(); - if (episodes.length > 0 && episodes[focusedIndex()]) { - setSelectedEpisodeID(episodes[focusedIndex()].episode.id); - } - return; - } - - // don't handle pane navigation here - unified in App.tsx - if (nav.activeDepth() !== FeedPaneType.FEED) return; - - const episodes = allEpisodes(); - if (episodes.length === 0) return; - - if (isDown && !isInverting()) { - setFocusedIndex((i) => (i + 1) % episodes.length); - } else if (isUp && isInverting()) { - setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length); - } else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { - setFocusedIndex((i) => (i + 1) % episodes.length); - } else if ((isCycle && isInverting()) || (isUp && isInverting())) { - setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length); - } - }, - { release: false }, - ); - }); - - const formatDate = (date: Date): string => { - return format(date, "MMM d, yyyy"); - }; - - const groupEpisodesByDate = () => { - const groups: Record> = {}; - - for (const item of allEpisodes()) { - const dateKey = formatDate(new Date(item.episode.pubDate)); - if (!groups[dateKey]) { - groups[dateKey] = []; - } - groups[dateKey].push(item); - } - - return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => { - // Convert date strings back to Date objects for proper chronological sorting - const dateA = new Date(a); - const dateB = new Date(b); - // Sort in descending order (newest first) - return dateB.getTime() - dateA.getTime(); - }); - }; - - const formatDuration = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const hrs = Math.floor(mins / 60); - if (hrs > 0) return `${hrs}h ${mins % 60}m`; - return `${mins}m`; - }; - - return ( - - 0} - fallback={ - - - No episodes yet. Subscribe to shows from Discover or Search. - - - } - > - - - {([date, items]) => ( - - false} primary> - {date} - - - {(item) => { - const isSelected = () => { - if ( - nav.activeTab() == TABS.FEED && - nav.activeDepth() == FeedPaneType.FEED && - selectedEpisodeID() && - selectedEpisodeID() === item.episode.id - ) { - return true; - } - return false; - }; - const isFocused = () => { - const episodes = allEpisodes(); - const currentIndex = episodes.findIndex( - (e: any) => e.episode.id === item.episode.id, - ); - return currentIndex === focusedIndex(); - }; - return ( - { - setSelectedEpisodeID(item.episode.id); - const episodes = allEpisodes(); - setFocusedIndex( - episodes.findIndex((e: any) => e.episode.id === item.episode.id), - ); - }} - > - - {item.episode.title} - - - - {item.feed.podcast.title} - - - {formatDuration(item.episode.duration)} - - - - ); - }} - - - )} - - - - - ); -} +export { FeedPage }; diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index 066dcf1..0b25971 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -1,325 +1,431 @@ /** - * MyShowsPage - Two-panel file-explorer style view - * Left panel: list of subscribed shows - * Right panel: episodes for the selected show + * MyShowsPage — yazi-style 3-pane view (canonical reference migration). + * + * pane 0 (parent) — subscribed shows + * pane 1 (current) — episodes of the focused show + * pane 2 (preview) — detail of the focused episode + * + * Movement (j/k, gg/G, page-jumps) and selection (space, v) are driven by the + * Shell router via the `nav.action` event bus; this page only subscribes and + * translates actions against its own data. h/l swipe between panes is handled + * by the Shell (nav.swipe). The focused row is read from nav.focusedIndex(pane) + * so the page is purely reactive. */ -import { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; +import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { DownloadStatus } from "@/types/episode"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; -import { useNavigation } from "@/context/NavigationContext"; +import { + useNavigation, + NavMode, + PaneSlot, + type PaneId, +} from "@/context/NavigationContext"; +import { useAudio } from "@/hooks/useAudio"; +import { on, off } from "@/utils/event-bus"; +import type { KeybindActionName } from "@/context/KeybindContext"; +import type { Episode } from "@/types/episode"; import { LoadingIndicator } from "@/components/LoadingIndicator"; -import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; +import { PANE_RATIO } from "@/utils/navigation"; -enum MyShowsPaneType { - SHOWS = 1, - EPISODES = 2, -} - -export const MyShowsPaneCount = 2; +export const MyShowsPaneCount = 3; export function MyShowsPage() { - const feedStore = useFeedStore(); - const downloadStore = useDownloadStore(); - const audioNav = useAudioNavStore(); - const [isRefreshing, setIsRefreshing] = createSignal(false); - const [showIndex, setShowIndex] = createSignal(0); - const [episodeIndex, setEpisodeIndex] = createSignal(0); - const { theme } = useTheme(); - const mutedColor = () => theme.muted || theme.text; - const nav = useNavigation(); - const keybind = useKeybinds(); + const feedStore = useFeedStore(); + const downloadStore = useDownloadStore(); + const audioNav = useAudioNavStore(); + const audio = useAudio(); + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const nav = useNavigation(); - onMount(() => { - useKeyboard( - (keyEvent: any) => { - const isDown = keybind.match("down", keyEvent); - const isUp = keybind.match("up", keyEvent); - const isCycle = keybind.match("cycle", keyEvent); - const isSelect = keybind.match("select", keyEvent); - const isInverting = keybind.isInverting(keyEvent); + const SHOWS = PaneSlot.PARENT; + const EPS = PaneSlot.CURRENT; + const PREV = PaneSlot.PREVIEW; - const shows = feedStore.getFilteredFeeds(); - const episodesList = episodes(); + const shows = () => feedStore.getFilteredFeeds(); - if (isSelect) { - if (shows.length > 0 && showIndex() < shows.length) { - setShowIndex(showIndex() + 1); - } - if (episodesList.length > 0 && episodeIndex() < episodesList.length) { - setEpisodeIndex(episodeIndex() + 1); - } - return; - } + // The selected show tracks the focused row of pane 0. + const selectedShow = createMemo(() => { + const list = shows(); + if (list.length === 0) return undefined; + const idx = Math.min(nav.focusedIndex(SHOWS), list.length - 1); + return list[idx]; + }); - // don't handle pane navigation here - unified in App.tsx - if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return; + const episodes = createMemo(() => { + const show = selectedShow(); + if (!show) return [] as Episode[]; + return [...show.episodes].sort( + (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), + ); + }); - if (episodesList.length > 0) { - if (isDown && !isInverting()) { - setEpisodeIndex((i) => (i + 1) % episodesList.length); - } else if (isUp && isInverting()) { - setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length); - } else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { - setEpisodeIndex((i) => (i + 1) % episodesList.length); - } else if ((isCycle && isInverting()) || (isUp && isInverting())) { - setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length); - } - } - }, - { release: false }, - ); - }); + // Register a resolver so visual-mode range selection grows by episode id. + onMount(() => { + nav.registerResolver(`${nav.activeTab()}:${EPS}`, (i) => episodes()[i]?.id); + // keep the resolver fresh as the episode list changes + const unsub = on("nav.action", () => { + nav.registerResolver( + `${nav.activeTab()}:${EPS}`, + (i) => episodes()[i]?.id, + ); + }); + onCleanup(() => unsub()); + }); - /** Threshold: load more when within this many items of the end */ - const LOAD_MORE_THRESHOLD = 5; + // Keep shows-focus in range after feeds load/change. + const ensureShowsFocus = () => { + const list = shows(); + if (list.length === 0) return; + const cur = nav.focusedIndex(SHOWS); + if (cur >= list.length) nav.setFocusedIndex(SHOWS, list.length - 1); + }; + onMount(ensureShowsFocus); - const shows = () => feedStore.getFilteredFeeds(); + // When the show changes, reset episode focus + set audio-nav source + show count. + const onShowChanged = () => { + const show = selectedShow(); + if (!show) return; + if (nav.focusedIndex(EPS) > episodes().length - 1) + nav.setFocusedIndex(EPS, 0); + audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id); + }; + onMount(onShowChanged); - const selectedShow = createMemo(() => { - return shows()[0]; //TODO: Integrate with locally handled keyboard navigation - }); + const focusedEpisode = createMemo(() => { + const eps = episodes(); + if (eps.length === 0) return undefined; + const idx = Math.min(nav.focusedIndex(EPS), eps.length - 1); + return eps[idx]; + }); - const episodes = createMemo(() => { - const show = selectedShow(); - if (!show) return []; - return [...show.episodes].sort( - (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), - ); - }); + // ── helpers ───────────────────────────────────────────────────────────────── + const formatDate = (d: Date) => format(d, "MMM d, yyyy"); + const formatDuration = (s: number) => { + const mins = Math.floor(s / 60); + const hrs = Math.floor(mins / 60); + return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; + }; + const downloadLabel = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return "[Q]"; + case DownloadStatus.DOWNLOADING: + return `[${downloadStore.getDownloadProgress(id)}%]`; + case DownloadStatus.COMPLETED: + return "[DL]"; + case DownloadStatus.FAILED: + return "[ERR]"; + default: + return ""; + } + }; + const downloadColor = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return theme.warning; + case DownloadStatus.DOWNLOADING: + return theme.primary; + case DownloadStatus.COMPLETED: + return theme.success; + case DownloadStatus.FAILED: + return theme.error; + default: + return muted(); + } + }; - const formatDate = (date: Date): string => { - return format(date, "MMM d, yyyy"); - }; + const playEpisode = (ep: Episode) => { + audio.play(ep).catch(() => {}); + audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id); + }; - const formatDuration = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const hrs = Math.floor(mins / 60); - if (hrs > 0) return `${hrs}h ${mins % 60}m`; - return `${mins}m`; - }; + // ── nav.action handler ────────────────────────────────────────────────────── + const PAGE_ACTIONS: Partial< + Record void> + > = { + "move-down": (p) => step(p, 1), + "move-up": (p) => step(p, -1), + "jump-down": (p) => step(p, 5), + "jump-up": (p) => step(p, -5), + "page-down": (p) => step(p, 10), + "page-up": (p) => step(p, -10), + "goto-top": (p) => nav.gotoIndex(0, len(p)), + "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), + open: (p) => { + if (p === SHOWS) { + nav.swipe(1, MyShowsPaneCount); + onShowChanged(); + } else if (p === EPS) { + const ep = focusedEpisode(); + if (ep) playEpisode(ep); + } + }, + "toggle-select": (p) => { + if (p === EPS) { + const ep = focusedEpisode(); + if (ep) nav.toggleSelected(ep.id); + } + }, + refresh: () => { + const show = selectedShow(); + if (show) feedStore.refreshFeed(show.id).catch(() => {}); + }, + }; - /** Get download status label for an episode */ - const downloadLabel = (episodeId: string): string => { - const status = downloadStore.getDownloadStatus(episodeId); - switch (status) { - case DownloadStatus.QUEUED: - return "[Q]"; - case DownloadStatus.DOWNLOADING: { - const pct = downloadStore.getDownloadProgress(episodeId); - return `[${pct}%]`; - } - case DownloadStatus.COMPLETED: - return "[DL]"; - case DownloadStatus.FAILED: - return "[ERR]"; - default: - return ""; - } - }; + function len(pane: PaneId): number { + if (pane === SHOWS) return shows().length; + if (pane === EPS) return episodes().length; + return 0; + } + function step(pane: PaneId, delta: number) { + nav.move(delta, len(pane)); + if (pane === SHOWS) { + // clamp episode focus + re-resolve after show change + nav.setFocusedIndex( + EPS, + Math.min(nav.focusedIndex(EPS), Math.max(0, episodes().length - 1)), + ); + onShowChanged(); + } + } - const handleRefresh = async () => { - const show = selectedShow(); - if (!show) return; - setIsRefreshing(true); - await feedStore.refreshFeed(show.id); - setIsRefreshing(false); - }; + const onAction = (data: { + action: KeybindActionName; + pane: PaneId; + mode: NavMode; + }) => { + // Only react when our tab is active. + // (Shell always emits; router guarantees our tab is active.) + ensureShowsFocus(); + const handler = PAGE_ACTIONS[data.action]; + if (handler) handler(data.pane); + // visual selection growth is handled inside nav.move/registerResolver + }; - const handleUnsubscribe = () => { - const show = selectedShow(); - if (!show) return; - feedStore.removeFeed(show.id); - setShowIndex((i) => Math.max(0, i - 1)); - setEpisodeIndex(0); - }; + onMount(() => { + on("nav.action", onAction); + onCleanup(() => off("nav.action", onAction)); + }); - /** Get download status color */ - const downloadColor = (episodeId: string): string => { - const status = downloadStore.getDownloadStatus(episodeId); - switch (status) { - case DownloadStatus.QUEUED: - return theme.warning.toString(); - case DownloadStatus.DOWNLOADING: - return theme.primary.toString(); - case DownloadStatus.COMPLETED: - return theme.success.toString(); - case DownloadStatus.FAILED: - return theme.error.toString(); - default: - return mutedColor().toString(); - } - }; + // ── render ────────────────────────────────────────────────────────────────── + const isActive = (p: PaneId) => nav.activePane() === p; + const border = (p: PaneId) => (isActive(p) ? theme.accent : theme.border); - return ( - - - - Refreshing... - - 0} - fallback={ - - - No shows yet. Subscribe from Discover or Search. - - - } - > - - - {(feed, index) => ( - { - setShowIndex(index()); - setEpisodeIndex(0); - audioNav.setSource( - AudioSource.MY_SHOWS, - selectedShow()?.podcast.id, - ); - }} - > - - {index() === showIndex() ? ">" : " "} - - - {feed.customName || feed.podcast.title} - - - ({feed.episodes.length}) - - - )} - - - - - - - Select a show - - } - > - 0} - fallback={ - - No episodes. Press [r] to refresh. - - } - > - - - {(episode, index) => ( - setEpisodeIndex(index())} - > - - - {index() === episodeIndex() ? ">" : " "} - - - {episode.episodeNumber - ? `#${episode.episodeNumber} ` - : ""} - {episode.title} - - - - - {formatDate(episode.pubDate)} - - - {formatDuration(episode.duration)} - - - - {downloadLabel(episode.id)} - - - - - )} - - - - - - - - - Scroll down for more episodes - - - - - - - - ); + 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 ( + + {/* ── pane 0: shows ─────────────────────────────────────────────────────── */} + + + Shows ({shows().length}) + + + 0} + fallback={ + + + No shows. Subscribe from Discover/Search. + + + } + > + + {(feed, index) => ( + { + nav.setActivePane(SHOWS); + nav.setFocusedIndex(SHOWS, index()); + onShowChanged(); + }} + > + + {index() === nav.focusedIndex(SHOWS) ? "❯" : " "} + + + {feed.customName || feed.podcast.title} + + + ({feed.episodes.length}) + + + )} + + + + + + {/* ── pane 1: episodes ──────────────────────────────────────────────────── */} + + + + {selectedShow()?.customName || + selectedShow()?.podcast.title || + "Episodes"}{" "} + · {episodes().length} + + + + 0} + fallback={ + + No episodes. :refresh + + } + > + + {(ep, index) => ( + { + nav.setActivePane(EPS); + nav.setFocusedIndex(EPS, index()); + }} + > + + + {index() === nav.focusedIndex(EPS) ? "❯" : " "} + + + {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} + {ep.title} + + + + + {formatDate(ep.pubDate)} + + + {formatDuration(ep.duration)} + + + + + + + {downloadLabel(ep.id)} + + + + + )} + + + + + + + + + + + {/* ── pane 2: preview ───────────────────────────────────────────────────── */} + + + Preview + + + + No episode focused + + } + > + {(ep) => ( + + + + {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} + {ep().title} + + + + {formatDate(ep().pubDate)} + {formatDuration(ep().duration)} + + + {downloadLabel(ep().id)} + + + + + by {selectedShow()!.podcast.author} + + + + {ep().description?.slice(0, 400) ?? + "No description available."} + {(ep().description?.length ?? 0) > 400 ? "…" : ""} + + + enter: play space: select h/l: panes + + )} + + + + + ); } diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index d4a4654..0b703ff 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -1,112 +1,122 @@ +/** + * PlayerPage — single-pane audio now-playing view. + * + * Audio transport (play/pause, next/prev, seek) is handled globally by the + * Shell router (P/N/B/). This page renders a single rich pane showing the + * current episode, waveform, and playback controls. Panes/swipe do nothing + * (PaneCount=1). + */ + +import { Show } from "solid-js"; import { PlaybackControls } from "./PlaybackControls"; import { RealtimeWaveform } from "./RealtimeWaveform"; import { useAudio } from "@/hooks/useAudio"; import { useAppStore } from "@/stores/app"; import { useTheme } from "@/context/ThemeContext"; import { useNavigation } from "@/context/NavigationContext"; -import { useKeybinds } from "@/context/KeybindContext"; -import { useKeyboard } from "@opentui/solid"; -import { onMount } from "solid-js"; -enum PlayerPaneType { - PLAYER = 1, -} export const PlayerPaneCount = 1; export function PlayerPage() { - const audio = useAudio(); - const { theme } = useTheme(); - const nav = useNavigation(); + const audio = useAudio(); + const { theme } = useTheme(); + const nav = useNavigation(); + const muted = () => theme.muted || theme.text; - const keybind = useKeybinds(); + // Single pane — always active. + const isActive = () => true; + const border = () => theme.accent; - onMount(() => { - useKeyboard( - (keyEvent: any) => { - const isInverting = keybind.isInverting(keyEvent); + const progressPercent = () => { + const d = audio.duration(); + if (d <= 0) return 0; + return Math.min(100, Math.round((audio.position() / d) * 100)); + }; - if (keybind.match("audio-toggle", keyEvent)) { - audio.togglePlayback(); - return; - } + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${String(s).padStart(2, "0")}`; + }; - if (keybind.match("audio-seek-forward", keyEvent)) { - audio.seek(audio.currentEpisode()?.duration ?? 0); - return; - } + return ( + + {/* ── pane 0: now playing ─────────────────────────────────────────── */} + + Player + + + + + + Now Playing + + + {formatTime(audio.position())} / {formatTime(audio.duration())} ( + {progressPercent()}%) + + - if (keybind.match("audio-seek-backward", keyEvent)) { - audio.seek(0); - return; - } - }, - { release: false }, - ); - }); + + {(err) => {err()}} + - const progressPercent = () => { - const d = audio.duration(); - if (d <= 0) return 0; - return Math.min(100, Math.round((audio.position() / d) * 100)); - }; + + No episode loaded. + + } + > + {(ep) => ( + + + {ep().title} + + + {ep().description?.slice(0, 500) ?? + "No description available."} + - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${String(s).padStart(2, "0")}`; - }; + { + const viz = useAppStore().state().settings.visualizer; + return { + bars: viz.bars, + noiseReduction: viz.noiseReduction, + lowCutOff: viz.lowCutOff, + highCutOff: viz.highCutOff, + }; + })()} + /> + + )} + - return ( - - - - Now Playing - - - {formatTime(audio.position())} / {formatTime(audio.duration())} ( - {progressPercent()}%) - - + audio.seek(0)} + onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} + onSpeedChange={(s: number) => audio.setSpeed(s)} + onVolumeChange={(v: number) => audio.setVolume(v)} + /> - {audio.error() && {audio.error()}} - - - - {audio.currentEpisode()?.title} - - {audio.currentEpisode()?.description} - - { - const viz = useAppStore().state().settings.visualizer; - return { - bars: viz.bars, - noiseReduction: viz.noiseReduction, - lowCutOff: viz.lowCutOff, - highCutOff: viz.highCutOff, - }; - })()} - /> - - - audio.seek(0)} - onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows) - onSpeedChange={(s: number) => audio.setSpeed(s)} - onVolumeChange={(v: number) => audio.setVolume(v)} - /> - - ); + + {"P play/pause N next B prev + + + + ); } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 6b767b5..c477590 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,210 +1,395 @@ /** - * SearchPage component - Main search interface for PodTUI + * SearchPage — yazi-style 3-pane view. + * + * pane 0 (parent) — query input with recent-search history (clickable) + * pane 1 (current) — search results list (navigate j/k) + * pane 2 (preview) — detail of the focused search result + * + * The Shell resets activePane to CURRENT(1) on tab enter so the user lands on + * the results pane. Swipe left (h) to pane 0 to type a query — the Shell + * router skips keys while `nav.inputFocused()` is true so the `` + * element captures typing natively. Press Enter (onSubmit) to search and + * auto-swipe to the results pane. */ -import { createSignal, createEffect, Show, onMount } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; +import { + createSignal, + createMemo, + createEffect, + For, + Show, + onMount, + onCleanup, +} from "solid-js"; import { useSearchStore } from "@/stores/search"; -import { SearchResults } from "./SearchResults"; -import { SearchHistory } from "./SearchHistory"; -import type { SearchResult } from "@/types/source"; -import { MyShowsPage } from "../MyShows/MyShowsPage"; +import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; -import { useNavigation } from "@/context/NavigationContext"; -import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; +import { + useNavigation, + NavMode, + PaneSlot, + type PaneId, +} from "@/context/NavigationContext"; +import { on, off } from "@/utils/event-bus"; +import type { KeybindActionName } from "@/context/KeybindContext"; +import type { SearchResult } from "@/types/source"; +import { PANE_RATIO } from "@/utils/navigation"; -enum SearchPaneType { - INPUT = 1, - RESULTS = 2, - HISTORY = 3, -} export const SearchPaneCount = 3; -export function SearchPage() { - const searchStore = useSearchStore(); - const [inputValue, setInputValue] = createSignal(""); - const [resultIndex, setResultIndex] = createSignal(0); - const [historyIndex, setHistoryIndex] = createSignal(0); - const { theme } = useTheme(); - const nav = useNavigation(); - const keybind = useKeybinds(); +function SearchPage() { + const searchStore = useSearchStore(); + const [inputValue, setInputValue] = createSignal(""); + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const nav = useNavigation(); - onMount(() => { - useKeyboard( - (keyEvent: any) => { - const isDown = keybind.match("down", keyEvent); - const isUp = keybind.match("up", keyEvent); - const isCycle = keybind.match("cycle", keyEvent); - const isSelect = keybind.match("select", keyEvent); - const isInverting = keybind.isInverting(keyEvent); + const INPUT = PaneSlot.PARENT; // 0 + const RESULTS = PaneSlot.CURRENT; // 1 + const DETAIL = PaneSlot.PREVIEW; // 2 - if (isSelect) { - const results = searchStore.results(); - if (results.length > 0 && resultIndex() < results.length) { - setResultIndex(resultIndex() + 1); - } - return; - } + const results = () => searchStore.results(); - // don't handle pane navigation here - unified in App.tsx - if (nav.activeDepth() !== SearchPaneType.RESULTS) return; + // The focused result tracks pane 1's focused row. + const focusedResult = createMemo(() => { + const list = results(); + if (list.length === 0) return undefined; + const idx = Math.min(nav.focusedIndex(RESULTS), list.length - 1); + return list[idx]; + }); - const results = searchStore.results(); - if (results.length === 0) return; + // Register a resolver so visual-mode range selection grows by result id. + onMount(() => { + nav.registerResolver( + `${nav.activeTab()}:${RESULTS}`, + (i) => results()[i]?.podcast.id, + ); + const unsub = on("nav.action", () => { + nav.registerResolver( + `${nav.activeTab()}:${RESULTS}`, + (i) => results()[i]?.podcast.id, + ); + }); + onCleanup(() => unsub()); + }); - if (isDown && !isInverting()) { - setResultIndex((i) => (i + 1) % results.length); - } else if (isUp && isInverting()) { - setResultIndex((i) => (i - 1 + results.length) % results.length); - } else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { - setResultIndex((i) => (i + 1) % results.length); - } else if ((isCycle && isInverting()) || (isUp && isInverting())) { - setResultIndex((i) => (i - 1 + results.length) % results.length); - } - }, - { release: false }, - ); - }); + // Keep results focus in range after searches complete. + const ensureFocus = () => { + const list = results(); + if (list.length === 0) return; + const cur = nav.focusedIndex(RESULTS); + if (cur >= list.length) nav.setFocusedIndex(RESULTS, list.length - 1); + }; + onMount(ensureFocus); - const handleSearch = async () => { - const query = inputValue().trim(); - if (query) { - await searchStore.search(query); - if (searchStore.results().length > 0) { - //setFocusArea("results"); //TODO: move level - setResultIndex(0); - } - } - }; + // ── input pane: set inputFocused so Shell router yields keys to ───── + createEffect(() => { + const isInputPane = nav.activePane() === INPUT; + nav.setInputFocused(isInputPane); + }); + onMount(() => { + onCleanup(() => nav.setInputFocused(false)); + }); - const handleHistorySelect = async (query: string) => { - setInputValue(query); - await searchStore.search(query); - if (searchStore.results().length > 0) { - //setFocusArea("results"); //TODO: move level - setResultIndex(0); - } - }; + // ── helpers ───────────────────────────────────────────────────────────────── + const formatDate = (d: Date) => format(d, "MMM d, yyyy"); - const handleResultSelect = (result: SearchResult) => { - //props.onSubscribe?.(result); - searchStore.markSubscribed(result.podcast.id); - }; + const handleSubmit = () => { + const query = inputValue().trim(); + if (!query) return; + searchStore.search(query).catch(() => {}); + nav.setFocusedIndex(RESULTS, 0); + nav.setActivePane(RESULTS); + }; - return ( - - {/* Search Header */} - - - Search Podcasts - + const handleHistorySelect = (query: string) => { + setInputValue(query); + searchStore.search(query).catch(() => {}); + nav.setFocusedIndex(RESULTS, 0); + nav.setActivePane(RESULTS); + }; - {/* Search Input */} - - Search: - { - setInputValue(value); - }} - placeholder="Enter podcast name, topic, or author..." - focused={nav.activeDepth() === SearchPaneType.INPUT} - width={50} - /> - - [Enter] Search - - + const handleSubscribe = (result: SearchResult) => { + searchStore.markSubscribed(result.podcast.id); + }; - {/* Status */} - - Searching... - - - {searchStore.error()} - - + // ── nav.action handler ────────────────────────────────────────────────────── + const PAGE_ACTIONS: Partial< + Record void> + > = { + "move-down": (p) => step(p, 1), + "move-up": (p) => step(p, -1), + "jump-down": (p) => step(p, 5), + "jump-up": (p) => step(p, -5), + "page-down": (p) => step(p, 10), + "page-up": (p) => step(p, -10), + "goto-top": (p) => nav.gotoIndex(0, len(p)), + "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), + open: (p) => { + if (p === RESULTS || p === DETAIL) { + const result = focusedResult(); + if (result) handleSubscribe(result); + } + }, + "toggle-select": (p) => { + if (p === RESULTS) { + const result = focusedResult(); + if (result) nav.toggleSelected(result.podcast.id); + } + }, + search: () => { + nav.setActivePane(INPUT); + }, + refresh: () => { + if (inputValue().trim()) { + searchStore.search(inputValue().trim()).catch(() => {}); + } + }, + }; - {/* Main Content - Results or History */} - - {/* Results Panel */} - - - - Results ({searchStore.results().length}) - - - 0} - fallback={ - - - {searchStore.query() - ? "No results found" - : "Enter a search term to find podcasts"} - - - } - > - - - + function len(pane: PaneId): number { + if (pane === RESULTS) return results().length; + return 0; + } + function step(pane: PaneId, delta: number) { + nav.move(delta, len(pane)); + } - {/* History Sidebar */} - - - - - History - - - - - - - - ); + 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 ( + + {/* ── pane 0: query input ──────────────────────────────────────────────── */} + + + Search + + + + + Query: + handleSubmit()} + placeholder="Enter podcast name..." + focused={isActive(INPUT)} + width={28} + /> + + Enter to search · h/l: panes + + + Searching... + + + {searchStore.error()} + + + + Recent + 0} + fallback={No recent searches} + > + + {(query) => ( + handleHistorySelect(query)} + > + + {">"} {query} + + + )} + + + + + + + {/* ── pane 1: results ──────────────────────────────────────────────────── */} + + + Results · {results().length} + + + 0} + fallback={ + + + {searchStore.query() + ? "No results found" + : "Enter a search term to find podcasts"} + + + } + > + + {(result, index) => ( + { + nav.setActivePane(RESULTS); + nav.setFocusedIndex(RESULTS, index()); + }} + > + + + {index() === nav.focusedIndex(RESULTS) ? "❯" : " "} + + + {result.podcast.title} + + + + [+] + + + + + + by {result.podcast.author} + + + + )} + + + + + + {/* ── pane 2: detail ───────────────────────────────────────────────────── */} + + + Detail + + + + No result focused + + } + > + {(result) => ( + + + {result().podcast.title} + + + + by {result().podcast.author} + + + + + {result().podcast.description!.slice(0, 400) ?? + "No description available."} + {(result().podcast.description?.length ?? 0) > 400 + ? "…" + : ""} + + + + 0}> + + + {(cat) => [{cat}]} + + + + + Feed: {result().podcast.feedUrl} + + Updated: {formatDate(result().podcast.lastUpdated)} + + + + Source: {result().sourceName} + + + + + [+] Subscribe (enter) + + + Already subscribed + + + enter: subscribe h/l: panes + + )} + + + + + ); } + +export { SearchPage }; diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 937fb6f..74c4d6c 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -1,119 +1,185 @@ -import { createSignal, For, onMount } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; +/** + * SettingsPage — yazi-style 2-pane view. + * + * pane 0 (parent) — section list (Sync, Sources, Preferences, ...) + * pane 1 (current) — active panel for the focused section + * + * Movement (j/k, gg/G, page-jumps) on pane 0 navigates the section list. + * The panel (pane 1) reactively shows the focused section's content. + * Audio transport and tab/pane swipes are handled by the Shell router. + */ + +import { For, Show, onMount, onCleanup } from "solid-js"; import { SourceManager } from "./SourceManager"; -import { useTheme } from "@/context/ThemeContext"; import { PreferencesPanel } from "./PreferencesPanel"; import { SyncPanel } from "./SyncPanel"; import { VisualizerSettings } from "./VisualizerSettings"; -import { useNavigation } from "@/context/NavigationContext"; -import { KeybindProvider, useKeybinds } from "@/context/KeybindContext"; +import { useTheme } from "@/context/ThemeContext"; +import { + useNavigation, + NavMode, + PaneSlot, + type PaneId, +} from "@/context/NavigationContext"; +import { on, off } from "@/utils/event-bus"; +import type { KeybindActionName } from "@/context/KeybindContext"; +import { PANE_RATIO } from "@/utils/navigation"; -enum SettingsPaneType { - SYNC = 1, - SOURCES = 2, - PREFERENCES = 3, - VISUALIZER = 4, - ACCOUNT = 5, -} -export const SettingsPaneCount = 5; +export const SettingsPaneCount = 2; -const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [ - { id: SettingsPaneType.SYNC, label: "Sync" }, - { id: SettingsPaneType.SOURCES, label: "Sources" }, - { id: SettingsPaneType.PREFERENCES, label: "Preferences" }, - { id: SettingsPaneType.VISUALIZER, label: "Visualizer" }, - { id: SettingsPaneType.ACCOUNT, label: "Account" }, -]; +const SECTIONS = [ + { id: 0, label: "Sync" }, + { id: 1, label: "Sources" }, + { id: 2, label: "Preferences" }, + { id: 3, label: "Visualizer" }, + { id: 4, label: "Account" }, +] as const; export function SettingsPage() { - const { theme } = useTheme(); - const nav = useNavigation(); - const keybind = useKeybinds(); + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const nav = useNavigation(); - // Helper function to check if a depth is active - const isActive = (depth: SettingsPaneType): boolean => { - return nav.activeDepth() === depth; - }; + const SECTIONS_PANE = PaneSlot.PARENT; // 0 + const PANEL = PaneSlot.CURRENT; // 1 - // Helper function to get the current depth as a number - const currentDepth = () => nav.activeDepth() as number; + // The focused section tracks pane 0's focused index. + const focusedSection = () => { + const idx = nav.focusedIndex(SECTIONS_PANE); + return SECTIONS[Math.min(idx, SECTIONS.length - 1)] ?? SECTIONS[0]; + }; - onMount(() => { - useKeyboard( - (keyEvent: any) => { - const isDown = keybind.match("down", keyEvent); - const isUp = keybind.match("up", keyEvent); - const isCycle = keybind.match("cycle", keyEvent); - const isSelect = keybind.match("select", keyEvent); - const isInverting = keybind.isInverting(keyEvent); + // Register a resolver so visual-mode range selection grows by section id. + onMount(() => { + nav.registerResolver(`${nav.activeTab()}:${SECTIONS_PANE}`, (i) => + SECTIONS[Math.min(i, SECTIONS.length - 1)]?.id.toString(), + ); + }); - // don't handle pane navigation here - unified in App.tsx - if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return; + // ── nav.action handler ────────────────────────────────────────────────────── + const PAGE_ACTIONS: Partial< + Record void> + > = { + "move-down": (p) => step(p, 1), + "move-up": (p) => step(p, -1), + "jump-down": (p) => step(p, 5), + "jump-up": (p) => step(p, -5), + "page-down": (p) => step(p, 10), + "page-up": (p) => step(p, -10), + "goto-top": (p) => nav.gotoIndex(0, len(p)), + "goto-bottom": (p) => nav.gotoIndex(len(p) - 1, len(p)), + open: (p) => { + if (p === SECTIONS_PANE) { + nav.swipe(1, SettingsPaneCount); + } + }, + }; - if (isDown && !isInverting()) { - nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1); - } else if (isUp && isInverting()) { - nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1); - } else if ((isCycle && !isInverting()) || (isDown && !isInverting())) { - nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1); - } else if ((isCycle && isInverting()) || (isUp && isInverting())) { - nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1); - } - }, - { release: false }, - ); - }); + function len(pane: PaneId): number { + if (pane === SECTIONS_PANE) return SECTIONS.length; + return 0; + } + function step(pane: PaneId, delta: number) { + nav.move(delta, len(pane)); + } - return ( - - - - {(section, index) => ( - nav.setActiveDepth(section.id)} - > - - [{index() + 1}] {section.label} - - - )} - - + const onAction = (data: { + action: KeybindActionName; + pane: PaneId; + mode: NavMode; + }) => { + const handler = PAGE_ACTIONS[data.action]; + if (handler) handler(data.pane); + }; - - {isActive(SettingsPaneType.SYNC) && } - {isActive(SettingsPaneType.SOURCES) && ( - - )} - {isActive(SettingsPaneType.PREFERENCES) && ( - - )} - {isActive(SettingsPaneType.VISUALIZER) && ( - - )} - {isActive(SettingsPaneType.ACCOUNT) && ( - - Account - - )} - - - ); + 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 ( + + {/* ── pane 0: sections ─────────────────────────────────────────────────── */} + + + Settings + + + + {(section, index) => ( + { + nav.setActivePane(SECTIONS_PANE); + nav.setFocusedIndex(SECTIONS_PANE, index()); + }} + > + + {index() === nav.focusedIndex(SECTIONS_PANE) ? "❯" : " "} + + + {section.label} + + + )} + + + + + {/* ── pane 1: panel ─────────────────────────────────────────────────────── */} + + + {focusedSection().label} + + + + + + + + + + + + + + + + + Account settings (not yet implemented) + + + + + + ); } diff --git a/src/stores/search.ts b/src/stores/search.ts index ba42afd..3828eac 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -3,185 +3,201 @@ * Manages search state, history, and results */ -import { createSignal } from "solid-js" -import { searchPodcasts } from "../utils/search" -import { useFeedStore } from "./feed" -import type { SearchResult } from "../types/source" +import { createSignal } from "solid-js"; +import { searchPodcasts } from "../utils/search"; +import { useFeedStore } from "./feed"; +import type { SearchResult } from "../types/source"; -const STORAGE_KEY = "podtui_search_history" -const MAX_HISTORY = 20 +const STORAGE_KEY = "podtui_search_history"; +const MAX_HISTORY = 20; export interface SearchState { - query: string - isSearching: boolean - results: SearchResult[] - error: string | null + query: string; + isSearching: boolean; + results: SearchResult[]; + error: string | null; } -const CACHE_TTL = 1000 * 60 * 5 +const CACHE_TTL = 1000 * 60 * 5; /** Load search history from localStorage */ function loadHistory(): string[] { - if (typeof localStorage === "undefined") return [] - try { - const stored = localStorage.getItem(STORAGE_KEY) - return stored ? JSON.parse(stored) : [] - } catch { - return [] - } + if (typeof localStorage === "undefined") return []; + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } } /** Save search history to localStorage */ function saveHistory(history: string[]): void { - if (typeof localStorage === "undefined") return - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(history)) - } catch { - // Ignore errors - } + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + } catch { + // Ignore errors + } } /** Create search store */ export function createSearchStore() { - const feedStore = useFeedStore() - const [query, setQuery] = createSignal("") - const [isSearching, setIsSearching] = createSignal(false) - const [results, setResults] = createSignal([]) - const [error, setError] = createSignal(null) - const [history, setHistory] = createSignal(loadHistory()) - const [selectedSources, setSelectedSources] = createSignal([]) + const feedStore = useFeedStore(); + const [query, setQuery] = createSignal(""); + const [isSearching, setIsSearching] = createSignal(false); + const [results, setResults] = createSignal([]); + const [error, setError] = createSignal(null); + const [history, setHistory] = createSignal(loadHistory()); + const [selectedSources, setSelectedSources] = createSignal([]); - const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => { - const feeds = feedStore.feeds() - const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl)) - const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id)) + const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => { + const feeds = feedStore.feeds(); + const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl)); + const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id)); - return items.map((item) => ({ - ...item, - podcast: { - ...item.podcast, - isSubscribed: - item.podcast.isSubscribed || - subscribedUrls.has(item.podcast.feedUrl) || - subscribedIds.has(item.podcast.id), - }, - })) - } + return items.map((item) => ({ + ...item, + podcast: { + ...item.podcast, + isSubscribed: + item.podcast.isSubscribed || + subscribedUrls.has(item.podcast.feedUrl) || + subscribedIds.has(item.podcast.id), + }, + })); + }; - /** Perform search (multi-source implementation) */ - const search = async (searchQuery: string): Promise => { - const q = searchQuery.trim() - if (!q) { - setResults([]) - return - } + /** Perform search (multi-source implementation) */ + const search = async (searchQuery: string): Promise => { + const q = searchQuery.trim(); + if (!q) { + setResults([]); + return; + } - setQuery(q) - setIsSearching(true) - setError(null) + setQuery(q); + setIsSearching(true); + setError(null); - // Add to history - addToHistory(q) + // Add to history + addToHistory(q); - try { - const sources = feedStore.sources() - const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id) - const sourceIds = selectedSources().length > 0 - ? selectedSources() - : enabledSourceIds + try { + const sources = feedStore.sources(); + const enabledSourceIds = sources + .filter((s) => s.enabled) + .map((s) => s.id); + const sourceIds = + selectedSources().length > 0 ? selectedSources() : enabledSourceIds; - const searchResults = await searchPodcasts(q, sourceIds, sources, { - cacheTtl: CACHE_TTL, - }) + // Empty query guard already returned above; if there are no enabled + // sources, tell the user instead of returning an empty list that looks + // like a network outage. + if (enabledSourceIds.length === 0) { + setError( + "No search sources are enabled. Enable one in Settings → Sources.", + ); + setResults([]); + return; + } - setResults(applySubscribedStatus(searchResults)) - } catch (e) { - setError("Search failed. Please try again.") - setResults([]) - } finally { - setIsSearching(false) - } - } + const searchResults = await searchPodcasts(q, sourceIds, sources, { + cacheTtl: CACHE_TTL, + }); - /** Add query to history */ - const addToHistory = (q: string) => { - setHistory((prev) => { - // Remove duplicates and add to front - const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase()) - const updated = [q, ...filtered].slice(0, MAX_HISTORY) - saveHistory(updated) - return updated - }) - } + setResults(applySubscribedStatus(searchResults)); + } catch (e) { + setError( + e instanceof Error && e.message + ? e.message + : "Search failed. Please try again.", + ); + setResults([]); + } finally { + setIsSearching(false); + } + }; - /** Clear search history */ - const clearHistory = () => { - setHistory([]) - saveHistory([]) - } + /** Add query to history */ + const addToHistory = (q: string) => { + setHistory((prev) => { + // Remove duplicates and add to front + const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase()); + const updated = [q, ...filtered].slice(0, MAX_HISTORY); + saveHistory(updated); + return updated; + }); + }; - /** Remove single history item */ - const removeFromHistory = (q: string) => { - setHistory((prev) => { - const updated = prev.filter((h) => h !== q) - saveHistory(updated) - return updated - }) - } + /** Clear search history */ + const clearHistory = () => { + setHistory([]); + saveHistory([]); + }; - /** Clear results */ - const clearResults = () => { - setResults([]) - setQuery("") - setError(null) - } + /** Remove single history item */ + const removeFromHistory = (q: string) => { + setHistory((prev) => { + const updated = prev.filter((h) => h !== q); + saveHistory(updated); + return updated; + }); + }; - /** Mark a podcast as subscribed in results */ - const markSubscribed = (podcastId: string, feedUrl?: string) => { - setResults((prev) => - prev.map((result) => { - const matchesId = result.podcast.id === podcastId - const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false - if (matchesId || matchesUrl) { - return { - ...result, - podcast: { - ...result.podcast, - isSubscribed: true, - }, - } - } - return result - }) - ) - } + /** Clear results */ + const clearResults = () => { + setResults([]); + setQuery(""); + setError(null); + }; - return { - // State - query, - isSearching, - results, - error, - history, - selectedSources, + /** Mark a podcast as subscribed in results */ + const markSubscribed = (podcastId: string, feedUrl?: string) => { + setResults((prev) => + prev.map((result) => { + const matchesId = result.podcast.id === podcastId; + const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false; + if (matchesId || matchesUrl) { + return { + ...result, + podcast: { + ...result.podcast, + isSubscribed: true, + }, + }; + } + return result; + }), + ); + }; - // Actions - search, - setQuery, - clearResults, - clearHistory, - removeFromHistory, - setSelectedSources, - markSubscribed, - } + return { + // State + query, + isSearching, + results, + error, + history, + selectedSources, + + // Actions + search, + setQuery, + clearResults, + clearHistory, + removeFromHistory, + setSelectedSources, + markSubscribed, + }; } /** Singleton search store */ -let searchStoreInstance: ReturnType | null = null +let searchStoreInstance: ReturnType | null = null; export function useSearchStore() { - if (!searchStoreInstance) { - searchStoreInstance = createSearchStore() - } - return searchStoreInstance + if (!searchStoreInstance) { + searchStoreInstance = createSearchStore(); + } + return searchStoreInstance; } diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index b0388d2..f0311e0 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -11,344 +11,374 @@ * component doesn't need to care which one is active. */ -import { platform } from "os" -import { existsSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" +import { platform } from "os"; +import { existsSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; // ── Types ──────────────────────────────────────────────────────────── -export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none" +export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none"; export interface AudioState { - playing: boolean - position: number - duration: number - volume: number - speed: number - backend: BackendName - error: string | null + playing: boolean; + position: number; + duration: number; + volume: number; + speed: number; + backend: BackendName; + error: string | null; } export interface AudioBackend { - readonly name: BackendName - play(url: string, opts?: PlayOptions): Promise - pause(): Promise - resume(): Promise - stop(): Promise - seek(seconds: number): Promise - setVolume(volume: number): Promise - setSpeed(speed: number): Promise - getPosition(): Promise - getDuration(): Promise - isPlaying(): boolean - dispose(): void + readonly name: BackendName; + play(url: string, opts?: PlayOptions): Promise; + pause(): Promise; + resume(): Promise; + stop(): Promise; + seek(seconds: number): Promise; + setVolume(volume: number): Promise; + setSpeed(speed: number): Promise; + getPosition(): Promise; + getDuration(): Promise; + isPlaying(): boolean; + dispose(): void; } export interface PlayOptions { - startPosition?: number - volume?: number - speed?: number + startPosition?: number; + volume?: number; + speed?: number; } // ── Utilities ──────────────────────────────────────────────────────── function which(cmd: string): string | null { - const resolved = Bun.which(cmd) - if (resolved) return resolved + const resolved = Bun.which(cmd); + if (resolved) return resolved; - if (platform() === "darwin") { - const candidates = [ - `/opt/homebrew/bin/${cmd}`, - `/usr/local/bin/${cmd}`, - `/usr/bin/${cmd}`, - ] - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate - } - } + if (platform() === "darwin") { + const candidates = [ + `/opt/homebrew/bin/${cmd}`, + `/usr/local/bin/${cmd}`, + `/usr/bin/${cmd}`, + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + } - return null + return null; } function mpvSocketPath(): string { - return join(tmpdir(), `podtui-mpv-${process.pid}.sock`) + return join(tmpdir(), `podtui-mpv-${process.pid}.sock`); } // ── mpv Backend ────────────────────────────────────────────────────── // Uses JSON IPC over a Unix socket for full bidirectional control. class MpvBackend implements AudioBackend { - readonly name: BackendName = "mpv" - private proc: ReturnType | null = null - private socketPath = mpvSocketPath() - private _playing = false - private _position = 0 - private _duration = 0 - private _volume = 100 - private _speed = 1 - private pollTimer: ReturnType | null = null + readonly name: BackendName = "mpv"; + private proc: ReturnType | null = null; + private socketPath = mpvSocketPath(); + private _playing = false; + private _position = 0; + private _duration = 0; + private _volume = 100; + private _speed = 1; + private pollTimer: ReturnType | null = null; - async play(url: string, opts?: PlayOptions): Promise { - await this.stop() + async play(url: string, opts?: PlayOptions): Promise { + await this.stop(); - // Clean up stale socket - try { - if (existsSync(this.socketPath)) { - const { unlinkSync } = await import("fs") - unlinkSync(this.socketPath) - } - } catch { /* ignore */ } + // Clean up stale socket + try { + if (existsSync(this.socketPath)) { + const { unlinkSync } = await import("fs"); + unlinkSync(this.socketPath); + } + } catch { + /* ignore */ + } - const args = [ - "mpv", - "--no-video", - "--no-terminal", - "--really-quiet", - `--input-ipc-server=${this.socketPath}`, - `--volume=${Math.round((opts?.volume ?? 1) * 100)}`, - `--speed=${opts?.speed ?? 1}`, - ] + const args = [ + "mpv", + "--no-video", + "--no-terminal", + "--really-quiet", + `--input-ipc-server=${this.socketPath}`, + `--volume=${Math.round((opts?.volume ?? 1) * 100)}`, + `--speed=${opts?.speed ?? 1}`, + ]; - if (opts?.startPosition && opts.startPosition > 0) { - args.push(`--start=${opts.startPosition}`) - } + if (opts?.startPosition && opts.startPosition > 0) { + args.push(`--start=${opts.startPosition}`); + } - args.push(url) + args.push(url); - this.proc = Bun.spawn(args, { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }) + this.proc = Bun.spawn(args, { + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }); - this._playing = true - this._position = opts?.startPosition ?? 0 - this._volume = Math.round((opts?.volume ?? 1) * 100) - this._speed = opts?.speed ?? 1 + this._playing = true; + this._position = opts?.startPosition ?? 0; + this._volume = Math.round((opts?.volume ?? 1) * 100); + this._speed = opts?.speed ?? 1; - // Wait for socket to appear (mpv creates it async) - await this.waitForSocket(2000) + // Wait for socket to appear (mpv creates it async) + await this.waitForSocket(2000); - // Start polling position - this.startPolling() + // Start polling position + this.startPolling(); - // Detect process exit - this.proc.exited.then(() => { - this._playing = false - this.stopPolling() - }).catch(() => {}) - } + // Detect process exit + this.proc.exited + .then(() => { + this._playing = false; + this.stopPolling(); + }) + .catch(() => {}); + } - private async waitForSocket(timeoutMs: number): Promise { - const start = Date.now() - while (Date.now() - start < timeoutMs) { - if (existsSync(this.socketPath)) return - await new Promise((r) => setTimeout(r, 50)) - } - } + private async waitForSocket(timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (existsSync(this.socketPath)) return; + await new Promise((r) => setTimeout(r, 50)); + } + } - private async ipc(command: unknown[]): Promise { - try { - const socket = await Bun.connect({ - unix: this.socketPath, - socket: { - data(_socket, data) { - // Response handling is done by reading below - }, - error(_socket, err) {}, - close() {}, - open() {}, - }, - }) + private async ipc(command: unknown[]): Promise { + try { + const socket = await Bun.connect({ + unix: this.socketPath, + socket: { + data(_socket, data) { + // Response handling is done by reading below + }, + error(_socket, err) {}, + close() {}, + open() {}, + }, + }); - const payload = JSON.stringify({ command }) + "\n" - socket.write(payload) + const payload = JSON.stringify({ command }) + "\n"; + socket.write(payload); - // Read response with timeout - const response = await new Promise((resolve) => { - let buf = "" - const reader = setInterval(() => { - // Check if we got a response already - if (buf.includes("\n")) { - clearInterval(reader) - resolve(buf) - } - }, 10) - setTimeout(() => { - clearInterval(reader) - resolve(buf) - }, 200) - }) + // Read response with timeout + const response = await new Promise((resolve) => { + let buf = ""; + const reader = setInterval(() => { + // Check if we got a response already + if (buf.includes("\n")) { + clearInterval(reader); + resolve(buf); + } + }, 10); + setTimeout(() => { + clearInterval(reader); + resolve(buf); + }, 200); + }); - socket.end() - if (response) { - try { return JSON.parse(response.split("\n")[0]) } catch { return null } - } - return null - } catch { - return null - } - } + socket.end(); + if (response) { + try { + return JSON.parse(response.split("\n")[0]); + } catch { + return null; + } + } + return null; + } catch { + return null; + } + } - /** Send a command over mpv's IPC and get the parsed response data. */ - private async ipcCommand(command: unknown[]): Promise { - try { - const conn = await Bun.connect({ - unix: this.socketPath, - socket: { - data() {}, - error() {}, - close() {}, - open() {}, - }, - }) + /** Send a command over mpv's IPC and get the parsed response data. */ + private async ipcCommand(command: unknown[]): Promise { + try { + const conn = await Bun.connect({ + unix: this.socketPath, + socket: { + data() {}, + error() {}, + close() {}, + open() {}, + }, + }); - const payload = JSON.stringify({ command }) + "\n" - conn.write(payload) + const payload = JSON.stringify({ command }) + "\n"; + conn.write(payload); - // Give mpv a moment to process, then read via a fresh connection - await new Promise((r) => setTimeout(r, 30)) - conn.end() + // Give mpv a moment to process, then read via a fresh connection + await new Promise((r) => setTimeout(r, 30)); + conn.end(); - return null - } catch { - return null - } - } + return null; + } catch { + return null; + } + } - /** Send a fire-and-forget command (no response needed) */ - private async send(command: unknown[]): Promise { - try { - const conn = await Bun.connect({ - unix: this.socketPath, - socket: { - data() {}, - error() {}, - close() {}, - open() {}, - }, - }) - conn.write(JSON.stringify({ command }) + "\n") - // Don't wait, just schedule a close - setTimeout(() => { try { conn.end() } catch {} }, 50) - } catch { /* ignore */ } - } + /** Send a fire-and-forget command (no response needed) */ + private async send(command: unknown[]): Promise { + try { + const conn = await Bun.connect({ + unix: this.socketPath, + socket: { + data() {}, + error() {}, + close() {}, + open() {}, + }, + }); + conn.write(JSON.stringify({ command }) + "\n"); + // Don't wait, just schedule a close + setTimeout(() => { + try { + conn.end(); + } catch {} + }, 50); + } catch { + /* ignore */ + } + } - /** Get a property value from mpv via IPC */ - private async getProperty(name: string): Promise { - try { - return await new Promise((resolve) => { - let result = 0 - const timeout = setTimeout(() => resolve(result), 300) + /** Get a property value from mpv via IPC */ + private async getProperty(name: string): Promise { + try { + return await new Promise((resolve) => { + let result = 0; + const timeout = setTimeout(() => resolve(result), 300); - Bun.connect({ - unix: this.socketPath, - socket: { - data(_socket, data) { - try { - const text = Buffer.from(data).toString() - const parsed = JSON.parse(text.split("\n")[0]) - if (parsed?.data !== undefined) { - result = Number(parsed.data) || 0 - } - } catch { /* ignore parse errors */ } - clearTimeout(timeout) - resolve(result) - }, - error() { clearTimeout(timeout); resolve(0) }, - close() {}, - open(socket) { - socket.write(JSON.stringify({ command: ["get_property", name] }) + "\n") - }, - }, - }).catch(() => { clearTimeout(timeout); resolve(0) }) - }) - } catch { - return 0 - } - } + Bun.connect({ + unix: this.socketPath, + socket: { + data(_socket, data) { + try { + const text = Buffer.from(data).toString(); + const parsed = JSON.parse(text.split("\n")[0]); + if (parsed?.data !== undefined) { + result = Number(parsed.data) || 0; + } + } catch { + /* ignore parse errors */ + } + clearTimeout(timeout); + resolve(result); + }, + error() { + clearTimeout(timeout); + resolve(0); + }, + close() {}, + open(socket) { + socket.write( + JSON.stringify({ command: ["get_property", name] }) + "\n", + ); + }, + }, + }).catch(() => { + clearTimeout(timeout); + resolve(0); + }); + }); + } catch { + return 0; + } + } - private startPolling(): void { - this.stopPolling() - this.pollTimer = setInterval(async () => { - if (!this._playing || !this.proc) return - this._position = await this.getProperty("time-pos") - if (this._duration <= 0) { - this._duration = await this.getProperty("duration") - } - }, 500) - } + private startPolling(): void { + this.stopPolling(); + this.pollTimer = setInterval(async () => { + if (!this._playing || !this.proc) return; + this._position = await this.getProperty("time-pos"); + if (this._duration <= 0) { + this._duration = await this.getProperty("duration"); + } + }, 500); + } - private stopPolling(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - } + private stopPolling(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } - async pause(): Promise { - await this.send(["set_property", "pause", true]) - this._playing = false - } + async pause(): Promise { + await this.send(["set_property", "pause", true]); + this._playing = false; + } - async resume(): Promise { - await this.send(["set_property", "pause", false]) - this._playing = true - } + async resume(): Promise { + await this.send(["set_property", "pause", false]); + this._playing = true; + } - async stop(): Promise { - this.stopPolling() - if (this.proc) { - try { this.proc.kill() } catch { /* ignore */ } - this.proc = null - } - this._playing = false - this._position = 0 + async stop(): Promise { + this.stopPolling(); + if (this.proc) { + try { + this.proc.kill(); + } catch { + /* ignore */ + } + this.proc = null; + } + this._playing = false; + this._position = 0; - // Clean up socket - try { - if (existsSync(this.socketPath)) { - const { unlinkSync } = await import("fs") - unlinkSync(this.socketPath) - } - } catch { /* ignore */ } - } + // Clean up socket + try { + if (existsSync(this.socketPath)) { + const { unlinkSync } = await import("fs"); + unlinkSync(this.socketPath); + } + } catch { + /* ignore */ + } + } - async seek(seconds: number): Promise { - await this.send(["set_property", "time-pos", seconds]) - this._position = seconds - } + async seek(seconds: number): Promise { + await this.send(["set_property", "time-pos", seconds]); + this._position = seconds; + } - async setVolume(volume: number): Promise { - const v = Math.round(volume * 100) - await this.send(["set_property", "volume", v]) - this._volume = v - } + async setVolume(volume: number): Promise { + const v = Math.round(volume * 100); + await this.send(["set_property", "volume", v]); + this._volume = v; + } - async setSpeed(speed: number): Promise { - await this.send(["set_property", "speed", speed]) - this._speed = speed - } + async setSpeed(speed: number): Promise { + await this.send(["set_property", "speed", speed]); + this._speed = speed; + } - async getPosition(): Promise { - return this._position - } + async getPosition(): Promise { + return this._position; + } - async getDuration(): Promise { - if (this._duration <= 0) { - this._duration = await this.getProperty("duration") - } - return this._duration - } + async getDuration(): Promise { + if (this._duration <= 0) { + this._duration = await this.getProperty("duration"); + } + return this._duration; + } - isPlaying(): boolean { - return this._playing - } + isPlaying(): boolean { + return this._playing; + } - dispose(): void { - this.stop() - } + dispose(): void { + this.stop(); + } } // ── ffplay Backend ─────────────────────────────────────────────────── @@ -356,497 +386,610 @@ class MpvBackend implements AudioBackend { // position via elapsed wall-clock time. Seek requires restarting. class FfplayBackend implements AudioBackend { - readonly name: BackendName = "ffplay" - private proc: ReturnType | null = null - private _playing = false - private _paused = false - private _position = 0 - private _duration = 0 - private _volume = 100 - private _speed = 1 - private _url = "" - private startTime = 0 - private pollTimer: ReturnType | null = null + readonly name: BackendName = "ffplay"; + private proc: ReturnType | null = null; + private _playing = false; + private _paused = false; + private _position = 0; + private _duration = 0; + private _volume = 100; + private _speed = 1; + private _url = ""; + private startTime = 0; + private pollTimer: ReturnType | null = null; - async play(url: string, opts?: PlayOptions): Promise { - await this.stop() + async play(url: string, opts?: PlayOptions): Promise { + await this.stop(); - this._url = url - this._volume = Math.round((opts?.volume ?? 1) * 100) - this._speed = opts?.speed ?? 1 - this._position = opts?.startPosition ?? 0 + this._url = url; + this._volume = Math.round((opts?.volume ?? 1) * 100); + this._speed = opts?.speed ?? 1; + this._position = opts?.startPosition ?? 0; - this.spawnProcess() - } + this.spawnProcess(); + } - private spawnProcess(): void { - const args = [ - "ffplay", - "-nodisp", - "-autoexit", - "-loglevel", "quiet", - "-volume", String(this._volume), - ] + private spawnProcess(): void { + const args = [ + "ffplay", + "-nodisp", + "-autoexit", + "-loglevel", + "quiet", + "-volume", + String(this._volume), + ]; - if (this._position > 0) { - args.push("-ss", String(this._position)) - } + if (this._position > 0) { + args.push("-ss", String(this._position)); + } - if (this._speed !== 1) { - args.push("-af", `atempo=${this._speed}`) - } + if (this._speed !== 1) { + args.push("-af", `atempo=${this._speed}`); + } - args.push("-i", this._url) + args.push("-i", this._url); - this.proc = Bun.spawn(args, { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }) + this.proc = Bun.spawn(args, { + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }); - this._playing = true - this._paused = false - this.startTime = Date.now() - this.startPolling() + this._playing = true; + this._paused = false; + this.startTime = Date.now(); + this.startPolling(); - this.proc.exited.then(() => { - this._playing = false - this.stopPolling() - }).catch(() => {}) - } + this.proc.exited + .then(() => { + this._playing = false; + this.stopPolling(); + }) + .catch(() => {}); + } - private startPolling(): void { - this.stopPolling() - this.pollTimer = setInterval(() => { - if (!this._playing) return - const elapsed = (Date.now() - this.startTime) / 1000 * this._speed - this._position = this._position + elapsed - this.startTime = Date.now() - }, 500) - } + private startPolling(): void { + this.stopPolling(); + this.pollTimer = setInterval(() => { + if (!this._playing) return; + const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed; + this._position = this._position + elapsed; + this.startTime = Date.now(); + }, 500); + } - private stopPolling(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - } + private stopPolling(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } - async pause(): Promise { - if (this.proc) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - try { if (pid) process.kill(pid, "SIGSTOP") } catch {} - this._paused = true - } - this._playing = false - this.stopPolling() - } + async pause(): Promise { + if (this.proc) { + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + try { + if (pid) process.kill(pid, "SIGSTOP"); + } catch {} + this._paused = true; + } + this._playing = false; + this.stopPolling(); + } - async resume(): Promise { - if (!this._url) return - if (this.proc && this._paused) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - try { if (pid) process.kill(pid, "SIGCONT") } catch {} - this._paused = false - this._playing = true - this.startTime = Date.now() - this.startPolling() - return - } - this.spawnProcess() - } + async resume(): Promise { + if (!this._url) return; + if (this.proc && this._paused) { + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + try { + if (pid) process.kill(pid, "SIGCONT"); + } catch {} + this._paused = false; + this._playing = true; + this.startTime = Date.now(); + this.startPolling(); + return; + } + this.spawnProcess(); + } - async stop(): Promise { - this.stopPolling() - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this._playing = false - this._paused = false - this._position = 0 - this._url = "" - } + async stop(): Promise { + this.stopPolling(); + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this._playing = false; + this._paused = false; + this._position = 0; + this._url = ""; + } - async seek(seconds: number): Promise { - this._position = seconds - if (this._playing && this._url) { - // Restart at new position - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this.spawnProcess() - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - if (this._paused && pid) { - try { process.kill(pid, "SIGSTOP") } catch {} - this._playing = false - } - } - } + async seek(seconds: number): Promise { + this._position = seconds; + if (this._playing && this._url) { + // Restart at new position + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this.spawnProcess(); + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + if (this._paused && pid) { + try { + process.kill(pid, "SIGSTOP"); + } catch {} + this._playing = false; + } + } + } - async setVolume(volume: number): Promise { - this._volume = Math.round(volume * 100) - // ffplay has no runtime IPC; volume will apply on next play/resume. - // Restart the process to apply immediately if currently playing. - if (this._url && (this._playing || this._paused)) { - this.stopPolling() - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this.spawnProcess() - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - if (this._paused && pid) { - try { process.kill(pid, "SIGSTOP") } catch {} - this._playing = false - } - } - } + async setVolume(volume: number): Promise { + this._volume = Math.round(volume * 100); + // ffplay has no runtime IPC; volume will apply on next play/resume. + // Restart the process to apply immediately if currently playing. + if (this._url && (this._playing || this._paused)) { + this.stopPolling(); + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this.spawnProcess(); + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + if (this._paused && pid) { + try { + process.kill(pid, "SIGSTOP"); + } catch {} + this._playing = false; + } + } + } - async setSpeed(speed: number): Promise { - this._speed = speed - if (this._url && (this._playing || this._paused)) { - this.stopPolling() - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this.spawnProcess() - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - if (this._paused && pid) { - try { process.kill(pid, "SIGSTOP") } catch {} - this._playing = false - } - } - } + async setSpeed(speed: number): Promise { + this._speed = speed; + if (this._url && (this._playing || this._paused)) { + this.stopPolling(); + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this.spawnProcess(); + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + if (this._paused && pid) { + try { + process.kill(pid, "SIGSTOP"); + } catch {} + this._playing = false; + } + } + } - async getPosition(): Promise { - return this._position - } + async getPosition(): Promise { + return this._position; + } - async getDuration(): Promise { - return this._duration - } + async getDuration(): Promise { + return this._duration; + } - isPlaying(): boolean { - return this._playing - } + isPlaying(): boolean { + return this._playing; + } - dispose(): void { - this.stop() - } + dispose(): void { + this.stop(); + } } // ── afplay Backend (macOS) ─────────────────────────────────────────── // Built-in on macOS. Supports volume and rate but no seek or position. class AfplayBackend implements AudioBackend { - readonly name: BackendName = "afplay" - private proc: ReturnType | null = null - private _playing = false - private _paused = false - private _position = 0 - private _duration = 0 - private _volume = 1 - private _speed = 1 - private _url = "" - private startTime = 0 - private pollTimer: ReturnType | null = null + readonly name: BackendName = "afplay"; + private proc: ReturnType | null = null; + private _playing = false; + private _paused = false; + private _position = 0; + private _duration = 0; + private _volume = 1; + private _speed = 1; + private _url = ""; + private startTime = 0; + private pollTimer: ReturnType | null = null; - async play(url: string, opts?: PlayOptions): Promise { - await this.stop() + async play(url: string, opts?: PlayOptions): Promise { + await this.stop(); - this._url = url - this._volume = opts?.volume ?? 1 - this._speed = opts?.speed ?? 1 - this._position = opts?.startPosition ?? 0 + this._url = url; + this._volume = opts?.volume ?? 1; + this._speed = opts?.speed ?? 1; + this._position = opts?.startPosition ?? 0; - this.spawnProcess() - } + this.spawnProcess(); + } - private spawnProcess(): void { - // afplay supports --volume (0-1) and --rate - const args = [ - "afplay", - "--volume", String(this._volume), - "--rate", String(this._speed), - ] + private spawnProcess(): void { + // afplay supports --volume (0-1) and --rate + const args = [ + "afplay", + "--volume", + String(this._volume), + "--rate", + String(this._speed), + ]; - if (this._position > 0) { - args.push("--time", String(this._duration > 0 ? this._duration - this._position : 0)) - } + if (this._position > 0) { + args.push( + "--time", + String(this._duration > 0 ? this._duration - this._position : 0), + ); + } - args.push(this._url) + args.push(this._url); - this.proc = Bun.spawn(args, { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }) + this.proc = Bun.spawn(args, { + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }); - this._playing = true - this._paused = false - this.startTime = Date.now() - this.startPolling() + this._playing = true; + this._paused = false; + this.startTime = Date.now(); + this.startPolling(); - this.proc.exited.then(() => { - this._playing = false - this.stopPolling() - }).catch(() => {}) - } + this.proc.exited + .then(() => { + this._playing = false; + this.stopPolling(); + }) + .catch(() => {}); + } - private startPolling(): void { - this.stopPolling() - this.pollTimer = setInterval(() => { - if (!this._playing) return - const elapsed = (Date.now() - this.startTime) / 1000 * this._speed - this._position = this._position + elapsed - this.startTime = Date.now() - }, 500) - } + private startPolling(): void { + this.stopPolling(); + this.pollTimer = setInterval(() => { + if (!this._playing) return; + const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed; + this._position = this._position + elapsed; + this.startTime = Date.now(); + }, 500); + } - private stopPolling(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - } + private stopPolling(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } - async pause(): Promise { - if (this.proc) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - try { if (pid) process.kill(pid, "SIGSTOP") } catch {} - this._paused = true - } - this._playing = false - this.stopPolling() - } + async pause(): Promise { + if (this.proc) { + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + try { + if (pid) process.kill(pid, "SIGSTOP"); + } catch {} + this._paused = true; + } + this._playing = false; + this.stopPolling(); + } - async resume(): Promise { - if (!this._url) return - if (this.proc && this._paused) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - try { if (pid) process.kill(pid, "SIGCONT") } catch {} - this._paused = false - this._playing = true - this.startTime = Date.now() - this.startPolling() - return - } - this.spawnProcess() - } + async resume(): Promise { + if (!this._url) return; + if (this.proc && this._paused) { + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + try { + if (pid) process.kill(pid, "SIGCONT"); + } catch {} + this._paused = false; + this._playing = true; + this.startTime = Date.now(); + this.startPolling(); + return; + } + this.spawnProcess(); + } - async stop(): Promise { - this.stopPolling() - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this._playing = false - this._paused = false - this._position = 0 - this._url = "" - } + async stop(): Promise { + this.stopPolling(); + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this._playing = false; + this._paused = false; + this._position = 0; + this._url = ""; + } - async seek(seconds: number): Promise { - this._position = seconds - if (this._playing && this._url) { - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this.spawnProcess() - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - if (this._paused && pid) { - try { process.kill(pid, "SIGSTOP") } catch {} - this._playing = false - } - } - } + async seek(seconds: number): Promise { + this._position = seconds; + if (this._playing && this._url) { + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this.spawnProcess(); + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + if (this._paused && pid) { + try { + process.kill(pid, "SIGSTOP"); + } catch {} + this._playing = false; + } + } + } - async setVolume(volume: number): Promise { - this._volume = volume - // Restart the process with new volume to apply immediately - if (this._url && (this._playing || this._paused)) { - this.stopPolling() - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this.spawnProcess() - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - if (this._paused && pid) { - try { process.kill(pid, "SIGSTOP") } catch {} - this._playing = false - } - } - } + async setVolume(volume: number): Promise { + this._volume = volume; + // Restart the process with new volume to apply immediately + if (this._url && (this._playing || this._paused)) { + this.stopPolling(); + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this.spawnProcess(); + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + if (this._paused && pid) { + try { + process.kill(pid, "SIGSTOP"); + } catch {} + this._playing = false; + } + } + } - async setSpeed(speed: number): Promise { - this._speed = speed - // Restart the process with new rate to apply immediately - if (this._url && (this._playing || this._paused)) { - this.stopPolling() - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this.spawnProcess() - const pid = (this.proc as unknown as { pid?: number } | null)?.pid - if (this._paused && pid) { - try { process.kill(pid, "SIGSTOP") } catch {} - this._playing = false - } - } - } + async setSpeed(speed: number): Promise { + this._speed = speed; + // Restart the process with new rate to apply immediately + if (this._url && (this._playing || this._paused)) { + this.stopPolling(); + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + } + this.spawnProcess(); + const pid = (this.proc as unknown as { pid?: number } | null)?.pid; + if (this._paused && pid) { + try { + process.kill(pid, "SIGSTOP"); + } catch {} + this._playing = false; + } + } + } - async getPosition(): Promise { - return this._position - } + async getPosition(): Promise { + return this._position; + } - async getDuration(): Promise { - return this._duration - } + async getDuration(): Promise { + return this._duration; + } - isPlaying(): boolean { - return this._playing - } + isPlaying(): boolean { + return this._playing; + } - dispose(): void { - this.stop() - } + dispose(): void { + this.stop(); + } } // ── System Backend (open/xdg-open) ─────────────────────────────────── // Fire-and-forget. Opens the URL in the default handler. No control. class SystemBackend implements AudioBackend { - readonly name: BackendName = "system" - private _playing = false + readonly name: BackendName = "system"; + private _playing = false; - async play(url: string): Promise { - const os = platform() - const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open" + async play(url: string): Promise { + const os = platform(); + const cmd = + os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"; - Bun.spawn([cmd, url], { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }) + Bun.spawn([cmd, url], { + stdout: "ignore", + stderr: "ignore", + stdin: "ignore", + }); - this._playing = true - } + this._playing = true; + } - async pause(): Promise { this._playing = false } - async resume(): Promise { this._playing = true } - async stop(): Promise { this._playing = false } - async seek(): Promise {} - async setVolume(): Promise {} - async setSpeed(): Promise {} - async getPosition(): Promise { return 0 } - async getDuration(): Promise { return 0 } - isPlaying(): boolean { return this._playing } - dispose(): void { this._playing = false } + async pause(): Promise { + this._playing = false; + } + async resume(): Promise { + this._playing = true; + } + async stop(): Promise { + this._playing = false; + } + async seek(): Promise {} + async setVolume(): Promise {} + async setSpeed(): Promise {} + async getPosition(): Promise { + return 0; + } + async getDuration(): Promise { + return 0; + } + isPlaying(): boolean { + return this._playing; + } + dispose(): void { + this._playing = false; + } } // ── No-op Backend ──────────────────────────────────────────────────── class NoopBackend implements AudioBackend { - readonly name: BackendName = "none" - async play(): Promise {} - async pause(): Promise {} - async resume(): Promise {} - async stop(): Promise {} - async seek(): Promise {} - async setVolume(): Promise {} - async setSpeed(): Promise {} - async getPosition(): Promise { return 0 } - async getDuration(): Promise { return 0 } - isPlaying(): boolean { return false } - dispose(): void {} + readonly name: BackendName = "none"; + async play(): Promise {} + async pause(): Promise {} + async resume(): Promise {} + async stop(): Promise {} + async seek(): Promise {} + async setVolume(): Promise {} + async setSpeed(): Promise {} + async getPosition(): Promise { + return 0; + } + async getDuration(): Promise { + return 0; + } + isPlaying(): boolean { + return false; + } + dispose(): void {} } // ── Detection & Factory ────────────────────────────────────────────── export interface DetectedPlayer { - name: BackendName - path: string | null - capabilities: { - seek: boolean - volume: boolean - speed: boolean - positionTracking: boolean - } + name: BackendName; + path: string | null; + capabilities: { + seek: boolean; + volume: boolean; + speed: boolean; + positionTracking: boolean; + }; } /** Detect all available audio players on this system. */ export function detectPlayers(): DetectedPlayer[] { - const players: DetectedPlayer[] = [] + const players: DetectedPlayer[] = []; - const mpvPath = which("mpv") - if (mpvPath) { - players.push({ - name: "mpv", - path: mpvPath, - capabilities: { seek: true, volume: true, speed: true, positionTracking: true }, - }) - } + const mpvPath = which("mpv"); + if (mpvPath) { + players.push({ + name: "mpv", + path: mpvPath, + capabilities: { + seek: true, + volume: true, + speed: true, + positionTracking: true, + }, + }); + } - const ffplayPath = which("ffplay") - if (ffplayPath) { - players.push({ - name: "ffplay", - path: ffplayPath, - capabilities: { seek: true, volume: true, speed: false, positionTracking: false }, - }) - } + const ffplayPath = which("ffplay"); + if (ffplayPath) { + players.push({ + name: "ffplay", + path: ffplayPath, + capabilities: { + seek: true, + volume: true, + speed: false, + positionTracking: false, + }, + }); + } - const os = platform() - if (os === "darwin") { - const afplayPath = which("afplay") - if (afplayPath) { - players.push({ - name: "afplay", - path: afplayPath, - capabilities: { seek: true, volume: true, speed: true, positionTracking: false }, - }) - } - } + const os = platform(); + if (os === "darwin") { + const afplayPath = which("afplay"); + if (afplayPath) { + players.push({ + name: "afplay", + path: afplayPath, + capabilities: { + seek: true, + volume: true, + speed: true, + positionTracking: false, + }, + }); + } + } - // System open is always available as fallback - const openCmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open" - if (which(openCmd)) { - players.push({ - name: "system", - path: which(openCmd), - capabilities: { seek: false, volume: false, speed: false, positionTracking: false }, - }) - } + // System open is always available as fallback + const openCmd = + os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"; + if (which(openCmd)) { + players.push({ + name: "system", + path: which(openCmd), + capabilities: { + seek: false, + volume: false, + speed: false, + positionTracking: false, + }, + }); + } - return players + return players; } /** Create the best available audio backend. */ export function createAudioBackend(preferred?: BackendName): AudioBackend { - if (preferred) { - const backend = createBackendByName(preferred) - if (backend) return backend - } + // Testability hook: allow forcing a backend (incl. "none") via env so the + // harness can run silently during replay and opt into real playback per turn. + // An explicit `preferred` argument still wins. + if (!preferred) { + const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined; + if ( + envPref && + (envPref === "mpv" || + envPref === "ffplay" || + envPref === "afplay" || + envPref === "system" || + envPref === "none") + ) { + preferred = envPref; + } + } - // Auto-detect in priority order - const players = detectPlayers() - if (players.length === 0) return new NoopBackend() + if (preferred) { + const backend = createBackendByName(preferred); + if (backend) return backend; + } - return createBackendByName(players[0].name) ?? new NoopBackend() + // Auto-detect in priority order + const players = detectPlayers(); + if (players.length === 0) return new NoopBackend(); + + return createBackendByName(players[0].name) ?? new NoopBackend(); } function createBackendByName(name: BackendName): AudioBackend | null { - switch (name) { - case "mpv": return which("mpv") ? new MpvBackend() : null - case "ffplay": return which("ffplay") ? new FfplayBackend() : null - case "afplay": return platform() === "darwin" && which("afplay") ? new AfplayBackend() : null - case "system": return new SystemBackend() - case "none": return new NoopBackend() - } + switch (name) { + case "mpv": + return which("mpv") ? new MpvBackend() : null; + case "ffplay": + return which("ffplay") ? new FfplayBackend() : null; + case "afplay": + return platform() === "darwin" && which("afplay") + ? new AfplayBackend() + : null; + case "system": + return new SystemBackend(); + case "none": + return new NoopBackend(); + } } diff --git a/src/utils/event-bus.ts b/src/utils/event-bus.ts index 889b5e1..d8b2be7 100644 --- a/src/utils/event-bus.ts +++ b/src/utils/event-bus.ts @@ -20,128 +20,148 @@ * ``` */ -type EventHandler = (data: T) => void +type EventHandler = (data: T) => void; // Export EventHandler type for external use -export type { EventHandler } +export type { EventHandler }; interface EventBusInstance { - on(event: string, handler: EventHandler): () => void - once(event: string, handler: EventHandler): () => void - off(event: string, handler: EventHandler): void - emit(event: string, data: T): void - clear(): void + on(event: string, handler: EventHandler): () => void; + once(event: string, handler: EventHandler): () => void; + off(event: string, handler: EventHandler): void; + emit(event: string, data: T): void; + clear(): void; } function createEventBus(): EventBusInstance { - const handlers = new Map>() + const handlers = new Map>(); - return { - on(event: string, handler: EventHandler): () => void { - if (!handlers.has(event)) { - handlers.set(event, new Set()) - } - handlers.get(event)!.add(handler as EventHandler) + return { + on(event: string, handler: EventHandler): () => void { + if (!handlers.has(event)) { + handlers.set(event, new Set()); + } + handlers.get(event)!.add(handler as EventHandler); - // Return unsubscribe function - return () => { - this.off(event, handler) - } - }, + // Return unsubscribe function + return () => { + this.off(event, handler); + }; + }, - once(event: string, handler: EventHandler): () => void { - const wrappedHandler: EventHandler = (data) => { - this.off(event, wrappedHandler) - handler(data) - } - return this.on(event, wrappedHandler) - }, + once(event: string, handler: EventHandler): () => void { + const wrappedHandler: EventHandler = (data) => { + this.off(event, wrappedHandler); + handler(data); + }; + return this.on(event, wrappedHandler); + }, - off(event: string, handler: EventHandler): void { - const eventHandlers = handlers.get(event) - if (eventHandlers) { - eventHandlers.delete(handler as EventHandler) - if (eventHandlers.size === 0) { - handlers.delete(event) - } - } - }, + off(event: string, handler: EventHandler): void { + const eventHandlers = handlers.get(event); + if (eventHandlers) { + eventHandlers.delete(handler as EventHandler); + if (eventHandlers.size === 0) { + handlers.delete(event); + } + } + }, - emit(event: string, data: T): void { - const eventHandlers = handlers.get(event) - if (eventHandlers) { - for (const handler of eventHandlers) { - try { - handler(data) - } catch (error) { - console.error(`Error in event handler for "${event}":`, error) - } - } - } - }, + emit(event: string, data: T): void { + const eventHandlers = handlers.get(event); + if (eventHandlers) { + for (const handler of eventHandlers) { + try { + handler(data); + } catch (error) { + console.error(`Error in event handler for "${event}":`, error); + } + } + } + }, - clear(): void { - handlers.clear() - }, - } + clear(): void { + handlers.clear(); + }, + }; } // Singleton event bus instance -export const EventBus = createEventBus() +export const EventBus = createEventBus(); + +import type { KeybindActionName } from "@/context/KeybindContext"; +import type { TABS } from "@/utils/navigation"; +import type { PaneId, NavMode } from "@/context/NavigationContext"; // Common event types for the application export type AppEvents = { - "theme.changed": { theme: string; mode: "dark" | "light" } - "theme.mode.changed": { mode: "dark" | "light" } - "theme.reload": {} - "navigation.tab.changed": { tab: string; previousTab?: string } - "navigation.layer.changed": { depth: number; previousDepth: number } - "feed.subscribed": { feedId: string; feedUrl: string } - "feed.unsubscribed": { feedId: string } - "player.play": { episodeId: string } - "player.pause": { episodeId: string } - "player.stop": {} - "auth.login": { userId: string } - "auth.logout": {} - "toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number } - "dialog.open": { dialogId: string } - "dialog.close": { dialogId?: string } - "command.execute": { command: string; args?: unknown } - "clipboard.copied": { text: string } - "selection.start": { x: number; y: number } - "selection.end": { text: string } + "theme.changed": { theme: string; mode: "dark" | "light" }; + "theme.mode.changed": { mode: "dark" | "light" }; + "theme.reload": {}; + "navigation.tab.changed": { tab: string; previousTab?: string }; + "navigation.layer.changed": { depth: number; previousDepth: number }; + "feed.subscribed": { feedId: string; feedUrl: string }; + "feed.unsubscribed": { feedId: string }; + "player.play": { episodeId: string }; + "player.pause": { episodeId: string }; + "player.stop": {}; + "auth.login": { userId: string }; + "auth.logout": {}; + "toast.show": { + message: string; + variant: "info" | "success" | "warning" | "error"; + title?: string; + duration?: number; + }; + "dialog.open": { dialogId: string }; + "dialog.close": { dialogId?: string }; + "command.execute": { command: string; args?: unknown }; + // Yazi-style unified router → active page dispatch. The Shell router + // emits these; each page subscribes to the subset it implements. + "nav.action": { + action: KeybindActionName; + tab: TABS; + pane: PaneId; + mode: NavMode; + }; + "clipboard.copied": { text: string }; + "selection.start": { x: number; y: number }; + "selection.end": { text: string }; - // Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio) - "media.toggle": {} - "media.volumeUp": {} - "media.volumeDown": {} - "media.seekForward": {} - "media.seekBackward": {} - "media.speedCycle": {} -} + // Multimedia key events (emitted by useMultimediaKeys, consumed by useAudio) + "media.toggle": {}; + "media.volumeUp": {}; + "media.volumeDown": {}; + "media.seekForward": {}; + "media.seekBackward": {}; + "media.speedCycle": {}; +}; // Type-safe emit and on functions -export function emit(event: K, data: AppEvents[K]): void { - EventBus.emit(event, data) +export function emit( + event: K, + data: AppEvents[K], +): void { + EventBus.emit(event, data); } export function on( - event: K, - handler: EventHandler + event: K, + handler: EventHandler, ): () => void { - return EventBus.on(event, handler) + return EventBus.on(event, handler); } export function once( - event: K, - handler: EventHandler + event: K, + handler: EventHandler, ): () => void { - return EventBus.once(event, handler) + return EventBus.once(event, handler); } export function off( - event: K, - handler: EventHandler + event: K, + handler: EventHandler, ): void { - EventBus.off(event, handler) + EventBus.off(event, handler); } diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 880cdf1..0608d74 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -1,90 +1,121 @@ /** * Keybinds persistence via JSONC file in XDG_CONFIG_HOME * - * Handles copying keybind.jsonc from package to user config directory + * Handles copying keybinds.jsonc from package to user config directory * and loading/saving keybind configurations. */ -import { copyFile, mkdir } from "fs/promises"; +import { copyFile } from "fs/promises"; import path from "path"; import { parseJSONC } from "./jsonc"; import { getConfigFilePath, ensureConfigDir } from "./config-dir"; import type { KeybindsResolved } from "../context/KeybindContext"; const KEYBINDS_SOURCE = path.join( - process.cwd(), - "src", - "config", - "keybind.jsonc", + process.cwd(), + "src", + "config", + "keybinds.jsonc", ); const KEYBINDS_FILE = "keybinds.jsonc"; -/** Default keybinds from package */ +/** Default keybinds (yazi-style) — mirrors src/config/keybinds.jsonc so the + * app works before a user keybinds file is copied into place. */ const DEFAULT_KEYBINDS: KeybindsResolved = { - up: ["up", "k"], - down: ["down", "j"], - left: ["left", "h"], - right: ["right", "l"], - cycle: ["tab"], - dive: ["return"], - select: ["return"], - out: ["esc"], - inverseModifier: "shift", - leader: ":", - quit: ["q"], - "audio-toggle": ["p"], - "audio-pause": [], - "audio-play": [], - "audio-next": ["n"], - "audio-prev": ["l"], - "audio-seek-forward": ["sf"], - "audio-seek-backward": ["sb"], + // movement + "move-down": ["j", "down"], + "move-up": ["k", "up"], + "page-down": ["ctrl-d"], + "page-up": ["ctrl-u"], + "full-down": ["ctrl-f"], + "full-up": ["ctrl-b"], + "jump-down": ["J"], + "jump-up": ["K"], + "goto-top": [["g", "g"]], + "goto-bottom": ["G"], + // pane swipe + "swipe-prev": ["h", "left"], + "swipe-next": ["l", "right"], + // open / select + open: ["return", "enter"], + "open-interactive": ["shift-return"], + "toggle-select": ["space"], + "visual-mode": ["v"], + "toggle-all": ["ctrl-a"], + "invert-all": ["ctrl-r"], + escape: ["escape", "ctrl-["], + // tabs + "tab-prev": ["["], + "tab-next": ["]"], + "tab-goto-1": ["1"], + "tab-goto-2": ["2"], + "tab-goto-3": ["3"], + "tab-goto-4": ["4"], + "tab-goto-5": ["5"], + "tab-goto-6": ["6"], + // command / help / quit + command: [":"], + quit: ["q", "ctrl-c"], + help: ["~", "f1"], + // list ops + search: ["s"], + filter: ["f"], + sort: [","], + "toggle-hidden": ["."], + refresh: ["r"], + // audio transport (preserved; shifted single keys, no collisions) + "audio-toggle": ["P"], + "audio-next": ["N"], + "audio-prev": ["B"], + "audio-seek-forward": ["shift-."], + "audio-seek-backward": ["shift-,"], }; -/** Copy keybind.jsonc to user config directory on first run */ +/** Copy keybinds.jsonc to user config directory on first run */ export async function copyKeybindsIfNeeded(): Promise { - try { - const targetPath = getConfigFilePath(KEYBINDS_FILE); + try { + const targetPath = getConfigFilePath(KEYBINDS_FILE); - // Check if file already exists - const targetFile = Bun.file(targetPath); - if (await targetFile.exists()) return; + // Check if file already exists + const targetFile = Bun.file(targetPath); + if (await targetFile.exists()) return; - await ensureConfigDir(); - await copyFile(KEYBINDS_SOURCE, targetPath); - } catch { - // Silently ignore errors - } + await ensureConfigDir(); + await copyFile(KEYBINDS_SOURCE, targetPath); + } catch { + // Silently ignore errors + } } /** Load keybinds from JSONC file */ export async function loadKeybindsFromFile(): Promise { - try { - const filePath = getConfigFilePath(KEYBINDS_FILE); - const file = Bun.file(filePath); + try { + const filePath = getConfigFilePath(KEYBINDS_FILE); + const file = Bun.file(filePath); - if (!(await file.exists())) return DEFAULT_KEYBINDS; + if (!(await file.exists())) return DEFAULT_KEYBINDS; - const raw = await file.text(); - const parsed = parseJSONC(raw); + const raw = await file.text(); + const parsed = parseJSONC(raw); - if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS; + if (!parsed || typeof parsed !== "object") return DEFAULT_KEYBINDS; - return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved; - } catch { - return DEFAULT_KEYBINDS; - } + // Merge so partial user configs inherit defaults for missing keys. + return { ...DEFAULT_KEYBINDS, ...parsed } as KeybindsResolved; + } catch { + return DEFAULT_KEYBINDS; + } } /** Save keybinds to JSONC file */ export async function saveKeybindsToFile( - keybinds: KeybindsResolved, + keybinds: KeybindsResolved, ): Promise { - try { - await ensureConfigDir(); - const filePath = getConfigFilePath(KEYBINDS_FILE); - await Bun.write(filePath, JSON.stringify(keybinds, null, 2)); - } catch { - // Silently ignore write errors - } + try { + await ensureConfigDir(); + const filePath = getConfigFilePath(KEYBINDS_FILE); + await Bun.write(filePath, JSON.stringify(keybinds, null, 2)); + } catch { + // Silently ignore write errors + } } diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts index e152777..0071d31 100644 --- a/src/utils/navigation.ts +++ b/src/utils/navigation.ts @@ -6,33 +6,55 @@ import { SearchPage, SearchPaneCount } from "@/pages/Search/SearchPage"; import { SettingsPage, SettingsPaneCount } from "@/pages/Settings/SettingsPage"; export enum DIRECTION { - Increment, - Decrement, + Increment, + Decrement, } export enum TABS { - FEED = 1, - MYSHOWS = 2, - DISCOVER = 3, - SEARCH = 4, - PLAYER = 5, - SETTINGS = 6, + FEED = 1, + MYSHOWS = 2, + DISCOVER = 3, + SEARCH = 4, + PLAYER = 5, + SETTINGS = 6, } export const TabsCount = 6; export const LayerGraph = { - [TABS.FEED]: FeedPage, - [TABS.MYSHOWS]: MyShowsPage, - [TABS.DISCOVER]: DiscoverPage, - [TABS.SEARCH]: SearchPage, - [TABS.PLAYER]: PlayerPage, - [TABS.SETTINGS]: SettingsPage, + [TABS.FEED]: FeedPage, + [TABS.MYSHOWS]: MyShowsPage, + [TABS.DISCOVER]: DiscoverPage, + [TABS.SEARCH]: SearchPage, + [TABS.PLAYER]: PlayerPage, + [TABS.SETTINGS]: SettingsPage, }; export const LayerDepths = { - [TABS.FEED]: FeedPaneCount, - [TABS.MYSHOWS]: MyShowsPaneCount, - [TABS.DISCOVER]: DiscoverPaneCount, - [TABS.SEARCH]: SearchPaneCount, - [TABS.PLAYER]: PlayerPaneCount, - [TABS.SETTINGS]: SettingsPaneCount, + [TABS.FEED]: FeedPaneCount, + [TABS.MYSHOWS]: MyShowsPaneCount, + [TABS.DISCOVER]: DiscoverPaneCount, + [TABS.SEARCH]: SearchPaneCount, + [TABS.PLAYER]: PlayerPaneCount, + [TABS.SETTINGS]: SettingsPaneCount, +}; + +// Yazi-style pane grow ratios (parent : current : preview) ≈ [1, 4, 3]. +// Panes use flexGrow (Yoga) so columns always sum to the row width regardless +// of terminal size — more robust than fixed percentages and exactly mirrors +// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs). +export const PANE_RATIO = { + parent: 1, + current: 4, + preview: 3, +} as const; + +// Number of interactive panes per tab (for the yazi h/l swipe). Slots beyond +// a tab's count are not focusable. Defined here (after TABS) to avoid re-introducing +// the old NavigationContext top-level-init circular deadlock. +export const TabPaneCount: Record = { + [TABS.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 }; diff --git a/src/utils/search.ts b/src/utils/search.ts index 2f4a0f5..ab2f32b 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -1,156 +1,174 @@ -import { searchSourceByType } from "./source-searcher" -import type { PodcastSource, SearchResult } from "../types/source" -import type { Episode } from "../types/episode" +import { searchSourceByType } from "./source-searcher"; +import type { PodcastSource, SearchResult } from "../types/source"; +import type { Episode } from "../types/episode"; type SearchCacheEntry = { - timestamp: number - results: SearchResult[] -} + timestamp: number; + results: SearchResult[]; +}; type SearchOptions = { - cacheTtl?: number -} + cacheTtl?: number; +}; -const searchCache = new Map() -const rateLimitState = new Map() -const RATE_LIMIT_WINDOW_MS = 60000 -const RATE_LIMIT_MAX_CALLS = 20 +const searchCache = new Map(); +const rateLimitState = new Map(); +const RATE_LIMIT_WINDOW_MS = 60000; +const RATE_LIMIT_MAX_CALLS = 20; const throttleSource = async (sourceId: string) => { - const now = Date.now() - const windowStart = now - RATE_LIMIT_WINDOW_MS - const timestamps = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [] + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW_MS; + const timestamps = + rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []; - if (timestamps.length >= RATE_LIMIT_MAX_CALLS) { - const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now - if (waitMs > 0) { - await new Promise((resolve) => setTimeout(resolve, waitMs)) - } - } + if (timestamps.length >= RATE_LIMIT_MAX_CALLS) { + const waitMs = timestamps[0] + RATE_LIMIT_WINDOW_MS - now; + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + } - const updated = rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? [] - updated.push(Date.now()) - rateLimitState.set(sourceId, updated) -} + const updated = + rateLimitState.get(sourceId)?.filter((ts) => ts > windowStart) ?? []; + updated.push(Date.now()); + rateLimitState.set(sourceId, updated); +}; const buildCacheKey = (query: string, sourceIds: string[]) => { - const keySources = [...sourceIds].sort().join(",") - return `${query.toLowerCase()}::${keySources}` -} + const keySources = [...sourceIds].sort().join(","); + return `${query.toLowerCase()}::${keySources}`; +}; const isCacheValid = (entry: SearchCacheEntry, ttl: number) => - Date.now() - entry.timestamp < ttl + Date.now() - entry.timestamp < ttl; const dedupeResults = (results: SearchResult[]): SearchResult[] => { - const map = new Map() - for (const result of results) { - const key = result.podcast.feedUrl || result.podcast.id || result.podcast.title - const existing = map.get(key) - if (!existing || (result.score ?? 0) > (existing.score ?? 0)) { - map.set(key, result) - } - } - return Array.from(map.values()) -} + const map = new Map(); + for (const result of results) { + const key = + result.podcast.feedUrl || result.podcast.id || result.podcast.title; + const existing = map.get(key); + if (!existing || (result.score ?? 0) > (existing.score ?? 0)) { + map.set(key, result); + } + } + return Array.from(map.values()); +}; export const searchPodcasts = async ( - query: string, - sourceIds: string[], - sources: PodcastSource[], - options: SearchOptions = {} + query: string, + sourceIds: string[], + sources: PodcastSource[], + options: SearchOptions = {}, ): Promise => { - const trimmed = query.trim() - if (!trimmed) return [] + const trimmed = query.trim(); + if (!trimmed) return []; - const activeSources = sources.filter( - (source) => sourceIds.includes(source.id) && source.enabled - ) + const activeSources = sources.filter( + (source) => sourceIds.includes(source.id) && source.enabled, + ); - if (activeSources.length === 0) return [] + if (activeSources.length === 0) { + // No enabled sources — surface a clear cause instead of returning empty, + // which otherwise looks indistinguishable from a network failure. + if (sourceIds.length === 0) { + throw new Error("No search sources are enabled"); + } + throw new Error("No enabled sources match the selected search sources"); + } - const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5 - const cacheKey = buildCacheKey(trimmed, activeSources.map((s) => s.id)) - const cached = searchCache.get(cacheKey) - if (cached && isCacheValid(cached, cacheTtl)) { - return cached.results - } + const cacheTtl = options.cacheTtl ?? 1000 * 60 * 5; + const cacheKey = buildCacheKey( + trimmed, + activeSources.map((s) => s.id), + ); + const cached = searchCache.get(cacheKey); + if (cached && isCacheValid(cached, cacheTtl)) { + return cached.results; + } - const results: SearchResult[] = [] - const errors: Error[] = [] + const results: SearchResult[] = []; + const errors: Error[] = []; - await Promise.all( - activeSources.map(async (source) => { - try { - await throttleSource(source.id) - const sourceResults = await searchSourceByType(trimmed, source) - results.push(...sourceResults) - } catch (error) { - errors.push(error as Error) - } - }) - ) + await Promise.all( + activeSources.map(async (source) => { + try { + await throttleSource(source.id); + const sourceResults = await searchSourceByType(trimmed, source); + results.push(...sourceResults); + } catch (error) { + errors.push(error as Error); + } + }), + ); - const deduped = dedupeResults(results) - const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + const deduped = dedupeResults(results); + const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); - if (sorted.length === 0 && errors.length > 0) { - throw new Error("Search failed for all sources") - } + if (sorted.length === 0 && errors.length > 0) { + throw new Error("Search failed for all sources"); + } - searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted }) - return sorted -} + searchCache.set(cacheKey, { timestamp: Date.now(), results: sorted }); + return sorted; +}; type ItunesEpisodeResult = { - trackId?: number - trackName?: string - description?: string - shortDescription?: string - releaseDate?: string - trackTimeMillis?: number - episodeUrl?: string - previewUrl?: string - trackViewUrl?: string -} + trackId?: number; + trackName?: string; + description?: string; + shortDescription?: string; + releaseDate?: string; + trackTimeMillis?: number; + episodeUrl?: string; + previewUrl?: string; + trackViewUrl?: string; +}; type ItunesEpisodeResponse = { - resultCount: number - results: ItunesEpisodeResult[] -} + resultCount: number; + results: ItunesEpisodeResult[]; +}; export const searchEpisodes = async ( - query: string, - feedId: string + query: string, + feedId: string, ): Promise => { - const trimmed = query.trim() - if (!trimmed) return [] + const trimmed = query.trim(); + if (!trimmed) return []; - const url = new URL("https://itunes.apple.com/search") - url.searchParams.set("term", trimmed) - url.searchParams.set("media", "podcast") - url.searchParams.set("entity", "podcastEpisode") - url.searchParams.set("country", "US") - url.searchParams.set("lang", "en_us") + const url = new URL("https://itunes.apple.com/search"); + url.searchParams.set("term", trimmed); + url.searchParams.set("media", "podcast"); + url.searchParams.set("entity", "podcastEpisode"); + url.searchParams.set("country", "US"); + url.searchParams.set("lang", "en_us"); - const response = await fetch(url.toString()) - if (!response.ok) return [] + const response = await fetch(url.toString()); + if (!response.ok) return []; - const data = (await response.json()) as ItunesEpisodeResponse - return data.results - .map((item) => { - if (!item.trackName) return null - const id = item.trackId ? `episode-${item.trackId}` : `episode-${item.trackName}` - const audioUrl = item.episodeUrl || item.previewUrl || item.trackViewUrl || "" + const data = (await response.json()) as ItunesEpisodeResponse; + return data.results + .map((item) => { + if (!item.trackName) return null; + const id = item.trackId + ? `episode-${item.trackId}` + : `episode-${item.trackName}`; + const audioUrl = + item.episodeUrl || item.previewUrl || item.trackViewUrl || ""; - return { - id, - podcastId: feedId, - title: item.trackName, - description: item.description || item.shortDescription || "", - audioUrl, - duration: item.trackTimeMillis ? Math.round(item.trackTimeMillis / 1000) : 0, - pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(), - } - }) - .filter((item): item is Episode => Boolean(item)) -} + return { + id, + podcastId: feedId, + title: item.trackName, + description: item.description || item.shortDescription || "", + audioUrl, + duration: item.trackTimeMillis + ? Math.round(item.trackTimeMillis / 1000) + : 0, + pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(), + }; + }) + .filter((item): item is Episode => Boolean(item)); +}; diff --git a/tests/keybind-matcher.test.ts b/tests/keybind-matcher.test.ts new file mode 100644 index 0000000..5dde396 --- /dev/null +++ b/tests/keybind-matcher.test.ts @@ -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>; + +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); diff --git a/tsconfig.json b/tsconfig.json index bc77489..b2bd7cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,8 @@ "types": ["bun-types"], "baseUrl": ".", "paths": { - "@/*": ["src/*"], + "@/*": ["src/*"] } }, - "include": ["src/**/*", "tests/**/*"] + "include": ["src/**/*", "tests/**/*", "scripts/**/*"] }