diff --git a/bun.lockb b/bun.lockb index 9f18359..3cf431d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 1a21a75..5659716 100644 --- a/package.json +++ b/package.json @@ -18,21 +18,15 @@ }, "devDependencies": { "@types/bun": "latest", - "@types/uuid": "^11.0.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "eslint": "^9.39.2", "typescript": "^5.9.3" }, "dependencies": { - "@babel/core": "^7.28.5", - "@babel/preset-typescript": "^7.28.5", "@opentui/core": "^0.1.77", "@opentui/solid": "^0.1.77", - "babel-preset-solid": "1.9.9", "date-fns": "^4.1.0", - "solid-js": "^1.9.9", - "uuid": "^13.0.0", - "zustand": "^5.0.11" + "solid-js": "^1.9.9" } } diff --git a/scripts/tui-harness.tsx b/scripts/tui-harness.tsx index ab121a3..1594757 100644 --- a/scripts/tui-harness.tsx +++ b/scripts/tui-harness.tsx @@ -205,7 +205,7 @@ function parseFlags(rest: string[]): { } else if (a === "--from") { flags.from = rest[++i]; } else { - flags[a.slice(2)] = rest[++i] ?? true; + throw new Error(`unknown flag: ${a}`); } } else { positional.push(a); @@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] { return mods; } -function buildAction(cmd: string, positional: string[]): Action | null { +// Per-command builders. Leading positional tokens that name a modifier +// (ctrl/shift/...) are stripped as mods; the rest is the command's data. +const modsOrUndefined = (positional: string[]): Mod[] | undefined => { 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); +});