From d8f11040bc3763a849272a2c5e500fef9bd07de4 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 30 Jul 2026 21:25:03 -0400 Subject: [PATCH] start revive --- .gitignore | 1 + bun.lockb | Bin 0 -> 115544 bytes scripts/tui-harness.tsx | 573 ++++++++++ src/App.tsx | 257 ++--- src/components/Shell.tsx | 586 +++++++++++ src/components/ShortcutHelp.tsx | 45 +- src/config/keybind.jsonc | 20 - src/config/keybinds.jsonc | 73 ++ src/config/shortcuts.ts | 30 +- src/context/KeybindContext.tsx | 475 ++++++--- src/context/NavigationContext.tsx | 358 +++++-- src/pages/Discover/DiscoverPage.tsx | 522 ++++++---- src/pages/Feed/FeedPage.tsx | 633 +++++++---- src/pages/MyShows/MyShowsPage.tsx | 696 +++++++------ src/pages/Player/PlayerPage.tsx | 194 ++-- src/pages/Search/SearchPage.tsx | 561 ++++++---- src/pages/Settings/SettingsPage.tsx | 272 +++-- src/stores/search.ts | 306 +++--- src/utils/audio-player.ts | 1507 +++++++++++++++------------ src/utils/event-bus.ts | 200 ++-- src/utils/keybinds-persistence.ts | 141 ++- src/utils/navigation.ts | 62 +- src/utils/search.ts | 256 ++--- tests/keybind-matcher.test.ts | 131 +++ tsconfig.json | 4 +- 25 files changed, 5344 insertions(+), 2559 deletions(-) create mode 100755 bun.lockb create mode 100644 scripts/tui-harness.tsx create mode 100644 src/components/Shell.tsx delete mode 100644 src/config/keybind.jsonc create mode 100644 src/config/keybinds.jsonc create mode 100644 tests/keybind-matcher.test.ts 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 0000000000000000000000000000000000000000..4ab240c46aef79c87c016f0b3cd883aa04c754c1 GIT binary patch literal 115544 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 [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/**/*"] }