From 2abdbaa4e960d80786b364541e39120c27626d4b Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 9 Aug 2026 09:54:52 -0400 Subject: [PATCH] cleaning up code --- bun.lockb | Bin 117904 -> 117147 bytes package.json | 8 +- scripts/tui-harness.tsx | 281 +++++++++++++--------- src/api/client.ts | 73 ------ src/api/source-handler.ts | 94 -------- src/components/PaneRow.tsx | 32 +-- src/components/Shell.tsx | 142 +++++------ src/components/ShortcutHelp.tsx | 27 --- src/components/TabNavigation.tsx | 55 ----- src/config/shortcuts.ts | 27 --- src/constants/sync-formats.ts | 12 - src/context/KeybindContext.tsx | 35 +-- src/hooks/useAudio.ts | 27 +-- src/hooks/useCachedData.ts | 34 --- src/index.tsx | 306 +++++++++++++----------- src/pages/Discover/PodcastCard.tsx | 85 ------- src/pages/Feed/FeedDetail.tsx | 194 --------------- src/pages/Feed/FeedFilter.tsx | 207 ---------------- src/pages/Feed/FeedItem.tsx | 154 ------------ src/pages/Feed/FeedList.tsx | 198 --------------- src/pages/Search/ResultCard.tsx | 95 -------- src/pages/Search/ResultDetail.tsx | 75 ------ src/pages/Search/SearchHistory.tsx | 89 ------- src/pages/Search/SearchResults.tsx | 80 ------- src/pages/Search/SourceBadge.tsx | 38 --- src/pages/Settings/PreferencesPanel.tsx | 12 + src/stores/app.ts | 3 +- src/stores/audio-nav.ts | 2 +- src/stores/discover.ts | 2 - src/stores/download.ts | 8 +- src/stores/feed.ts | 40 ++-- src/stores/search.ts | 2 - src/types/desktop-theme.ts | 163 +------------ src/types/settings.ts | 2 + src/types/sync-json.ts | 24 -- src/types/sync-xml.ts | 28 --- src/utils/app-persistence.ts | 1 + src/utils/audio-stream-reader.ts | 2 - src/utils/audio-waveform.ts | 103 -------- src/utils/cache.ts | 57 ----- src/utils/clipboard.ts | 2 +- src/utils/config-dir.ts | 8 +- src/utils/config-validation.ts | 150 ------------ src/utils/config.ts | 7 +- src/utils/data-fetcher.ts | 57 ----- src/utils/dispatch.ts | 2 +- src/utils/event-bus.ts | 4 +- src/utils/jsonc.ts | 2 +- src/utils/keybinds-persistence.ts | 1 - src/utils/search.ts | 58 ----- src/utils/source-searcher.ts | 6 +- src/utils/sync-validation.ts | 25 -- src/utils/sync.ts | 60 ----- src/utils/system-theme.ts | 9 - src/utils/theme-css.ts | 36 --- src/utils/theme-loader.ts | 38 --- src/utils/theme-observer.ts | 55 +---- src/utils/theme.ts | 67 +----- tests/auto-jump.test.ts | 43 ++++ 59 files changed, 518 insertions(+), 2929 deletions(-) delete mode 100644 src/api/client.ts delete mode 100644 src/api/source-handler.ts delete mode 100644 src/components/ShortcutHelp.tsx delete mode 100644 src/components/TabNavigation.tsx delete mode 100644 src/config/shortcuts.ts delete mode 100644 src/constants/sync-formats.ts delete mode 100644 src/hooks/useCachedData.ts delete mode 100644 src/pages/Discover/PodcastCard.tsx delete mode 100644 src/pages/Feed/FeedDetail.tsx delete mode 100644 src/pages/Feed/FeedFilter.tsx delete mode 100644 src/pages/Feed/FeedItem.tsx delete mode 100644 src/pages/Feed/FeedList.tsx delete mode 100644 src/pages/Search/ResultCard.tsx delete mode 100644 src/pages/Search/ResultDetail.tsx delete mode 100644 src/pages/Search/SearchHistory.tsx delete mode 100644 src/pages/Search/SearchResults.tsx delete mode 100644 src/pages/Search/SourceBadge.tsx delete mode 100644 src/types/sync-json.ts delete mode 100644 src/types/sync-xml.ts delete mode 100644 src/utils/audio-waveform.ts delete mode 100644 src/utils/cache.ts delete mode 100644 src/utils/config-validation.ts delete mode 100644 src/utils/data-fetcher.ts delete mode 100644 src/utils/sync-validation.ts delete mode 100644 src/utils/sync.ts delete mode 100644 src/utils/theme-css.ts create mode 100644 tests/auto-jump.test.ts diff --git a/bun.lockb b/bun.lockb index 9f1835977daf6311eaa18d895c8c2f91f4edba88..3cf431d75af7c978277fa3201cd2d38f4b30460c 100755 GIT binary patch delta 19019 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 { 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}`); - } + return mods.length ? mods : undefined; +}; + +const BUILDERS: Record Action> = { + key: (p) => { + if (!p[0]) throw new Error("key requires a argument"); + return { t: "key", k: p[0], mods: modsOrUndefined(p) }; + }, + arrow: (p) => { + if (!p[0] || !["up", "down", "left", "right"].includes(p[0])) + throw new Error("arrow requires up|down|left|right"); + return { + t: "arrow", + d: p[0] as "up" | "down" | "left" | "right", + mods: modsOrUndefined(p), + }; + }, + enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }), + escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }), + tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }), + space: (p) => ({ t: "space", mods: modsOrUndefined(p) }), + backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }), + type: (p) => { + if (p[0] === undefined) throw new Error("type requires "); + // Re-join the rest in case text had spaces; p[0] already is first token, + // caller should quote. We join all positional as the text. + return { t: "type", s: p.join(" ") }; + }, + wait: (p) => { + if (!p[0]) throw new Error("wait requires "); + return { t: "wait", ms: parseInt(p[0], 10) || 0 }; + }, + resize: (p) => { + if (!p[0] || !p[1]) throw new Error("resize requires "); + return { + t: "resize", + w: parseInt(p[0], 10) || 100, + h: parseInt(p[1], 10) || 30, + }; + }, +}; + +function buildAction(cmd: string, positional: string[]): Action | null { + const builder = BUILDERS[cmd]; + if (builder) return builder(positional); + // Local-only commands return early in main before this is reached; keep + // the null contract so the public behavior is unchanged. + if ( + cmd === "frame" || + cmd === "state" || + cmd === "reset" || + cmd === "actions" || + cmd === "init" || + cmd === "seed" + ) + return null; + // Single table-miss error for any unknown command. + throw new Error(`unknown command: ${cmd}`); } // ── Execute one action against a mounted setup ────────────────────────────── @@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise { await new Promise((r) => setTimeout(r, 40)); } -// ── Main ─────────────────────────────────────────────────────────────────── -async function main() { - activateSandbox(); - captureIssues(); +// ── Mount, snapshot & output (extracted from main) ───────────────────────── +// A line is "visually empty" if it's either fully blank OR contains only +// box-drawing chars + whitespace (i.e. empty-pane interior padding like +// "│ │"). Runs of these collapse to a single `…N` marker so an empty +// 24-row pane costs 1 line, not 18. +const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼"; +const isVisuallyEmpty = (l: string): boolean => + l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch)); - const argv = process.argv.slice(2); - const cmd = argv[0] ?? "frame"; - const { flags, positional } = parseFlags(argv.slice(1)); +function trimFrame(plainFrame: string): string { + const lines = plainFrame + .replace(/\n+$/, "") + .split("\n") + .map((l) => l.replace(/\s+$/, "")); + while (lines.length && isVisuallyEmpty(lines[lines.length - 1])) + lines.pop(); + const out: string[] = []; + let blank = 0; + const flushBlanks = () => { + if (blank >= 3) out.push(` …${blank} empty`); + else for (let i = 0; i < blank; i++) out.push(""); + blank = 0; + }; + for (const l of lines) { + if (isVisuallyEmpty(l)) { + blank++; + } else { + flushBlanks(); + out.push(l); + } + } + flushBlanks(); + return out.join("\n"); +} - // Local-only commands that don't mount. +// Local-only commands that don't mount. Returns true if handled (main returns). +function runLocal(cmd: string, flags: Record): boolean { if (cmd === "reset") { saveActions([]); console.log("✔ actions log cleared."); - return; + return true; } if (cmd === "actions") { const a = loadActions(); console.log(`Action log (${a.length}):`); console.log(JSON.stringify(a, null, 2)); - return; + return true; } if (cmd === "seed") { const from = String( @@ -349,9 +392,29 @@ async function main() { const dest = join(process.env.XDG_CONFIG_HOME!, "podtui"); cpSync(from, dest, { recursive: true }); console.log(`✔ seeded sandbox config from ${from} → ${dest}`); - return; + return true; } + return false; +} +type FrameCapture = { + lines: { spans: Span[] }[]; + cols: number; + rows: number; + cursor: [number, number]; +}; + +async function mountApp( + flags: Record, + cmd: string, + positional: string[], +): Promise<{ + setup: any; + spans: FrameCapture; + plainFrame: string; + audioControls: any; + actions: Action[]; +}> { // Size settings. let width = 100; let height = 30; @@ -448,12 +511,7 @@ async function main() { // 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 spans = setup.captureSpans() as FrameCapture; const plainFrame = setup.captureCharFrame(); // Dump structured spans + plain frame. @@ -462,6 +520,10 @@ async function main() { writeFileSync(FRAME_TXT, plainFrame); } catch {} + return { setup, spans, plainFrame, audioControls, actions }; +} + +async function snapshotState(audioControls: any): Promise> { // Store state snapshot. const state: Record = {}; try { @@ -514,54 +576,31 @@ async function main() { try { writeFileSync(STATE_JSON, JSON.stringify(state)); } catch {} + return state; +} - // ── Output ────────────────────────────────────────────────────────────── +function emitOutput(p: { + spans: FrameCapture; + plainFrame: string; + state: Record; + actions: Action[]; + cmd: string; + flags: Record; + positional: string[]; +}): void { // Compact by default: trimmed frame, one-line state per section, no styles // block, no boilerplate footer. Use --styles / --verbose to opt back in. - const verbose = !!flags.verbose; - const scope = cmd === "state" ? String(positional[0] || "all") : "all"; - - // A line is "visually empty" if it's either fully blank OR contains only - // box-drawing chars + whitespace (i.e. empty-pane interior padding like - // "│ │"). Runs of these collapse to a single `…N` marker so an empty - // 24-row pane costs 1 line, not 18. - const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼"; - const isVisuallyEmpty = (l: string): boolean => - l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch)); - const frameTrimmed = (() => { - const lines = plainFrame - .replace(/\n+$/, "") - .split("\n") - .map((l) => l.replace(/\s+$/, "")); - while (lines.length && isVisuallyEmpty(lines[lines.length - 1])) - lines.pop(); - const out: string[] = []; - let blank = 0; - const flushBlanks = () => { - if (blank >= 3) out.push(` …${blank} empty`); - else for (let i = 0; i < blank; i++) out.push(""); - blank = 0; - }; - for (const l of lines) { - if (isVisuallyEmpty(l)) { - blank++; - } else { - flushBlanks(); - out.push(l); - } - } - flushBlanks(); - return out.join("\n"); - })(); + const verbose = !!p.flags.verbose; + const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all"; console.log( - `FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`, + `FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`, ); - console.log(frameTrimmed); + console.log(trimFrame(p.plainFrame)); // ── distinct styles: opt-in only (--styles OR --verbose) ── - if (scope === "all" && (flags.styles || verbose)) { - const styles = distinctStyles(spans); + if (scope === "all" && (p.flags.styles || verbose)) { + const styles = distinctStyles(p.spans); if (styles.length) { console.log("-- styles (top 20) --"); for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`); @@ -572,9 +611,9 @@ async function main() { const want = (k: string) => scope === "all" || scope === k; const compact = (obj: unknown): string => verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj); - if (want("nav")) console.log("nav " + compact(state.nav)); - if (want("audio")) console.log("audio " + compact(state.audio)); - if (want("feed")) console.log("feed " + compact(state.feed)); + if (want("nav")) console.log("nav " + compact(p.state.nav)); + if (want("audio")) console.log("audio " + compact(p.state.audio)); + if (want("feed")) console.log("feed " + compact(p.state.feed)); if (want("app")) console.log("app (not dumped in v1)"); // ── issues: terse ── @@ -586,12 +625,14 @@ async function main() { } // Footer is identical every run — only print on init or --verbose. - if (cmd === "init" || verbose) { + if (p.cmd === "init" || verbose) { console.log( `(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`, ); } +} +async function teardown(setup: any, audioControls: any): Promise { // Tear down child processes (audio backend) before exit to avoid orphans. try { if (audioControls?.stop) await audioControls.stop().catch(() => {}); @@ -606,6 +647,32 @@ async function main() { process.exit(0); } +// ── 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 (runLocal(cmd, flags)) return; + + const m = await mountApp(flags, cmd, positional); + const state = await snapshotState(m.audioControls); + emitOutput({ + spans: m.spans, + plainFrame: m.plainFrame, + state, + actions: m.actions, + cmd, + flags, + positional, + }); + await teardown(m.setup, m.audioControls); +} + main().catch((err) => { console.error("HARNESS FAILED:", err?.stack || err); process.exit(1); diff --git a/src/api/client.ts b/src/api/client.ts deleted file mode 100644 index e93e877..0000000 --- a/src/api/client.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Feed } from "../types/feed" -import type { Episode } from "../types/episode" -import type { Podcast } from "../types/podcast" -import type { PodcastSource } from "../types/source" -import { parseRSSFeed } from "@/api/rss-parser" -import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler" - -export const fetchEpisodes = async (feedUrl: string): Promise => { - try { - const response = await fetch(feedUrl) - if (!response.ok) return [] - const xml = await response.text() - return parseRSSFeed(xml, feedUrl).episodes - } catch { - return [] - } -} - -export const fetchFeeds = async ( - sourceIds: string[], - sources: PodcastSource[] -): Promise => { - const active = sources.filter((source) => sourceIds.includes(source.id)) - const feeds: Feed[] = [] - - await Promise.all( - active.map(async (source) => { - try { - if (source.type === "rss") { - const rssFeeds = await handleRSSSource(source) - feeds.push(...rssFeeds) - } else if (source.type === "api") { - const apiFeeds = await handleAPISource(source, "") - feeds.push(...apiFeeds) - } else { - const customFeeds = await handleCustomSource(source, "") - feeds.push(...customFeeds) - } - } catch { - // ignore individual source errors - } - }) - ) - - return feeds -} - -export const searchPodcasts = async ( - query: string, - sources: PodcastSource[] -): Promise => { - const results: Podcast[] = [] - await Promise.all( - sources.map(async (source) => { - try { - if (source.type === "rss") { - const feeds = await handleRSSSource(source) - results.push(...feeds.map((feed: Feed) => feed.podcast)) - } else if (source.type === "api") { - const feeds = await handleAPISource(source, query) - results.push(...feeds.map((feed: Feed) => feed.podcast)) - } else { - const feeds = await handleCustomSource(source, query) - results.push(...feeds.map((feed: Feed) => feed.podcast)) - } - } catch { - // ignore errors - } - }) - ) - - return results -} diff --git a/src/api/source-handler.ts b/src/api/source-handler.ts deleted file mode 100644 index a9f4cdd..0000000 --- a/src/api/source-handler.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { FeedVisibility } from "../types/feed" -import type { Feed } from "../types/feed" -import type { PodcastSource } from "../types/source" -import type { Podcast } from "../types/podcast" -import { parseRSSFeed } from "./rss-parser" - -const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => { - return { - id: `${sourceId}-${podcast.id}`, - podcast, - episodes: [], - visibility: FeedVisibility.PUBLIC, - sourceId, - lastUpdated: new Date(), - isPinned: false, - } -} - -export const handleRSSSource = async (source: PodcastSource): Promise => { - if (!source.baseUrl) return [] - const response = await fetch(source.baseUrl) - if (!response.ok) return [] - const xml = await response.text() - const parsed = parseRSSFeed(xml, source.baseUrl) - return [ - { - id: `${source.id}-${parsed.feedUrl}`, - podcast: { - id: parsed.id, - title: parsed.title, - description: parsed.description, - feedUrl: parsed.feedUrl, - author: parsed.author, - categories: parsed.categories, - lastUpdated: parsed.lastUpdated, - isSubscribed: true, - }, - episodes: parsed.episodes, - visibility: FeedVisibility.PUBLIC, - sourceId: source.id, - lastUpdated: parsed.lastUpdated, - isPinned: false, - }, - ] -} - -export const handleAPISource = async ( - source: PodcastSource, - query: string -): Promise => { - const url = new URL(source.baseUrl || "https://itunes.apple.com/search") - url.searchParams.set("term", query || "podcast") - url.searchParams.set("media", "podcast") - url.searchParams.set("entity", "podcast") - url.searchParams.set("country", source.country || "US") - url.searchParams.set("lang", source.language || "en_us") - - const response = await fetch(url.toString()) - if (!response.ok) return [] - const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> } - const results = data.results ?? [] - - return results - .filter((item) => item.collectionName && item.feedUrl) - .map((item) => { - const podcast: Podcast = { - id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`, - title: item.collectionName || "Untitled Podcast", - description: item.collectionName || "", - feedUrl: item.feedUrl || "", - author: item.artistName, - lastUpdated: new Date(), - isSubscribed: false, - } - return buildFeedFromPodcast(podcast, source.id) - }) -} - -export const handleCustomSource = async ( - source: PodcastSource, - query: string -): Promise => { - if (!query) return [] - const podcast: Podcast = { - id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`, - title: `${query} Highlights`, - description: `Curated results for ${query}`, - feedUrl: source.baseUrl || "", - author: source.name, - lastUpdated: new Date(), - isSubscribed: false, - } - return [buildFeedFromPodcast(podcast, source.id)] -} diff --git a/src/components/PaneRow.tsx b/src/components/PaneRow.tsx index 6d5d80b..bc4f0e4 100644 --- a/src/components/PaneRow.tsx +++ b/src/components/PaneRow.tsx @@ -69,16 +69,13 @@ function resolveLabel(v: PaneLabel | undefined): string { } /** Normalize a PaneContent (static JSX or accessor) into a reactive accessor. - * We deliberately do NOT use Solid's `children()` helper here: that helper - * flattens accessor children into a stable resolved-nodes array and is the - * wrong tool for content whose ROOT swaps at runtime (e.g. the current pane - * switching between a depth-1 list fragment and a depth-2 editor — both - * truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy - * root swap, freezing the previous subtree in place. Instead we hand the - * raw accessor to a reactive `{ expr ?? }` expression below, - * which Solid compiles into a tracked `insert` effect that disposes the old - * subtree and mounts the new whenever the accessor returns a different - * element identity. */ + * We deliberately avoid Solid's `children()` helper: it flattens accessor + * children into a stable resolved-nodes array and won't re-resolve on a + * truthy→truthy root swap (e.g. the current pane switching between a + * depth-1 list fragment and a depth-2 editor), freezing the previous + * subtree. Instead the raw accessor feeds a reactive `{ expr ?? }` + * expression — a tracked `insert` effect that disposes the old subtree and + * mounts the new whenever the accessor returns a different element identity. */ function normalizeContent( v: PaneContent | undefined, ): () => JSX.Element | undefined { @@ -129,20 +126,7 @@ function Pane(props: { borderColor={borderColor()} backgroundColor={theme.background} > - {/* - * Render the content accessor directly via a reactive expression. - * `{ accessor() ?? }` compiles to a Solid `insert` - * effect that re-runs whenever the accessor's tracked signals - * change (e.g. `depth()` swapping the root from a list fragment to - * an editor). Solid disposes the previously-rendered subtree and - * mounts the new element identity. `null`/`undefined` falls back - * to the muted placeholder so the parent pane keeps its 1/7 slot - * visibly blank at depth 0. This is the correct tool for root - * swapping — unlike Solid's `children()` / ``-children, - * which only react to truthiness flips, not truthy<@->truthy root - * identity changes. - */} - {props.content() ?? } + {props.content() ?? } ); diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 47cf2ac..19641b8 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -17,10 +17,11 @@ 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 { useAudioNavStore } from "@/stores/audio-nav"; import { useFeedStore } from "@/stores/feed"; +import { useAppStore } from "@/stores/app"; import { useToast } from "@/ui/toast"; -import { emit } from "@/utils/event-bus"; +import { emit, on } from "@/utils/event-bus"; import { LayerGraph } from "@/utils/layer-graph"; import { TABS, TabPaneCount } from "@/utils/navigation"; import { createDispatcher } from "@/utils/dispatch"; @@ -48,6 +49,18 @@ export function Shell() { const [showHelp, setShowHelp] = createSignal(false); + // ── Auto jump to Player on podcast start ─────────────────────────────────── + // Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see + // "player.started" — distinct from "player.play", which also fires on + // resume), switch to the Player tab and drop into its content pane. + on("player.started", () => { + const app = useAppStore(); + if (app.state().preferences.autoJumpToPlayer) { + nav.setActiveTab(TABS.PLAYER); + nav.enterTabContent(); // PLAYER is a depth-tab — enter its content. + } + }); + /** 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) { @@ -83,74 +96,60 @@ export function Shell() { } // ── Command bar dispatch ──────────────────────────────────────────────────── + const COMMANDS: Record void> = { + quit: () => process.exit(0), + exit: () => process.exit(0), + q: () => process.exit(0), + refresh: () => + emit("nav.action", { + action: "refresh", + tab: nav.activeTab(), + pane: nav.activePane(), + mode: nav.mode(), + }), + r: () => + emit("nav.action", { + action: "refresh", + tab: nav.activeTab(), + pane: nav.activePane(), + mode: nav.mode(), + }), + play: () => audio.togglePlayback().catch(() => {}), + pause: () => audio.togglePlayback().catch(() => {}), + p: () => audio.togglePlayback().catch(() => {}), + next: () => advanceEpisode(1), + n: () => advanceEpisode(1), + prev: () => advanceEpisode(-1), + seek: (arg) => { + const n = Number(arg) || 0; + audio.seek(n).catch(() => {}); + }, + feed: () => nav.setActiveTab(TABS.FEED), + f: () => nav.setActiveTab(TABS.FEED), + shows: () => nav.setActiveTab(TABS.MYSHOWS), + myshows: () => nav.setActiveTab(TABS.MYSHOWS), + discover: () => nav.setActiveTab(TABS.DISCOVER), + d: () => nav.setActiveTab(TABS.DISCOVER), + search: () => nav.setActiveTab(TABS.SEARCH), + player: () => nav.setActiveTab(TABS.PLAYER), + settings: () => nav.setActiveTab(TABS.SETTINGS), + set: () => nav.setActiveTab(TABS.SETTINGS), + help: () => setShowHelp((v) => !v), + h: () => setShowHelp((v) => !v), + }; + 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": - return 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); - } + const unknownCommand = () => { + nav.setCommandError(`unknown command: ${name}`); + // re-enter command mode so the user sees the error + can correct + nav.enterCommand(); + nav.setCommandBuffer(cmd); + }; + (COMMANDS[name] ?? unknownCommand)(arg); } // ── Command-mode key handling ─────────────────────────────────────────────── @@ -458,18 +457,5 @@ function k_match_escape(evt: any): boolean { ); } -/** 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); - nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane. - useAudioNavStore().setSource(AudioSource.FEED); -} - // Re-export Episode type for callers building pane trees. export type { Episode } from "@/types/episode"; diff --git a/src/components/ShortcutHelp.tsx b/src/components/ShortcutHelp.tsx deleted file mode 100644 index 519cc28..0000000 --- a/src/components/ShortcutHelp.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 ( - - - - {(s) => ( - - {s.keys} - {s.action} - - )} - - - - ); -} diff --git a/src/components/TabNavigation.tsx b/src/components/TabNavigation.tsx deleted file mode 100644 index d531c20..0000000 --- a/src/components/TabNavigation.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useTheme } from "@/context/ThemeContext"; -import { TABS, TabsCount } from "@/utils/navigation"; -import { For } from "solid-js"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; -import { useNavigation } from "@/context/NavigationContext"; - -export const tabs: TabDefinition[] = [ - { id: TABS.FEED, label: "Feed" }, - { id: TABS.MYSHOWS, label: "My Shows" }, - { id: TABS.DISCOVER, label: "Discover" }, - { id: TABS.SEARCH, label: "Search" }, - { id: TABS.PLAYER, label: "Player" }, - { id: TABS.SETTINGS, label: "Settings" }, -]; - -export function TabNavigation() { - const { theme } = useTheme(); - const { activeTab, setActiveTab, activeDepth } = useNavigation(); - return ( - - - {(tab) => ( - tab.id == activeTab()} - onMouseDown={() => setActiveTab(tab.id)} - > - tab.id == activeTab()} - primary - alignSelf="center" - > - {tab.label} - - - )} - - - ); -} - -export type TabDefinition = { - id: TABS; - label: string; -}; diff --git a/src/config/shortcuts.ts b/src/config/shortcuts.ts deleted file mode 100644 index ee03c33..0000000 --- a/src/config/shortcuts.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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: "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: "x", action: "Unsubscribe focused show (My Shows)" }, - { 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/constants/sync-formats.ts b/src/constants/sync-formats.ts deleted file mode 100644 index 26c714e..0000000 --- a/src/constants/sync-formats.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const syncFormats = { - json: { - version: "1.0", - extension: ".json", - }, - xml: { - version: "1.0", - extension: ".xml", - }, -} - -export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version] diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index b4104d4..2ec2878 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -72,20 +72,7 @@ export type KeybindActionName = | "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"; + | "audio-seek-backward"; /** Resolved config: action -> list of alternative stroke-sequences. */ export type KeybindsResolved = Partial>; @@ -146,7 +133,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] { } /** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */ -export function strokeFromEvent(evt: { +function strokeFromEvent(evt: { name: string; ctrl?: boolean; meta?: boolean; @@ -154,7 +141,7 @@ export function strokeFromEvent(evt: { }): Stroke { // Uppercase letter events from opentui arrive as name="q" + shift; normalize. return { - key: (evt.name ?? "").toLowerCase(), + key: evt.name.toLowerCase(), ctrl: !!evt.ctrl, shift: !!evt.shift, meta: !!evt.meta, @@ -171,7 +158,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean { } /** A human label for a stroke, for the status bar / help. */ -export function strokeLabel(s: Stroke): string { +function strokeLabel(s: Stroke): string { let out = ""; if (s.ctrl) out += "C-"; if (s.meta) out += "M-"; @@ -180,7 +167,7 @@ export function strokeLabel(s: Stroke): string { return out; } -export function sequenceLabel(seq: Stroke[]): string { +function sequenceLabel(seq: Stroke[]): string { return seq.map(strokeLabel).join(" "); } @@ -338,17 +325,6 @@ export const { use: useKeybinds, provider: KeybindProvider } = 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(() => {}); }); @@ -366,7 +342,6 @@ export const { use: useKeybinds, provider: KeybindProvider } = pending, match, tryMatch, - isInverting, print, save, load, diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index d80f499..df3793c 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -138,7 +138,6 @@ function startPolling(): void { const progressStore = useProgressStore(); progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed()); - // Update platform media position const media = useMediaRegistry(); media.setPosition(pos); } @@ -215,6 +214,9 @@ async function play(episode: Episode): Promise { startPolling(); emit("player.play", { episodeId: episode.id }); + // Distinct from "player.play" (which also fires on resume): signals a + // fresh episode start so Shell can honor the auto-jump-to-player pref. + emit("player.started", { episodeId: episode.id }); } catch (err) { setError(err instanceof Error ? err.message : "Playback failed"); setIsPlaying(false); @@ -285,7 +287,6 @@ async function stop(): Promise { stopPolling(); emit("player.stop", {}); - // Clear platform media controls const media = useMediaRegistry(); media.clearNowPlaying(); } catch (err) { @@ -332,12 +333,8 @@ async function doSetSpeed(spd: number): Promise { setSpeed(clamped); // Sync back to app store - try { - const appStore = useAppStore(); - appStore.updateSettings({ playbackSpeed: clamped }); - } catch { - // Store may not be available - } + const appStore = useAppStore(); + appStore.updateSettings({ playbackSpeed: clamped }); } async function switchBackend(name: BackendName): Promise { @@ -347,14 +344,12 @@ async function switchBackend(name: BackendName): Promise { const vol = volume(); const spd = speed(); - // Stop current backend if (backend) { stopPolling(); backend.dispose(); backend = null; } - // Create new backend backend = createAudioBackend(name); setBackendName(backend.name); setAvailablePlayers(detectPlayers()); @@ -388,14 +383,10 @@ export function useAudio(): AudioControls { // Sync initial speed from app store if (refCount === 0) { - try { - const appStore = useAppStore(); - const storeSpeed = appStore.state().settings.playbackSpeed; - if (storeSpeed && storeSpeed !== speed()) { - setSpeed(storeSpeed); - } - } catch { - // Store may not be available yet + const appStore = useAppStore(); + const storeSpeed = appStore.state().settings.playbackSpeed; + if (storeSpeed && storeSpeed !== speed()) { + setSpeed(storeSpeed); } } diff --git a/src/hooks/useCachedData.ts b/src/hooks/useCachedData.ts deleted file mode 100644 index 2ca4450..0000000 --- a/src/hooks/useCachedData.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createSignal, onCleanup } from "solid-js" - -type CacheOptions = { - fetcher: () => Promise - intervalMs?: number -} - -export const useCachedData = (options: CacheOptions) => { - const [data, setData] = createSignal(null) - const [loading, setLoading] = createSignal(false) - const [error, setError] = createSignal(null) - - const refresh = async () => { - setLoading(true) - setError(null) - try { - const value = await options.fetcher() - setData(() => value) - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load data") - } finally { - setLoading(false) - } - } - - refresh() - - if (options.intervalMs) { - const interval = setInterval(refresh, options.intervalMs) - onCleanup(() => clearInterval(interval)) - } - - return { data, loading, error, refresh } -} diff --git a/src/index.tsx b/src/index.tsx index b484a0b..9e65ce5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,6 @@ +import type { Feed } from "./types/feed" +import type { Episode } from "./types/episode" + const VERSION = "0.2.1"; interface CliArgs { @@ -37,160 +40,173 @@ if (cliArgs.version) { process.exit(0); } +// ── CLI handlers ────────────────────────────────────────────────────── + +/** Find the most recent episode across all feeds */ +function findLatestEpisode( + feeds: Feed[], +): { feed: Feed; episode: Episode } | null { + let latest: { feed: Feed; episode: Episode } | null = null + let latestDate = 0 + + for (const feed of feeds) { + if (feed.episodes.length === 0) continue + const ep = feed.episodes[0] + const epDate = + ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate) + if (epDate > latestDate) { + latestDate = epDate + latest = { feed, episode: ep } + } + } + + return latest +} + +/** Search feeds by title and print matching shows */ +function handleQuery(feeds: Feed[], query: string): void { + const normalizedQuery = query.toLowerCase() + + const matches = feeds.filter((feed) => { + const title = feed.podcast.title.toLowerCase() + return title.includes(normalizedQuery) + }) + + if (matches.length === 0) { + console.log(`No shows found matching: ${query}`) + if (feeds.length > 0) { + console.log("\nAvailable shows:") + feeds.slice(0, 5).forEach((feed) => { + console.log(` - ${feed.podcast.title}`) + }) + if (feeds.length > 5) { + console.log(` ... and ${feeds.length - 5} more`) + } + } + process.exit(0) + } + + if (matches.length === 1) { + const feed = matches[0] + console.log(`\n${feed.podcast.title}`) + if (feed.podcast.description) { + console.log( + feed.podcast.description.substring(0, 200) + + (feed.podcast.description.length > 200 ? "..." : ""), + ) + } + console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`) + feed.episodes.slice(0, 5).forEach((ep, idx) => { + const date = + ep.pubDate instanceof Date + ? ep.pubDate.toLocaleDateString() + : String(ep.pubDate) + console.log(` ${idx + 1}. ${ep.title} (${date})`) + }) + process.exit(0) + } + + console.log(`\nClosest matches for "${query}":`) + matches.slice(0, 5).forEach((feed, idx) => { + console.log(` ${idx + 1}. ${feed.podcast.title}`) + }) + process.exit(0) +} + +/** Resolve and play an episode from `arg` (title path or "latest") */ +async function handlePlay(feeds: Feed[], arg: string): Promise { + const normalizedArg = arg.toLowerCase() + + let feedResult: Feed | null = null + let episodeResult: Episode | null = null + + if (normalizedArg === "latest") { + const latest = findLatestEpisode(feeds) + if (latest) { + feedResult = latest.feed + episodeResult = latest.episode + } + } else { + const parts = normalizedArg.split("/") + const showQuery = parts[0] + const episodeQuery = parts[1] + + const matchingFeeds = feeds.filter((feed) => + feed.podcast.title.toLowerCase().includes(showQuery), + ) + + if (matchingFeeds.length === 0) { + console.log(`No show found matching: ${showQuery}`) + process.exit(1) + } + + const feed = matchingFeeds[0] + + if (!episodeQuery) { + if (feed.episodes.length > 0) { + feedResult = feed + episodeResult = feed.episodes[0] + } else { + console.log(`No episodes available for: ${feed.podcast.title}`) + process.exit(1) + } + } else if (episodeQuery === "latest") { + feedResult = feed + episodeResult = feed.episodes[0] + } else { + const matchingEpisode = feed.episodes.find((ep) => + ep.title.toLowerCase().includes(episodeQuery), + ) + + if (matchingEpisode) { + feedResult = feed + episodeResult = matchingEpisode + } else { + console.log(`Episode not found: ${episodeQuery}`) + console.log(`Available episodes for ${feed.podcast.title}:`) + feed.episodes.slice(0, 5).forEach((ep, idx) => { + console.log(` ${idx + 1}. ${ep.title}`) + }) + process.exit(1) + } + } + } + + if (!feedResult || !episodeResult) { + console.log("Could not find episode to play") + process.exit(1) + } + + console.log(`\nPlaying: ${episodeResult.title}`) + console.log(`Show: ${feedResult.podcast.title}`) + + try { + const { createAudioBackend } = await import("./utils/audio-player") + const backend = createAudioBackend() + if (episodeResult.audioUrl) { + await backend.play(episodeResult.audioUrl) + console.log("Playback started (use the UI to control)") + } else { + console.log("No audio URL available for this episode") + process.exit(1) + } + } catch (err) { + console.error("Playback error:", err) + process.exit(1) + } +} + if (cliArgs.query !== null || cliArgs.play !== null) { import("./utils/feeds-persistence") .then(async ({ loadFeedsFromFile }) => { const feeds = await loadFeedsFromFile(); if (cliArgs.query !== null) { - const query = cliArgs.query; - const normalizedQuery = query.toLowerCase(); - - const matches = feeds.filter((feed) => { - const title = feed.podcast.title.toLowerCase(); - return title.includes(normalizedQuery); - }); - - if (matches.length === 0) { - console.log(`No shows found matching: ${query}`); - if (feeds.length > 0) { - console.log("\nAvailable shows:"); - feeds.slice(0, 5).forEach((feed) => { - console.log(` - ${feed.podcast.title}`); - }); - if (feeds.length > 5) { - console.log(` ... and ${feeds.length - 5} more`); - } - } - process.exit(0); - } - - if (matches.length === 1) { - const feed = matches[0]; - console.log(`\n${feed.podcast.title}`); - if (feed.podcast.description) { - console.log( - feed.podcast.description.substring(0, 200) + - (feed.podcast.description.length > 200 ? "..." : ""), - ); - } - console.log( - `\nRecent episodes (${Math.min(5, feed.episodes.length)}):`, - ); - feed.episodes.slice(0, 5).forEach((ep, idx) => { - const date = - ep.pubDate instanceof Date - ? ep.pubDate.toLocaleDateString() - : String(ep.pubDate); - console.log(` ${idx + 1}. ${ep.title} (${date})`); - }); - process.exit(0); - } - - console.log(`\nClosest matches for "${query}":`); - matches.slice(0, 5).forEach((feed, idx) => { - console.log(` ${idx + 1}. ${feed.podcast.title}`); - }); - process.exit(0); + handleQuery(feeds, cliArgs.query) } if (cliArgs.play !== null) { - const playArg = cliArgs.play; - const normalizedArg = playArg.toLowerCase(); - - let feedResult: (typeof feeds)[0] | null = null; - let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null; - - if (normalizedArg === "latest") { - let latestFeed: (typeof feeds)[0] | null = null; - let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null; - let latestDate = 0; - - for (const feed of feeds) { - if (feed.episodes.length > 0) { - const ep = feed.episodes[0]; - const epDate = - ep.pubDate instanceof Date - ? ep.pubDate.getTime() - : Number(ep.pubDate); - if (epDate > latestDate) { - latestDate = epDate; - latestFeed = feed; - latestEpisode = ep; - } - } - } - - feedResult = latestFeed; - episodeResult = latestEpisode; - } else { - const parts = normalizedArg.split("/"); - const showQuery = parts[0]; - const episodeQuery = parts[1]; - - const matchingFeeds = feeds.filter((feed) => - feed.podcast.title.toLowerCase().includes(showQuery), - ); - - if (matchingFeeds.length === 0) { - console.log(`No show found matching: ${showQuery}`); - process.exit(1); - } - - const feed = matchingFeeds[0]; - - if (!episodeQuery) { - if (feed.episodes.length > 0) { - feedResult = feed; - episodeResult = feed.episodes[0]; - } else { - console.log(`No episodes available for: ${feed.podcast.title}`); - process.exit(1); - } - } else if (episodeQuery === "latest") { - feedResult = feed; - episodeResult = feed.episodes[0]; - } else { - const matchingEpisode = feed.episodes.find((ep) => - ep.title.toLowerCase().includes(episodeQuery), - ); - - if (matchingEpisode) { - feedResult = feed; - episodeResult = matchingEpisode; - } else { - console.log(`Episode not found: ${episodeQuery}`); - console.log(`Available episodes for ${feed.podcast.title}:`); - feed.episodes.slice(0, 5).forEach((ep, idx) => { - console.log(` ${idx + 1}. ${ep.title}`); - }); - process.exit(1); - } - } - } - - if (!feedResult || !episodeResult) { - console.log("Could not find episode to play"); - process.exit(1); - } - - console.log(`\nPlaying: ${episodeResult.title}`); - console.log(`Show: ${feedResult.podcast.title}`); - - try { - const { createAudioBackend } = await import("./utils/audio-player"); - const backend = createAudioBackend(); - if (episodeResult.audioUrl) { - await backend.play(episodeResult.audioUrl); - console.log("Playback started (use the UI to control)"); - } else { - console.log("No audio URL available for this episode"); - process.exit(1); - } - } catch (err) { - console.error("Playback error:", err); - process.exit(1); - } + await handlePlay(feeds, cliArgs.play) } }) .catch((err) => { diff --git a/src/pages/Discover/PodcastCard.tsx b/src/pages/Discover/PodcastCard.tsx deleted file mode 100644 index 6150806..0000000 --- a/src/pages/Discover/PodcastCard.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/** - * PodcastCard component - Reusable card for displaying podcast info - */ - -import { Show, For } from "solid-js"; -import type { Podcast } from "@/types/podcast"; -import { useTheme } from "@/context/ThemeContext"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; - -type PodcastCardProps = { - podcast: Podcast; - selected: boolean; - compact?: boolean; - onSelect?: () => void; - onSubscribe?: () => void; -}; - -export function PodcastCard(props: PodcastCardProps) { - const { theme } = useTheme(); - const handleSubscribeClick = () => { - props.onSubscribe?.(); - }; - - return ( - props.selected} - flexDirection="column" - padding={1} - onMouseDown={props.onSelect} - > - - props.selected} primary> - {props.podcast.title} - - - - [+] - - - - {/* Author */} - - props.selected} - tertiary - > - by {props.podcast.author} - - - - {/* Description */} - - props.selected} - tertiary - > - {props.podcast.description!.length > 80 - ? props.podcast.description!.slice(0, 80) + "..." - : props.podcast.description} - - - - {/****/} - - 0}> - - {(cat) => [{cat}]} - - - - - - - - {props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"} - - - - - ); -} diff --git a/src/pages/Feed/FeedDetail.tsx b/src/pages/Feed/FeedDetail.tsx deleted file mode 100644 index 6b4c958..0000000 --- a/src/pages/Feed/FeedDetail.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Feed detail view component for PodTUI - * Shows podcast info and episode list - */ - -import { createSignal, For, Show } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; -import type { Feed } from "@/types/feed"; -import type { Episode } from "@/types/episode"; -import { format } from "date-fns"; -import { useTheme } from "@/context/ThemeContext"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; - -interface FeedDetailProps { - feed: Feed; - focused?: boolean; - onBack?: () => void; - onPlayEpisode?: (episode: Episode) => void; -} - -export function FeedDetail(props: FeedDetailProps) { - const { theme } = useTheme(); - const [selectedIndex, setSelectedIndex] = createSignal(0); - const [showInfo, setShowInfo] = createSignal(true); - - const episodes = () => { - // Sort episodes by publication date (newest first) - return [...props.feed.episodes].sort( - (a, b) => b.pubDate.getTime() - a.pubDate.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`; - }; - - const formatDate = (date: Date): string => { - return format(date, "MMM d, yyyy"); - }; - - const handleKeyPress = (key: { name: string }) => { - const eps = episodes(); - - if (key.name === "escape" && props.onBack) { - props.onBack(); - return; - } - - if (key.name === "i") { - setShowInfo((v) => !v); - return; - } - - if (key.name === "v") { - props.feed.podcast.onToggleVisibility?.(props.feed.id); - return; - } - - if (key.name === "up" || key.name === "k") { - setSelectedIndex((i) => Math.max(0, i - 1)); - } else if (key.name === "down" || key.name === "j") { - setSelectedIndex((i) => Math.min(eps.length - 1, i + 1)); - } else if (key.name === "return") { - const episode = eps[selectedIndex()]; - if (episode && props.onPlayEpisode) { - props.onPlayEpisode(episode); - } - } else if (key.name === "home" || key.name === "g") { - setSelectedIndex(0); - } else if (key.name === "end") { - setSelectedIndex(eps.length - 1); - } else if (key.name === "pageup") { - setSelectedIndex((i) => Math.max(0, i - 10)); - } else if (key.name === "pagedown") { - setSelectedIndex((i) => Math.min(eps.length - 1, i + 10)); - } - }; - - useKeyboard((key) => { - if (!props.focused) return; - handleKeyPress(key); - }); - - return ( - - {/* Header with back button */} - - - false} primary>[Esc] Back - - setShowInfo((v) => !v)} borderColor={theme.border}> - false} primary>[i] {showInfo() ? "Hide" : "Show"} Info - - props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}> - false} primary>[v] Toggle Visibility - - - - {/* Podcast info section */} - - - false} primary> - {props.feed.customName || props.feed.podcast.title} - - {props.feed.podcast.author && ( - - false} tertiary>by - false} primary>{props.feed.podcast.author} - - )} - - false} tertiary> - {props.feed.podcast.description?.slice(0, 200)} - {(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""} - - - - - false} tertiary>Episodes: - false} tertiary>{props.feed.episodes.length} - - - false} tertiary>Updated: - false} tertiary>{formatDate(props.feed.lastUpdated)} - - false} tertiary> - {props.feed.visibility === "public" ? "[Public]" : "[Private]"} - - {props.feed.isPinned && false} tertiary>[Pinned]} - - - false} tertiary>[v] Toggle Visibility - - - - - {/* Episodes header */} - - false} primary> - Episodes - - false} tertiary>({episodes().length} total) - - - {/* Episode list */} - - - {(episode, index) => ( - index() === selectedIndex()} - flexDirection="column" - gap={0} - padding={1} - onMouseDown={() => { - setSelectedIndex(index()); - if (props.onPlayEpisode) { - props.onPlayEpisode(episode); - } - }} - > - index() === selectedIndex()} - primary - > - {index() === selectedIndex() ? ">" : " "} - - index() === selectedIndex()} - primary - > - {episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""} - {episode.title} - - - index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)} - index() === selectedIndex()} tertiary>{formatDuration(episode.duration)} - - - )} - - - - {/* Help text */} - - j/k to navigate, Enter to play, i to toggle info, Esc to go back - - - ); -} diff --git a/src/pages/Feed/FeedFilter.tsx b/src/pages/Feed/FeedFilter.tsx deleted file mode 100644 index 678ec01..0000000 --- a/src/pages/Feed/FeedFilter.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Feed filter component for PodTUI - * Toggle and filter options for feed list - */ - -import { createSignal } from "solid-js"; -import { FeedVisibility, FeedSortField } from "@/types/feed"; -import type { FeedFilter } from "@/types/feed"; -import { useTheme } from "@/context/ThemeContext"; - -interface FeedFilterProps { - filter: FeedFilter; - focused?: boolean; - onFilterChange: (filter: FeedFilter) => void; -} - -type FilterField = "visibility" | "sort" | "pinned" | "private" | "search"; - -export function FeedFilterComponent(props: FeedFilterProps) { - const { theme } = useTheme(); - const [focusField, setFocusField] = createSignal("visibility"); - const [searchValue, setSearchValue] = createSignal( - props.filter.searchQuery || "", - ); - - const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"]; - - const handleKeyPress = (key: { name: string; shift?: boolean }) => { - if (key.name === "tab") { - const currentIndex = fields.indexOf(focusField()); - const nextIndex = key.shift - ? (currentIndex - 1 + fields.length) % fields.length - : (currentIndex + 1) % fields.length; - setFocusField(fields[nextIndex]); - } else if (key.name === "return") { - if (focusField() === "visibility") { - cycleVisibility(); - } else if (focusField() === "sort") { - cycleSort(); - } else if (focusField() === "pinned") { - togglePinned(); - } else if (focusField() === "private") { - togglePrivate(); - } - } else if (key.name === "space") { - if (focusField() === "pinned") { - togglePinned(); - } else if (focusField() === "private") { - togglePrivate(); - } - } - }; - - const cycleVisibility = () => { - const current = props.filter.visibility; - let next: FeedVisibility | "all"; - if (current === "all") next = FeedVisibility.PUBLIC; - else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE; - else next = "all"; - props.onFilterChange({ ...props.filter, visibility: next }); - }; - - const cycleSort = () => { - const sortOptions: FeedSortField[] = [ - FeedSortField.UPDATED, - FeedSortField.TITLE, - FeedSortField.EPISODE_COUNT, - FeedSortField.LATEST_EPISODE, - ]; - const currentIndex = sortOptions.indexOf( - props.filter.sortBy as FeedSortField, - ); - const nextIndex = (currentIndex + 1) % sortOptions.length; - props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] }); - }; - - const togglePinned = () => { - props.onFilterChange({ - ...props.filter, - pinnedOnly: !props.filter.pinnedOnly, - }); - }; - - const togglePrivate = () => { - props.onFilterChange({ - ...props.filter, - showPrivate: !props.filter.showPrivate, - }); - }; - - const handleSearchInput = (value: string) => { - setSearchValue(value); - props.onFilterChange({ ...props.filter, searchQuery: value }); - }; - - const visibilityLabel = () => { - const vis = props.filter.visibility; - if (vis === "all") return "All"; - if (vis === "public") return "Public"; - return "Private"; - }; - - const visibilityColor = () => { - const vis = props.filter.visibility; - if (vis === "public") return theme.success; - if (vis === "private") return theme.warning; - return theme.text; - }; - - const sortLabel = () => { - const sort = props.filter.sortBy; - switch (sort) { - case "title": - return "Title"; - case "episodeCount": - return "Episodes"; - case "latestEpisode": - return "Latest"; - case "updated": - default: - return "Updated"; - } - }; - - return ( - - - Filter Feeds - - - - {/* Visibility filter */} - - - - Show: - - {visibilityLabel()} - - - - {/* Sort filter */} - - - Sort: - {sortLabel()} - - - - {/* Pinned filter */} - - - - Pinned: - - - {props.filter.pinnedOnly ? "Yes" : "No"} - - - - - {/* Private filter */} - - - - Private: - - - {props.filter.showPrivate ? "Yes" : "No"} - - - - - - {/* Search box */} - - Search: - - - - Tab to navigate, Enter/Space to toggle - - ); -} diff --git a/src/pages/Feed/FeedItem.tsx b/src/pages/Feed/FeedItem.tsx deleted file mode 100644 index c9880ac..0000000 --- a/src/pages/Feed/FeedItem.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Feed item component for PodTUI - * Displays a single feed/podcast in the list - */ - -import type { Feed, FeedVisibility } from "@/types/feed"; -import { format } from "date-fns"; -import { useTheme } from "@/context/ThemeContext"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; - -interface FeedItemProps { - feed: Feed; - isSelected: boolean; - showEpisodeCount?: boolean; - showLastUpdated?: boolean; - compact?: boolean; -} - -export function FeedItem(props: FeedItemProps) { - const formatDate = (date: Date): string => { - return format(date, "MMM d"); - }; - - const episodeCount = () => props.feed.episodes.length; - const unplayedCount = () => { - // This would be calculated based on episode status - return props.feed.episodes.length; - }; - - const visibilityIcon = () => { - return props.feed.visibility === "public" ? "[P]" : "[*]"; - }; - - const visibilityColor = () => { - return props.feed.visibility === "public" ? theme.success : theme.warning; - }; - - const pinnedIndicator = () => { - return props.feed.isPinned ? "*" : " "; - }; - - const { theme } = useTheme(); - - if (props.compact) { - // Compact single-line view - return ( - props.isSelected} - flexDirection="row" - gap={1} - paddingLeft={1} - paddingRight={1} - onMouseDown={() => {}} - > - props.isSelected} - primary - > - {props.isSelected ? ">" : " "} - - props.isSelected} - tertiary - > - {visibilityIcon()} - - props.isSelected} - primary - > - {props.feed.customName || props.feed.podcast.title} - - {props.showEpisodeCount && ( - props.isSelected} - tertiary - > - ({episodeCount()}) - - )} - - ); - } - - // Full view with details - return ( - props.isSelected} - flexDirection="column" - gap={0} - padding={1} - onMouseDown={() => {}} - > - {/* Title row */} - - props.isSelected} - primary - > - {props.isSelected ? ">" : " "} - - props.isSelected} - tertiary - > - {visibilityIcon()} - - props.isSelected} - secondary - > - {pinnedIndicator()} - - props.isSelected} - primary - > - {props.feed.customName || props.feed.podcast.title} - - - - - {props.showEpisodeCount && ( - props.isSelected} - tertiary - > - {episodeCount()} episodes ({unplayedCount()} new) - - )} - {props.showLastUpdated && ( - props.isSelected} - tertiary - > - Updated: {formatDate(props.feed.lastUpdated)} - - )} - - - {props.feed.podcast.description && ( - props.isSelected} - paddingLeft={4} - paddingTop={0} - tertiary - > - {props.feed.podcast.description.slice(0, 60)} - {props.feed.podcast.description.length > 60 ? "..." : ""} - - )} - - ); -} diff --git a/src/pages/Feed/FeedList.tsx b/src/pages/Feed/FeedList.tsx deleted file mode 100644 index 59ee128..0000000 --- a/src/pages/Feed/FeedList.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Feed list component for PodTUI - * Scrollable list of feeds with keyboard navigation and mouse support - */ - -import { createSignal, For, Show } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; -import { FeedItem } from "./FeedItem"; -import { useFeedStore } from "@/stores/feed"; -import { FeedVisibility, FeedSortField } from "@/types/feed"; -import type { Feed } from "@/types/feed"; -import { useTheme } from "@/context/ThemeContext"; - -interface FeedListProps { - focused?: boolean; - compact?: boolean; - showEpisodeCount?: boolean; - showLastUpdated?: boolean; - onSelectFeed?: (feed: Feed) => void; - onOpenFeed?: (feed: Feed) => void; - onFocusChange?: (focused: boolean) => void; -} - -export function FeedList(props: FeedListProps) { - const { theme } = useTheme(); - const feedStore = useFeedStore(); - const [selectedIndex, setSelectedIndex] = createSignal(0); - - const filteredFeeds = () => feedStore.getFilteredFeeds(); - - const handleKeyPress = (key: { name: string }) => { - if (key.name === "escape") { - props.onFocusChange?.(false); - return; - } - const feeds = filteredFeeds(); - - if (key.name === "up" || key.name === "k") { - setSelectedIndex((i) => Math.max(0, i - 1)); - } else if (key.name === "down" || key.name === "j") { - setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1)); - } else if (key.name === "return") { - const feed = feeds[selectedIndex()]; - if (feed && props.onOpenFeed) { - props.onOpenFeed(feed); - } - } else if (key.name === "home" || key.name === "g") { - setSelectedIndex(0); - } else if (key.name === "end") { - setSelectedIndex(feeds.length - 1); - } else if (key.name === "pageup") { - setSelectedIndex((i) => Math.max(0, i - 5)); - } else if (key.name === "pagedown") { - setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5)); - } else if (key.name === "p") { - // Toggle pin on selected feed - const feed = feeds[selectedIndex()]; - if (feed) { - feedStore.togglePinned(feed.id); - } - } else if (key.name === "v") { - // Toggle visibility on selected feed - const feed = feeds[selectedIndex()]; - if (feed) { - const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC; - feedStore.updateFeed(feed.id, { visibility: newVisibility }); - } - } else if (key.name === "f") { - // Cycle visibility filter - cycleVisibilityFilter(); - } else if (key.name === "s") { - // Cycle sort - cycleSortField(); - } - - // Notify selection change - const selectedFeed = feeds[selectedIndex()]; - if (selectedFeed && props.onSelectFeed) { - props.onSelectFeed(selectedFeed); - } - }; - - useKeyboard((key) => { - if (!props.focused) return; - handleKeyPress(key); - }); - - const cycleVisibilityFilter = () => { - const current = feedStore.filter().visibility; - let next: FeedVisibility | "all"; - if (current === "all") next = FeedVisibility.PUBLIC; - else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE; - else next = "all"; - feedStore.setFilter({ ...feedStore.filter(), visibility: next }); - }; - - const cycleSortField = () => { - const sortOptions: FeedSortField[] = [ - FeedSortField.UPDATED, - FeedSortField.TITLE, - FeedSortField.EPISODE_COUNT, - FeedSortField.LATEST_EPISODE, - ]; - const current = feedStore.filter().sortBy as FeedSortField; - const idx = sortOptions.indexOf(current); - const next = sortOptions[(idx + 1) % sortOptions.length]; - feedStore.setFilter({ ...feedStore.filter(), sortBy: next }); - }; - - const visibilityLabel = () => { - const vis = feedStore.filter().visibility; - if (vis === "all") return "All"; - if (vis === "public") return "Public"; - return "Private"; - }; - - const sortLabel = () => { - const sort = feedStore.filter().sortBy; - switch (sort) { - case "title": - return "Title"; - case "episodeCount": - return "Episodes"; - case "latestEpisode": - return "Latest"; - default: - return "Updated"; - } - }; - - const handleFeedClick = (feed: Feed, index: number) => { - setSelectedIndex(index); - if (props.onSelectFeed) { - props.onSelectFeed(feed); - } - }; - - const handleFeedDoubleClick = (feed: Feed) => { - if (props.onOpenFeed) { - props.onOpenFeed(feed); - } - }; - - return ( - - {/* Header with filter controls */} - - - My Feeds - - ({filteredFeeds().length} feeds) - - - [f] {visibilityLabel()} - - - [s] {sortLabel()} - - - - - {/* Feed list in scrollbox */} - 0} - fallback={ - - - No feeds found. Add podcasts from the Discover or Search tabs. - - - } - > - - - {(feed, index) => ( - handleFeedClick(feed, index())}> - - - )} - - - - - {/* Navigation help */} - - - Enter open | Esc up | j/k navigate | p pin | f filter | s sort - - - - ); -} diff --git a/src/pages/Search/ResultCard.tsx b/src/pages/Search/ResultCard.tsx deleted file mode 100644 index 5712404..0000000 --- a/src/pages/Search/ResultCard.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Show } from "solid-js"; -import type { SearchResult } from "@/types/source"; -import { SourceBadge } from "./SourceBadge"; -import { useTheme } from "@/context/ThemeContext"; -import { SelectableBox, SelectableText } from "@/components/Selectable"; - -type ResultCardProps = { - result: SearchResult; - selected: boolean; - onSelect: () => void; - onSubscribe?: () => void; -}; - -export function ResultCard(props: ResultCardProps) { - const { theme } = useTheme(); - const podcast = () => props.result.podcast; - - return ( - props.selected} - flexDirection="column" - padding={1} - onMouseDown={props.onSelect} - > - - - props.selected} - primary - > - {podcast().title} - - - - - [Subscribed] - - - - - props.selected} - tertiary - > - by {podcast().author} - - - - - {(description) => ( - props.selected} - tertiary - > - {description().length > 120 - ? description().slice(0, 120) + "..." - : description()} - - )} - - - 0}> - - {(podcast().categories ?? []).slice(0, 3).map((category) => ( - [{category}] - ))} - - - - - { - event.stopPropagation?.(); - props.onSubscribe?.(); - }} - > - [+] Add to Feeds - - - - ); -} diff --git a/src/pages/Search/ResultDetail.tsx b/src/pages/Search/ResultDetail.tsx deleted file mode 100644 index 531cc67..0000000 --- a/src/pages/Search/ResultDetail.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Show } from "solid-js"; -import { format } from "date-fns"; -import type { SearchResult } from "@/types/source"; -import { SourceBadge } from "./SourceBadge"; -import { useTheme } from "@/context/ThemeContext"; - -type ResultDetailProps = { - result?: SearchResult; - onSubscribe?: (result: SearchResult) => void; -}; - -export function ResultDetail(props: ResultDetailProps) { - const { theme } = useTheme(); - return ( - - Select a result to see details.} - > - {(result) => ( - <> - - {result().podcast.title} - - - - - - by {result().podcast.author} - - - - {result().podcast.description} - - - 0}> - - {(result().podcast.categories ?? []).map((category) => ( - [{category}] - ))} - - - - Feed: {result().podcast.feedUrl} - - - Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")} - - - - props.onSubscribe?.(result())} - > - [+] Add to Feeds - - - - - Already subscribed - - - )} - - - ); -} diff --git a/src/pages/Search/SearchHistory.tsx b/src/pages/Search/SearchHistory.tsx deleted file mode 100644 index 2ea6bb7..0000000 --- a/src/pages/Search/SearchHistory.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * SearchHistory component for displaying and managing search history - */ - -import { For, Show } from "solid-js" -import { useTheme } from "@/context/ThemeContext" -import { SelectableBox, SelectableText } from "@/components/Selectable" - -type SearchHistoryProps = { - history: string[] - focused: boolean - selectedIndex: number - onSelect?: (query: string) => void - onRemove?: (query: string) => void - onClear?: () => void - onChange?: (index: number) => void -} - -export function SearchHistory(props: SearchHistoryProps) { - const { theme } = useTheme(); - const handleSearchClick = (index: number, query: string) => { - props.onChange?.(index) - props.onSelect?.(query) - } - - const handleRemoveClick = (query: string) => { - props.onRemove?.(query) - } - - return ( - - - Recent Searches - 0}> - props.onClear?.()} padding={0}> - [Clear All] - - - - - 0} - fallback={ - - No recent searches - - } - > - - - - {(query, index) => { - const isSelected = () => index() === props.selectedIndex && props.focused - - return ( - handleSearchClick(index(), query)} - > - - {">"} - - - {query} - - handleRemoveClick(query)} padding={0}> - [x] - - - ) - }} - - - - - - ) -} diff --git a/src/pages/Search/SearchResults.tsx b/src/pages/Search/SearchResults.tsx deleted file mode 100644 index 1f90f1e..0000000 --- a/src/pages/Search/SearchResults.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * SearchResults component for displaying podcast search results - */ - -import { For, Show } from "solid-js"; -import type { SearchResult } from "@/types/source"; -import { ResultCard } from "./ResultCard"; -import { ResultDetail } from "./ResultDetail"; - -type SearchResultsProps = { - results: SearchResult[]; - selectedIndex: number; - focused: boolean; - onSelect?: (result: SearchResult) => void; - onChange?: (index: number) => void; - isSearching?: boolean; - error?: string | null; -}; - -export function SearchResults(props: SearchResultsProps) { - const handleSelect = (index: number) => { - props.onChange?.(index); - }; - - return ( - - Searching... - - } - > - - {props.error} - - } - > - 0} - fallback={ - - - No results found. Try a different search term. - - - } - > - - - - - - {(result, index) => ( - handleSelect(index())} - onSubscribe={() => props.onSelect?.(result)} - /> - )} - - - - - - props.onSelect?.(result)} - /> - - - - - - ); -} diff --git a/src/pages/Search/SourceBadge.tsx b/src/pages/Search/SourceBadge.tsx deleted file mode 100644 index 3946b17..0000000 --- a/src/pages/Search/SourceBadge.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { SourceType } from "@/types/source"; -import { useTheme } from "@/context/ThemeContext"; - -type SourceBadgeProps = { - sourceId: string; - sourceName?: string; - sourceType?: SourceType; -}; - -const typeLabel = (sourceType?: SourceType) => { - if (sourceType === SourceType.API) return "API"; - if (sourceType === SourceType.RSS) return "RSS"; - if (sourceType === SourceType.CUSTOM) return "Custom"; - return "Source"; -}; - -// No module-level typeColor here — it needs the theme from the component. -// The correct definition lives inside SourceBadge below. -export function SourceBadge(props: SourceBadgeProps) { - const { theme } = useTheme(); - const label = () => props.sourceName || props.sourceId; - - const typeColor = (sourceType?: SourceType) => { - if (sourceType === SourceType.API) return theme.primary; - if (sourceType === SourceType.RSS) return theme.success; - if (sourceType === SourceType.CUSTOM) return theme.warning; - return theme.textMuted; - }; - - return ( - - - [{typeLabel(props.sourceType)}] - - {label()} - - ); -} diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index 4607e0f..45dbd97 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -90,5 +90,17 @@ export function usePreferencesItems(): SettingItem[] { autoDownload: !prefs().autoDownload, }), }, + { + id: "autoJumpToPlayer", + label: "Auto Jump to Player", + kind: "toggle", + display: () => (prefs().autoJumpToPlayer ? "On" : "Off"), + help: () => + `Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`, + toggle: () => + app.updatePreferences({ + autoJumpToPlayer: !prefs().autoJumpToPlayer, + }), + }, ]; } diff --git a/src/stores/app.ts b/src/stores/app.ts index b42ae2f..c8cd07e 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -35,6 +35,7 @@ const defaultSettings: AppSettings = { const defaultPreferences: UserPreferences = { showExplicit: false, autoDownload: false, + autoJumpToPlayer: true, }; const defaultState: AppState = { @@ -43,7 +44,7 @@ const defaultState: AppState = { customTheme: DEFAULT_THEME, }; -export function createAppStore() { +function createAppStore() { // Start with defaults; async load will update once ready const [state, setState] = createSignal(defaultState); diff --git a/src/stores/audio-nav.ts b/src/stores/audio-nav.ts index 66c8c5a..21ac799 100644 --- a/src/stores/audio-nav.ts +++ b/src/stores/audio-nav.ts @@ -36,7 +36,7 @@ const defaultNavState: AudioNavState = { }; /** Create audio navigation store */ -export function createAudioNavStore() { +function createAudioNavStore() { const [navState, setNavState] = createSignal(defaultNavState); /** Persist current navigation state to file (fire-and-forget) */ diff --git a/src/stores/discover.ts b/src/stores/discover.ts index 94e3a4d..ba4565e 100644 --- a/src/stores/discover.ts +++ b/src/stores/discover.ts @@ -127,7 +127,6 @@ export function createDiscoverStore() { return; } - // Build the podcast list from the manifest entries const fetched = manifest.podcasts.map(entryToPodcast); cachedAt = now; setPodcasts(fetched); @@ -173,7 +172,6 @@ export function createDiscoverStore() { const unsubscribe = (podcastId: string) => { const podcast = podcasts().find((p) => p.id === podcastId); if (podcast) { - // Remove the feed from the feed store const feedStore = useFeedStore(); feedStore.removeFeedByUrl(podcast.feedUrl); } diff --git a/src/stores/download.ts b/src/stores/download.ts index 79a41ed..975d5eb 100644 --- a/src/stores/download.ts +++ b/src/stores/download.ts @@ -38,7 +38,7 @@ interface QueueItem { } /** Create download store */ -export function createDownloadStore() { +function createDownloadStore() { const [downloads, setDownloads] = createSignal< Map >(new Map()); @@ -48,7 +48,6 @@ export function createDownloadStore() { /** Active AbortControllers keyed by episodeId */ const abortControllers = new Map(); - // Load persisted downloads on init (async () => { const loaded = await loadDownloads(); if (loaded.size > 0) setDownloads(loaded); @@ -153,7 +152,6 @@ export function createDownloadStore() { const slotsAvailable = MAX_CONCURRENT - current; const toStart = q.slice(0, slotsAvailable); - // Remove started items from queue if (toStart.length > 0) { setQueue((prev) => prev.slice(toStart.length)); } @@ -250,7 +248,6 @@ export function createDownloadStore() { return; // Already downloading or queued } - // Create download entry const entry: DownloadedEpisode = { episodeId: episode.id, feedId, @@ -269,7 +266,6 @@ export function createDownloadStore() { return next; }); - // Add to queue const queueItem: QueueItem = { episodeId: episode.id, feedId, @@ -291,10 +287,8 @@ export function createDownloadStore() { abortControllers.delete(episodeId); } - // Remove from queue setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId)); - // Update status updateDownload(episodeId, { status: DownloadStatus.NONE, progress: 0, diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 50ee23c..8484a0a 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -43,7 +43,7 @@ function saveSources(sources: PodcastSource[]): void { } /** Create feed store */ -export function createFeedStore() { +function createFeedStore() { const [feeds, setFeeds] = createSignal([]); const [sources, setSources] = createSignal([ ...DEFAULT_SOURCES, @@ -62,22 +62,18 @@ export function createFeedStore() { let result = [...feeds()]; const f = filter(); - // Filter by visibility if (f.visibility && f.visibility !== "all") { result = result.filter((feed) => feed.visibility === f.visibility); } - // Filter by source if (f.sourceId) { result = result.filter((feed) => feed.sourceId === f.sourceId); } - // Filter by pinned if (f.pinnedOnly) { result = result.filter((feed) => feed.isPinned); } - // Filter by search query if (f.searchQuery) { const query = f.searchQuery.toLowerCase(); result = result.filter( @@ -88,7 +84,6 @@ export function createFeedStore() { ); } - // Sort by selected field const sortDir = f.sortDirection === "asc" ? 1 : -1; result.sort((a, b) => { switch (f.sortBy) { @@ -111,7 +106,6 @@ export function createFeedStore() { } }); - // Pinned feeds always first result.sort((a, b) => { if (a.isPinned && !b.isPinned) return -1; if (!a.isPinned && b.isPinned) return 1; @@ -224,25 +218,21 @@ export function createFeedStore() { newEpisodes: Episode[], count: number, ) => { - try { - const dlStore = useDownloadStore(); - // Sort by pubDate descending (newest first) - const sorted = [...newEpisodes].sort( - (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), - ); - // count = 0 means download all new episodes - const toDownload = count > 0 ? sorted.slice(0, count) : sorted; - for (const ep of toDownload) { - const status = dlStore.getDownloadStatus(ep.id); - if ( - status === DownloadStatus.NONE || - status === DownloadStatus.FAILED - ) { - dlStore.startDownload(ep, feedId); - } + const dlStore = useDownloadStore(); + // Sort by pubDate descending (newest first) + const sorted = [...newEpisodes].sort( + (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), + ); + // count = 0 means download all new episodes + const toDownload = count > 0 ? sorted.slice(0, count) : sorted; + for (const ep of toDownload) { + const status = dlStore.getDownloadStatus(ep.id); + if ( + status === DownloadStatus.NONE || + status === DownloadStatus.FAILED + ) { + dlStore.startDownload(ep, feedId); } - } catch { - // Download store may not be available yet } }; diff --git a/src/stores/search.ts b/src/stores/search.ts index 3828eac..ddf5a9d 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -80,7 +80,6 @@ export function createSearchStore() { setIsSearching(true); setError(null); - // Add to history addToHistory(q); try { @@ -122,7 +121,6 @@ export function createSearchStore() { /** 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); diff --git a/src/types/desktop-theme.ts b/src/types/desktop-theme.ts index c18053f..c55558a 100644 --- a/src/types/desktop-theme.ts +++ b/src/types/desktop-theme.ts @@ -1,12 +1,4 @@ -import type { - DesktopTheme, - ThemeColors, - ThemeDefinition, - ThemeName, - ThemeToken, - ThemeVariant, -} from "../types/settings" -import type { ColorValue } from "./theme-schema" +import type { ThemeColors } from "../types/settings" // Base theme colors export const BASE_THEME_COLORS: ThemeColors = { @@ -37,156 +29,3 @@ export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = { layer2: "#161b22", layer3: "#0d1117", } - -// Theme tokens -export const BASE_THEME_TOKENS: ThemeToken = { - "background": "transparent", - "surface": "#1b1f27", - "primary": "#6fa8ff", - "secondary": "#a9b1d6", - "accent": "#f6c177", - "text": "#e6edf3", - "muted": "#7d8590", - "warning": "#f0b429", - "error": "#f47067", - "success": "#3fb950", - "layer0": "transparent", - "layer1": "#1e222e", - "layer2": "#161b22", - "layer3": "#0d1117", -} - -// Desktop theme structure -export const THEMES_DESKTOP: DesktopTheme = { - name: "PodTUI", - variants: [ - { - name: "catppuccin", - colors: { - background: "transparent", - surface: "#1e1e2e", - primary: "#89b4fa", - secondary: "#cba6f7", - accent: "#f9e2af", - text: "#cdd6f4", - textPrimary: "#cdd6f4", - textSecondary: "#cba6f7", - textTertiary: "#7f849c", - textSelectedPrimary: "#1e1e2e", - textSelectedSecondary: "#cdd6f4", - textSelectedTertiary: "#cba6f7", - muted: "#7f849c", - warning: "#fab387", - error: "#f38ba8", - success: "#a6e3a1", - layerBackgrounds: { - layer0: "transparent", - layer1: "#181825", - layer2: "#11111b", - layer3: "#0a0a0f", - }, - }, - }, - { - name: "gruvbox", - colors: { - background: "transparent", - surface: "#282828", - primary: "#fabd2f", - secondary: "#83a598", - accent: "#fe8019", - text: "#ebdbb2", - textPrimary: "#ebdbb2", - textSecondary: "#83a598", - textTertiary: "#928374", - textSelectedPrimary: "#282828", - textSelectedSecondary: "#ebdbb2", - textSelectedTertiary: "#83a598", - muted: "#928374", - warning: "#fabd2f", - error: "#fb4934", - success: "#b8bb26", - layerBackgrounds: { - layer0: "transparent", - layer1: "#32302a", - layer2: "#1d2021", - layer3: "#0d0c0c", - }, - }, - }, - { - name: "tokyo", - colors: { - background: "transparent", - surface: "#1a1b26", - primary: "#7aa2f7", - secondary: "#bb9af7", - accent: "#e0af68", - text: "#c0caf5", - textPrimary: "#c0caf5", - textSecondary: "#bb9af7", - textTertiary: "#565f89", - textSelectedPrimary: "#1a1b26", - textSelectedSecondary: "#c0caf5", - textSelectedTertiary: "#bb9af7", - muted: "#565f89", - warning: "#e0af68", - error: "#f7768e", - success: "#9ece6a", - layerBackgrounds: { - layer0: "transparent", - layer1: "#16161e", - layer2: "#0f0f15", - layer3: "#08080b", - }, - }, - }, - { - name: "nord", - colors: { - background: "transparent", - surface: "#2e3440", - primary: "#88c0d0", - secondary: "#81a1c1", - accent: "#ebcb8b", - text: "#eceff4", - textPrimary: "#eceff4", - textSecondary: "#81a1c1", - textTertiary: "#4c566a", - textSelectedPrimary: "#2e3440", - textSelectedSecondary: "#eceff4", - textSelectedTertiary: "#81a1c1", - muted: "#4c566a", - warning: "#ebcb8b", - error: "#bf616a", - success: "#a3be8c", - layerBackgrounds: { - layer0: "transparent", - layer1: "#3b4252", - layer2: "#242933", - layer3: "#1a1c23", - }, - }, - }, - ], - defaultVariant: "catppuccin", - tokens: BASE_THEME_TOKENS, -} - -// Helper function to get theme by name -export function getThemeByName(name: ThemeName): ThemeVariant | undefined { - return THEMES_DESKTOP.variants.find((variant) => variant.name === name) -} - -// Helper function to get default theme -export function getDefaultTheme(): ThemeVariant { - return THEMES_DESKTOP.variants.find( - (variant) => variant.name === THEMES_DESKTOP.defaultVariant - )! -} - -export type ThemeJsonFile = ThemeDefinition - -export function isColorReference(value: ColorValue): value is string { - return typeof value === "string" && !value.startsWith("#") -} diff --git a/src/types/settings.ts b/src/types/settings.ts index 60e6bce..afa4b3c 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -85,6 +85,8 @@ export type AppSettings = { export type UserPreferences = { showExplicit: boolean; autoDownload: boolean; + /** Jump to the Player view automatically when playback starts (default: true) */ + autoJumpToPlayer: boolean; }; export type AppState = { diff --git a/src/types/sync-json.ts b/src/types/sync-json.ts deleted file mode 100644 index c409303..0000000 --- a/src/types/sync-json.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type SyncData = { - version: string - lastSyncedAt: string - feeds: { - id: string - title: string - url: string - isPrivate: boolean - }[] - sources: { - id: string - name: string - url: string - }[] - settings: { - theme: string - playbackSpeed: number - downloadPath: string - } - preferences: { - showExplicit: boolean - autoDownload: boolean - } -} diff --git a/src/types/sync-xml.ts b/src/types/sync-xml.ts deleted file mode 100644 index 403966a..0000000 --- a/src/types/sync-xml.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type SyncDataXML = { - version: string - lastSyncedAt: string - feeds: { - feed: { - id: string - title: string - url: string - isPrivate: boolean - }[] - } - sources: { - source: { - id: string - name: string - url: string - }[] - } - settings: { - theme: string - playbackSpeed: number - downloadPath: string - } - preferences: { - showExplicit: boolean - autoDownload: boolean - } -} diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index f05568e..70f61a9 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -38,6 +38,7 @@ const defaultSettings: AppSettings = { const defaultPreferences: UserPreferences = { showExplicit: false, autoDownload: false, + autoJumpToPlayer: true, }; const defaultState: AppState = { diff --git a/src/utils/audio-stream-reader.ts b/src/utils/audio-stream-reader.ts index c14e6f2..bf755cb 100644 --- a/src/utils/audio-stream-reader.ts +++ b/src/utils/audio-stream-reader.ts @@ -135,10 +135,8 @@ export class AudioStreamReader { this.writePos = 0; this.totalSamplesWritten = 0; - // Capture generation for this run const myGeneration = this.generation; - // Start async reading loop this.readLoop(myGeneration); // Detect process exit diff --git a/src/utils/audio-waveform.ts b/src/utils/audio-waveform.ts deleted file mode 100644 index 9a0e349..0000000 --- a/src/utils/audio-waveform.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Audio waveform analysis for PodTUI - * - * Extracts amplitude data from audio files using ffmpeg (when available) - * Results are cache in-memory keyed by audio URL. - */ - -/** Number of amplitude data points to generate */ -const DEFAULT_RESOLUTION = 128; - -/** In-memory cache: audioUrl -> amplitude data */ -const waveformCache = new Map(); - -/** - * Try to extract real waveform data from an audio URL using ffmpeg. - * Returns null if ffmpeg is not available or the extraction fails. - */ -async function extractWithFfmpeg( - audioUrl: string, - resolution: number, -): Promise { - try { - if (!Bun.which("ffmpeg")) return null; - - // Use ffmpeg to output raw PCM samples, then downsample to `resolution` points. - // -t 300: read at most 5 minutes (enough data to fill the waveform) - const proc = Bun.spawn( - [ - "ffmpeg", - "-i", - audioUrl, - "-t", - "300", - "-ac", - "1", // mono - "-ar", - "8000", // low sample rate to keep data small - "-f", - "s16le", // raw signed 16-bit PCM - "-v", - "quiet", - "-", - ], - { stdout: "pipe", stderr: "ignore" }, - ); - - const output = await new Response(proc.stdout).arrayBuffer(); - await proc.exited; - - if (output.byteLength === 0) return null; - - const samples = new Int16Array(output); - if (samples.length === 0) return null; - - // Downsample to `resolution` buckets by taking the max absolute amplitude - // in each bucket. - const bucketSize = Math.max(1, Math.floor(samples.length / resolution)); - const data: number[] = []; - - for (let i = 0; i < resolution; i++) { - const start = i * bucketSize; - const end = Math.min(start + bucketSize, samples.length); - let maxAbs = 0; - for (let j = start; j < end; j++) { - const abs = Math.abs(samples[j]); - if (abs > maxAbs) maxAbs = abs; - } - // Normalise to 0-1 - data.push(Number((maxAbs / 32768).toFixed(3))); - } - - return data; - } catch { - return null; - } -} - -/** - * Get waveform data for an audio URL. - * - * Returns cached data if available, otherwise attempts ffmpeg extraction - */ -export async function getWaveformData( - audioUrl: string, - resolution: number = DEFAULT_RESOLUTION, -): Promise { - const cacheKey = `${audioUrl}:${resolution}`; - const cached = waveformCache.get(cacheKey); - if (cached) return cached; - - const real = await extractWithFfmpeg(audioUrl, resolution); - if (real) { - waveformCache.set(cacheKey, real); - return real; - } else { - console.error("generation failure"); - return []; - } -} - -export function clearWaveformCache(): void { - waveformCache.clear(); -} diff --git a/src/utils/cache.ts b/src/utils/cache.ts deleted file mode 100644 index 4313126..0000000 --- a/src/utils/cache.ts +++ /dev/null @@ -1,57 +0,0 @@ -type CacheEntry = { - value: T - timestamp: number -} - -const CACHE_KEY = "podtui_cache" -const DEFAULT_TTL = 1000 * 60 * 60 - -const loadCache = (): Record> => { - if (typeof localStorage === "undefined") return {} - try { - const raw = localStorage.getItem(CACHE_KEY) - return raw ? (JSON.parse(raw) as Record>) : {} - } catch { - return {} - } -} - -const saveCache = (cache: Record>) => { - if (typeof localStorage === "undefined") return - try { - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)) - } catch { - // ignore - } -} - -const cache = loadCache() - -export const cacheValue = (key: string, value: T) => { - cache[key] = { value, timestamp: Date.now() } - saveCache(cache) -} - -export const getCachedValue = (key: string, ttl = DEFAULT_TTL): T | null => { - const entry = cache[key] as CacheEntry | undefined - if (!entry) return null - if (Date.now() - entry.timestamp > ttl) { - delete cache[key] - saveCache(cache) - return null - } - return entry.value -} - -export const invalidateCache = (prefix?: string) => { - if (!prefix) { - Object.keys(cache).forEach((key) => delete cache[key]) - saveCache(cache) - return - } - - Object.keys(cache) - .filter((key) => key.startsWith(prefix)) - .forEach((key) => delete cache[key]) - saveCache(cache) -} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index 346df68..1dc596e 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -106,7 +106,7 @@ export namespace Clipboard { /** * Read text from the clipboard. */ - export async function readText(): Promise { + async function readText(): Promise { const os = platform() if (os === "darwin") { diff --git a/src/utils/config-dir.ts b/src/utils/config-dir.ts index 60ecab2..e22d6c5 100644 --- a/src/utils/config-dir.ts +++ b/src/utils/config-dir.ts @@ -13,7 +13,7 @@ import path from "path" const APP_DIR_NAME = "podtui" /** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */ -export function getXdgConfigHome(): string { +function getXdgConfigHome(): string { const xdg = process.env.XDG_CONFIG_HOME if (xdg) return xdg @@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise { } /** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */ -export function getXdgDataHome(): string { +function getXdgDataHome(): string { const xdg = process.env.XDG_DATA_HOME if (xdg) return xdg @@ -55,12 +55,12 @@ export function getXdgDataHome(): string { } /** Get the application-specific data directory path */ -export function getDataDir(): string { +function getDataDir(): string { return path.join(getXdgDataHome(), APP_DIR_NAME) } /** Get the downloads directory path */ -export function getDownloadsDir(): string { +function getDownloadsDir(): string { return path.join(getDataDir(), "downloads") } diff --git a/src/utils/config-validation.ts b/src/utils/config-validation.ts deleted file mode 100644 index df018e9..0000000 --- a/src/utils/config-validation.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Validates JSON structure of config files, handles corrupted files - * gracefully (falling back to defaults), and provides a single - */ - -import { getConfigFilePath } from "./config-dir"; -// --- Validation helpers --- - -/** Check that a value is a non-null object */ -function isObject(v: unknown): v is Record { - return v !== null && typeof v === "object" && !Array.isArray(v); -} - -/** Validate AppState JSON structure */ -export function validateAppState(data: unknown): { - valid: boolean; - errors: string[]; -} { - const errors: string[] = []; - if (!isObject(data)) { - return { valid: false, errors: ["app-state.json is not an object"] }; - } - - // settings - if (data.settings !== undefined) { - if (!isObject(data.settings)) { - errors.push("settings must be an object"); - } else { - const s = data.settings as Record; - if (s.theme !== undefined && typeof s.theme !== "string") - errors.push("settings.theme must be a string"); - if (s.fontSize !== undefined && typeof s.fontSize !== "number") - errors.push("settings.fontSize must be a number"); - if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") - errors.push("settings.playbackSpeed must be a number"); - if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") - errors.push("settings.downloadPath must be a string"); - } - } - - // preferences - if (data.preferences !== undefined) { - if (!isObject(data.preferences)) { - errors.push("preferences must be an object"); - } else { - const p = data.preferences as Record; - if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean") - errors.push("preferences.showExplicit must be a boolean"); - if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") - errors.push("preferences.autoDownload must be a boolean"); - } - } - - // customTheme - if (data.customTheme !== undefined && !isObject(data.customTheme)) { - errors.push("customTheme must be an object"); - } - - return { valid: errors.length === 0, errors }; -} - -/** Validate feeds JSON structure */ -export function validateFeeds(data: unknown): { - valid: boolean; - errors: string[]; -} { - const errors: string[] = []; - if (!Array.isArray(data)) { - return { valid: false, errors: ["feeds.json is not an array"] }; - } - - for (let i = 0; i < data.length; i++) { - const feed = data[i]; - if (!isObject(feed)) { - errors.push(`feeds[${i}] is not an object`); - continue; - } - if (typeof feed.id !== "string") - errors.push(`feeds[${i}].id must be a string`); - if (!isObject(feed.podcast)) - errors.push(`feeds[${i}].podcast must be an object`); - if (!Array.isArray(feed.episodes)) - errors.push(`feeds[${i}].episodes must be an array`); - } - - return { valid: errors.length === 0, errors }; -} - -/** Validate progress JSON structure */ -export function validateProgress(data: unknown): { - valid: boolean; - errors: string[]; -} { - const errors: string[] = []; - if (!isObject(data)) { - return { valid: false, errors: ["progress.json is not an object"] }; - } - - for (const [key, value] of Object.entries(data)) { - if (!isObject(value)) { - errors.push(`progress["${key}"] is not an object`); - continue; - } - const p = value as Record; - if (typeof p.episodeId !== "string") - errors.push(`progress["${key}"].episodeId must be a string`); - if (typeof p.position !== "number") - errors.push(`progress["${key}"].position must be a number`); - if (typeof p.duration !== "number") - errors.push(`progress["${key}"].duration must be a number`); - } - - return { valid: errors.length === 0, errors }; -} - -// --- Safe config file reading --- - -/** - * Safely read and validate a config file. - * Returns the parsed data if valid, or null if the file is missing/corrupt. - */ -export async function safeReadConfigFile( - filename: string, - validator: (data: unknown) => { valid: boolean; errors: string[] }, -): Promise<{ data: T | null; errors: string[] }> { - try { - const filePath = getConfigFilePath(filename); - const file = Bun.file(filePath); - if (!(await file.exists())) { - return { data: null, errors: [] }; - } - - const text = await file.text(); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return { data: null, errors: [`${filename}: invalid JSON`] }; - } - - const result = validator(parsed); - if (!result.valid) { - return { data: null, errors: result.errors }; - } - - return { data: parsed as T, errors: [] }; - } catch (err) { - return { data: null, errors: [`${filename}: ${String(err)}`] }; - } -} diff --git a/src/utils/config.ts b/src/utils/config.ts index e679b7d..ba697c6 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -73,11 +73,6 @@ export function updateConfig(patch: Partial): void { }); } -/** Await all pending config writes (used by sync/export flows). */ -export async function flushConfig(): Promise { - await writeChain; -} - /** Guards so migration runs exactly once per process. */ let migrationDone = false; let migrationPromise: Promise | null = null; @@ -98,7 +93,7 @@ async function migrateOnce(): Promise { * Safe to call on every startup — no-op once config.json exists (except for * backup cleanup, which runs unconditionally since those files are now dead). */ -export async function migrateLegacyConfig(): Promise { +async function migrateLegacyConfig(): Promise { try { await ensureConfigDir(); const dir = getConfigDir(); diff --git a/src/utils/data-fetcher.ts b/src/utils/data-fetcher.ts deleted file mode 100644 index f4dd6bf..0000000 --- a/src/utils/data-fetcher.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { FeedVisibility } from "../types/feed" -import type { Feed } from "../types/feed" -import type { Episode } from "../types/episode" -import type { Podcast } from "../types/podcast" -import { cacheValue, getCachedValue } from "./cache" -import { fetchEpisodes } from "@/api/client" - -const feedKey = (feedUrl: string) => `feed:${feedUrl}` -const episodesKey = (feedUrl: string) => `episodes:${feedUrl}` -const searchKey = (query: string) => `search:${query.toLowerCase()}` - -export const fetchFeedWithCache = async (feedUrl: string): Promise => { - const cached = getCachedValue(feedKey(feedUrl)) - if (cached) return cached - try { - const episodes = await fetchEpisodes(feedUrl) - const feed: Feed = { - id: feedUrl, - podcast: { - id: feedUrl, - title: feedUrl, - description: "", - feedUrl, - lastUpdated: new Date(), - isSubscribed: true, - }, - episodes, - visibility: FeedVisibility.PUBLIC, - sourceId: "rss", - lastUpdated: new Date(), - isPinned: false, - } - cacheValue(feedKey(feedUrl), feed) - return feed - } catch { - return null - } -} - -export const fetchEpisodesWithCache = async (feedUrl: string): Promise => { - const cached = getCachedValue(episodesKey(feedUrl)) - if (cached) return cached - const episodes = await fetchEpisodes(feedUrl) - cacheValue(episodesKey(feedUrl), episodes) - return episodes -} - -export const searchWithCache = async ( - query: string, - fetcher: () => Promise -): Promise => { - const cached = getCachedValue(searchKey(query)) - if (cached) return cached - const results = await fetcher() - cacheValue(searchKey(query), results) - return results -} diff --git a/src/utils/dispatch.ts b/src/utils/dispatch.ts index 2c03c89..b8cc885 100644 --- a/src/utils/dispatch.ts +++ b/src/utils/dispatch.ts @@ -79,7 +79,7 @@ export const PAGE_ACTIONS: ReadonlySet = ]); /** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */ -export function tabByDigit(action: KeybindActionName): TABS | null { +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; diff --git a/src/utils/event-bus.ts b/src/utils/event-bus.ts index 9be949e..777b2d9 100644 --- a/src/utils/event-bus.ts +++ b/src/utils/event-bus.ts @@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance { } // Singleton event bus instance -export const EventBus = createEventBus(); +const EventBus = createEventBus(); import type { KeybindActionName } from "@/context/KeybindContext"; import type { TABS } from "@/utils/navigation"; @@ -105,6 +105,8 @@ export type AppEvents = { "player.play": { episodeId: string }; "player.pause": { episodeId: string }; "player.stop": {}; + // Emitted when a NEW episode begins playback (not on resume). + "player.started": { episodeId: string }; "toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; diff --git a/src/utils/jsonc.ts b/src/utils/jsonc.ts index 0a300aa..1d8ae56 100644 --- a/src/utils/jsonc.ts +++ b/src/utils/jsonc.ts @@ -8,7 +8,7 @@ /** * Remove JSONC comments from a string */ -export function stripComments(jsonString: string): string { +function stripComments(jsonString: string): string { const comments = [ { pattern: /\/\/.*$/gm, replacement: "" }, { pattern: /\/\*[\s\S]*?\*\//g, replacement: "" }, diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 54c3500..38d070b 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -77,7 +77,6 @@ export async function copyKeybindsIfNeeded(): Promise { try { const targetPath = getConfigFilePath(KEYBINDS_FILE); - // Check if file already exists const targetFile = Bun.file(targetPath); if (await targetFile.exists()) return; diff --git a/src/utils/search.ts b/src/utils/search.ts index ab2f32b..6bea1fd 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -1,6 +1,5 @@ import { searchSourceByType } from "./source-searcher"; import type { PodcastSource, SearchResult } from "../types/source"; -import type { Episode } from "../types/episode"; type SearchCacheEntry = { timestamp: number; @@ -114,61 +113,4 @@ export const searchPodcasts = async ( return sorted; }; -type ItunesEpisodeResult = { - trackId?: number; - trackName?: string; - description?: string; - shortDescription?: string; - releaseDate?: string; - trackTimeMillis?: number; - episodeUrl?: string; - previewUrl?: string; - trackViewUrl?: string; -}; -type ItunesEpisodeResponse = { - resultCount: number; - results: ItunesEpisodeResult[]; -}; - -export const searchEpisodes = async ( - query: string, - feedId: string, -): Promise => { - 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 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 || ""; - - 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/src/utils/source-searcher.ts b/src/utils/source-searcher.ts index 4d4fffe..90c7e04 100644 --- a/src/utils/source-searcher.ts +++ b/src/utils/source-searcher.ts @@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear }) } -export const searchRSSSource = async ( +const searchRSSSource = async ( query: string, source: PodcastSource ): Promise => { @@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | } } -export const searchAPISource = async ( +const searchAPISource = async ( query: string, source: PodcastSource ): Promise => { @@ -173,7 +173,7 @@ export const searchAPISource = async ( })) } -export const searchCustomSource = async ( +const searchCustomSource = async ( query: string, source: PodcastSource ): Promise => { diff --git a/src/utils/sync-validation.ts b/src/utils/sync-validation.ts deleted file mode 100644 index ae60e14..0000000 --- a/src/utils/sync-validation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SyncData } from "../types/sync-json" -import type { SyncDataXML } from "../types/sync-xml" -import { syncFormats } from "../constants/sync-formats" - -const isObject = (value: unknown): value is { [key: string]: unknown } => - typeof value === "object" && value !== null - -const hasVersion = (value: unknown): value is { version: string } => - isObject(value) && typeof value.version === "string" - -export function validateJSONSync(data: unknown): SyncData { - if (!hasVersion(data) || data.version !== syncFormats.json.version) { - throw { message: "Unsupported sync format" } - } - - return data as SyncData -} - -export function validateXMLSync(data: unknown): SyncDataXML { - if (!hasVersion(data) || data.version !== syncFormats.xml.version) { - throw { message: "Unsupported sync format" } - } - - return data as SyncDataXML -} diff --git a/src/utils/sync.ts b/src/utils/sync.ts deleted file mode 100644 index b635aee..0000000 --- a/src/utils/sync.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { SyncData } from "../types/sync-json" -import type { SyncDataXML } from "../types/sync-xml" -import { validateJSONSync, validateXMLSync } from "./sync-validation" -import { syncFormats } from "../constants/sync-formats" -import { FeedVisibility } from "../types/feed" - -export function exportToJSON(data: SyncData): string { - return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}` -} - -export function importFromJSON(json: string): SyncData { - const data = json - return validateJSONSync(data as unknown) -} - -export function exportToXML(data: SyncDataXML): string { - const feedItems = "" - const sourceItems = "" - - return `\n` + - `\n` + - ` ${data.lastSyncedAt}\n` + - ` \n` + - feedItems + - ` \n` + - ` \n` + - sourceItems + - ` \n` + - ` \n` + - ` ${data.settings.theme}\n` + - ` ${data.settings.playbackSpeed}\n` + - ` ${data.settings.downloadPath}\n` + - ` \n` + - ` \n` + - ` ${data.preferences.showExplicit}\n` + - ` ${data.preferences.autoDownload}\n` + - ` \n` + - `` -} - -export function importFromXML(xml: string): SyncDataXML { - const version = syncFormats.xml.version - const data = { - version, - lastSyncedAt: "", - feeds: { feed: [] }, - sources: { source: [] }, - settings: { - theme: "system", - playbackSpeed: 1, - downloadPath: "", - }, - preferences: { - showExplicit: false, - autoDownload: false, - }, - } as SyncDataXML - - return validateXMLSync(data) -} diff --git a/src/utils/system-theme.ts b/src/utils/system-theme.ts index 2fc4420..f0798a4 100644 --- a/src/utils/system-theme.ts +++ b/src/utils/system-theme.ts @@ -13,15 +13,6 @@ export function clearPaletteCache() { cached = null; } -export function detectSystemTheme(colors: TerminalColors) { - const bg = RGBA.fromHex( - colors.defaultBackground ?? colors.palette[0] ?? "#000000", - ); - const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b; - const mode = luminance > 0.5 ? "light" : "dark"; - return { mode, background: bg }; -} - export function generateSystemTheme( colors: TerminalColors, mode: "dark" | "light", diff --git a/src/utils/theme-css.ts b/src/utils/theme-css.ts deleted file mode 100644 index 72b2e16..0000000 --- a/src/utils/theme-css.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RGBA } from "@opentui/core" -import type { ColorValue } from "../types/theme-schema" - -const toCss = (value: ColorValue | RGBA) => { - if (value instanceof RGBA) { - const r = Math.round(value.r * 255) - const g = Math.round(value.g * 255) - const b = Math.round(value.b * 255) - return `rgba(${r}, ${g}, ${b}, ${value.a})` - } - if (typeof value === "number") return `var(--ansi-${value})` - if (typeof value === "string") return value - return value.dark -} - -export function applyThemeToCSS(theme: Record) { - const root = document.documentElement - for (const [key, value] of Object.entries(theme)) { - if (key === "layerBackgrounds" && typeof value === "object") { - const layers = value as Record - for (const [layer, color] of Object.entries(layers)) { - root.style.setProperty(`--color-${layer}`, toCss(color)) - } - } else { - root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA)) - } - } -} - -export function setThemeAttribute(themeName: string) { - document.documentElement.setAttribute("data-theme", themeName) -} - -export function resolveColorReference(value: ColorValue) { - return toCss(value) -} diff --git a/src/utils/theme-loader.ts b/src/utils/theme-loader.ts index a95c0d8..a7fd05e 100644 --- a/src/utils/theme-loader.ts +++ b/src/utils/theme-loader.ts @@ -1,42 +1,4 @@ -import path from "path" import type { ThemeJson } from "../types/theme-schema" -import { THEME_JSON } from "../constants/themes" - -export async function loadTheme(name: string) { - if (THEME_JSON[name]) return THEME_JSON[name] - const file = path.resolve(process.cwd(), "themes", `${name}.json`) - return loadThemeFromPath(file) -} - -export async function loadThemeFromPath(file: string) { - const json = (await Bun.file(file).json()) as ThemeJson - validateTheme(json, file) - return json -} - -export async function getAllThemes() { - return { ...THEME_JSON, ...(await getCustomThemes()) } -} - -export async function getCustomThemes() { - const dirs = [ - path.join(process.env.HOME ?? "", ".config/podtui/themes"), - path.resolve(process.cwd(), ".podtui/themes"), - path.resolve(process.cwd(), "themes"), - ] - - const result: Record = {} - for (const dir of dirs) { - const glob = new Bun.Glob("*.json") - for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) { - const name = path.basename(item, ".json") - const json = (await Bun.file(item).json()) as ThemeJson - validateTheme(json, item) - result[name] = json - } - } - return result -} export function validateTheme(theme: ThemeJson, source?: string) { if (!theme || typeof theme !== "object") { diff --git a/src/utils/theme-observer.ts b/src/utils/theme-observer.ts index cce06b2..02c76f3 100644 --- a/src/utils/theme-observer.ts +++ b/src/utils/theme-observer.ts @@ -7,40 +7,12 @@ * - Tracking theme change state */ -import { emit, on, off, type EventHandler } from "./event-bus" - -/** - * Subscribe to theme reload events. - * These are triggered by SIGUSR2 signals. - */ -export function onThemeReload(handler: EventHandler<{}>): () => void { - return on("theme.reload", handler) -} - -/** - * Subscribe to theme changed events. - * These are triggered when the theme selection changes. - */ -export function onThemeChanged( - handler: EventHandler<{ theme: string; mode: "dark" | "light" }> -): () => void { - return on("theme.changed", handler) -} - -/** - * Subscribe to theme mode changed events. - * These are triggered when switching between dark/light mode. - */ -export function onThemeModeChanged( - handler: EventHandler<{ mode: "dark" | "light" }> -): () => void { - return on("theme.mode.changed", handler) -} +import { emit } from "./event-bus" /** * Emit a theme reload event. */ -export function emitThemeReload(): void { +function emitThemeReload(): void { emit("theme.reload", {}) } @@ -79,26 +51,3 @@ export function setupThemeSignalHandler(onReload: () => void): () => void { process.off("SIGUSR2", handler) } } - -/** - * Create a debounced theme change handler to prevent rapid consecutive updates. - * - * @param handler - The handler to debounce - * @param delay - Delay in milliseconds (default: 100ms) - */ -export function createDebouncedThemeHandler( - handler: (event: T) => void, - delay: number = 100 -): (event: T) => void { - let timeout: NodeJS.Timeout | null = null - - return (event: T) => { - if (timeout) { - clearTimeout(timeout) - } - timeout = setTimeout(() => { - handler(event) - timeout = null - }, delay) - } -} diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 3277eb7..cf43658 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -3,67 +3,13 @@ * Handles dynamic theme switching by updating CSS custom properties */ -import { RGBA, type TerminalColors } from "@opentui/core"; -import type { ThemeColors } from "../types/settings"; -import type { ColorValue, ThemeJson } from "../types/theme-schema"; +import type { TerminalColors } from "@opentui/core"; +import type { ThemeJson } from "../types/theme-schema"; import { THEME_JSON } from "../constants/themes"; import { getCustomThemes } from "./custom-themes"; import { resolveTheme as resolveThemeJson } from "./theme-resolver"; import { generateSystemTheme } from "./system-theme"; -const toCss = (value: ColorValue | RGBA) => { - if (value instanceof RGBA) { - const r = Math.round(value.r * 255); - const g = Math.round(value.g * 255); - const b = Math.round(value.b * 255); - return `rgba(${r}, ${g}, ${b}, ${value.a})`; - } - if (typeof value === "number") return `var(--ansi-${value})`; - if (typeof value === "string") return value; - return value.dark; -}; - -export function applyTheme(theme: ThemeColors | Record) { - if (typeof document === "undefined") return; - const root = document.documentElement; - root.style.setProperty( - "--color-background", - toCss(theme.background as ColorValue), - ); - root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue)); - root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue)); - root.style.setProperty( - "--color-secondary", - toCss(theme.secondary as ColorValue), - ); - root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue)); - root.style.setProperty("--color-text", toCss(theme.text as ColorValue)); - root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue)); - root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue)); - root.style.setProperty("--color-error", toCss(theme.error as ColorValue)); - root.style.setProperty("--color-success", toCss(theme.success as ColorValue)); - - const layers = theme.layerBackgrounds as - | Record - | undefined; - if (layers) { - root.style.setProperty("--color-layer0", toCss(layers.layer0)); - root.style.setProperty("--color-layer1", toCss(layers.layer1)); - root.style.setProperty("--color-layer2", toCss(layers.layer2)); - root.style.setProperty("--color-layer3", toCss(layers.layer3)); - } -} - -/** - * Get theme mode from system preference - */ -export function getSystemThemeMode(): "dark" | "light" { - if (typeof window === "undefined") return "dark"; - - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - return prefersDark ? "dark" : "light"; -} - /** * Apply CSS variable data-theme attribute */ @@ -77,15 +23,6 @@ export async function loadThemes() { return await getCustomThemes(); } -export async function loadTheme(name: string) { - const themes = await loadThemes(); - return themes[name]; -} - -export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { - return resolveThemeJson(theme, mode); -} - export function resolveTerminalTheme( themes: Record, name: string, diff --git a/tests/auto-jump.test.ts b/tests/auto-jump.test.ts new file mode 100644 index 0000000..f92c3d0 --- /dev/null +++ b/tests/auto-jump.test.ts @@ -0,0 +1,43 @@ +/** + * auto-jump.test.ts — "auto jump to player on podcast start" feature. + * + * The Shell subscribes to the `player.started` event (emitted by useAudio when + * a NEW episode begins, not on resume) and, when `autoJumpToPlayer` is enabled, + * performs `nav.setActiveTab(TABS.PLAYER)` + `nav.enterTabContent()` to land in + * the Player content pane. These tests pin that jump contract against the real + * nav store (the exact two calls the Shell handler makes) and pin the default + * value of the new preference. + */ +import { test, expect } from "bun:test"; +import { createRoot } from "solid-js"; +import { + createNavigation, + type NavigationState, +} from "../src/context/navigation-store"; +import { TABS } from "../src/utils/navigation"; +import { loadAppStateFromFile } from "../src/utils/app-persistence"; + +function withNav(fn: (nav: NavigationState) => void) { + createRoot((dispose) => { + const nav = createNavigation(); + fn(nav); + dispose(); + }); +} + +test("auto-jump: PLAYER + enterTabContent lands in the Player content pane", () => { + withNav((nav) => { + // Exact call sequence the Shell handler runs on `player.started`. + nav.setActiveTab(TABS.PLAYER); + nav.enterTabContent(); + expect(nav.activeTab()).toBe(TABS.PLAYER); + // Content entered: tab list is no longer the CURRENT pane. + expect(nav.atRootTab()).toBe(false); + }); +}); + +test("auto-jump: preference defaults to true on a config without the field", async () => { + // Existing configs predate the field; load must merge in the default true. + const state = await loadAppStateFromFile(); + expect(state.preferences.autoJumpToPlayer).toBe(true); +});