diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6d9bd83 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,200 @@ +# Contributing to PodTui + +This file is written **for humans**. If you're an AI agent or LLM working in +this repo, read [AGENTS.md](AGENTS.md) instead — it has the machine-oriented +build/test/lint contract and code-style rules. Both describe the same project; +CONTRIBUTING.md focuses on *understanding* and *navigating* the codebase. + +PodTui is a keyboard-first, yazi-style terminal podcast client. TypeScript + +[OpenTUI](https://github.com/opentui/opentui) on top, [Bun](https://bun.sh) +as the runtime and toolchain. + +--- + +## Quick start + +```bash +brew install bun # or: curl -fsSL https://bun.sh/install | bash +git clone git@github.com:mikefreno/podtui.git +cd podtui + +bun install # install JS dependencies +make native # build libcavacore.dylib from the vendored C source +bun run dev # launch with hot reload (alias: make dev) +``` + +The app is a TUI — it expects a real terminal (kitty, iTerm2, WezTerm, tmux, +…). It will not render in a plain captured `bash` session. + +## What each command does + +| Command | Purpose | +|--------------------|--------------------------------------------------------------------------| +| `bun install` | Install JS dependencies | +| `make native` | Compile `cava/cavacore.c` → `src/native/libcavacore.` | +| `bun run dev` | Run with hot reload | +| `bun run start` | Run once (no watch) | +| `bun test` | Run the test suite (see [Testing](#testing)) | +| `make lint` | Type-check with `bun tsc --noEmit` | +| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) | +| `make dist` | Compile the standalone binary + make the current platform's tarball | +| `make clean` | Remove `dist/` | + +> Note: `package.json` also has a `lint` script that points at a +> `lint.ts` file that doesn't exist. Use `make lint` (real type-checking). + +## Repository layout + +``` +src/ + api/ Network + XML/RSS — client.ts, rss-parser.ts + components/ Reusable UI pieces: Shell, Navigation, YaziPaneRow, TabPanel… + config/ App config: keybinds.jsonc, shortcuts, auth + constants/ Static tables (sync formats, themes) + context/ Solid contexts: KeybindContext, NavigationContext, ThemeContext + hooks/ useAudio, useMultimediaKeys, useCachedData + native/ FFI glue + the built libcavacore.{dylib,so} + pages/ App screens: Feed, MyShows, Discover, Search, Player, Settings + stores/ Zustand stores — app, feed, audio-nav, search, auth, progress… + styles/ theme.css + themes/ catppuccin, gruvbox, nord, tokyo schemes + schema.json + types/ All shared interfaces (podcast, episode, feed, settings…) + ui/ Modal-adjacent UI: command.tsx, dialog.tsx, toast.tsx + utils/ Parser/persistence/audio helpers (audio-player, config-dir…) +scripts/ + build-cavacore.sh C → shared lib; finds libfftw3.a on macOS & Debian + tui-harness.tsx Headless harness for scripted interaction (see below) +cava/ Vendored cavacore C source (MIT, from karlstav/cava) +tests/ bun test suite + cavacore smoke test +dist/ Build output (JS bundle + libs + tarballs) +``` + +## Native libraries: how the FFI layer works + +PodTui loads **two** native libraries at runtime: + +1. **libopentui** — the OpenTUI renderer (shipped inside the + `@opentui/core--` npm packages, copied to `dist/` by + `build.ts`). +2. **libcavacore** — the audio spectrum renderer, built from C. The source is + vendored under `cava/` (it must stay committed — every CI runner builds it). + `libfftw3` is needed to build it: + - macOS: `brew install fftw` + - Debian/Ubuntu: `apt-get install libfftw3-dev` + (CI installs it for you; locally run `make native`.) + +**Critical sibling rule**: both libraries are loaded *relative to the binary*, +so `podtui`, `libopentui.*` and `libcavacore.*` must sit in the **same +directory**. Never move a single binary out of the tarball. The Homebrew +formula keeps all three in `libexec/` and exposes only a `podtui` symlink. + +Cavacore smoke test: `bun tests/cavacore-smoke.ts` +(FFI-calls `cava_init` / `cava_execute` / `cava_destroy` and prints results). + +## Gotchas (read before touching anything) + +1. **Never add a top-level `preload` to `bunfig.toml`.** + A compiled PodTui binary's embedded runtime reads the *launching process's* + CWD `bunfig.toml`, and a `preload` entry points at a module the standalone + can't resolve (`@opentui/solid/preload`) → the binary dies at startup with + `preload not found`. This is why `bunfig.toml` has **no** top-level + `preload`; dev-mode preloading happens via explicit `--preload` flags in + `package.json`. The `[test]` section *does* keep a preload — that only + affects `bun test`. + +2. **Smoke-test the compiled binary from a bunfig-free dir.** + Because of (1), `./dist/podtui --version` run from the repo root launched + inside CI would fail. CI always unpacks the tarball into a `mktemp` dir + before booting. Do the same when testing a release build locally. + +3. **Homebrew's dylib-repair warning is benign.** + `brew install` may print “load commands do not fit in the header … needs + `-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so + the warning is cosmetic; installs complete and the app boots. + +4. **`make lint` is the truth, not the `package.json` scripts.** + The repo's ESLint wiring is stale; `make lint` runs the real + type-check and is what CI treats as the clean bar. + +## Testing + +```bash +bun test # full suite (54 tests across 6 files today) +``` + +The suite covers the keyboard/nav model, keybind dispatch, and the yazi pane +logic; plus `tests/cavacore-smoke.ts` asserting the native lib exports. + +For scripted end-to-end interaction there's a **headless harness**, +`scripts/tui-harness.tsx`: each invocation snapshot-rebuilds the app state +into a sandboxed `.harness/` config dir, replays the saved action log +(`.harness/actions.json`), executes one more key/action passed on the CLI, and +prints the resulting frame + a style summary — all without a real terminal. +Audio is a no-op during those snapshots. The last frame lands in +`.harness/last-frame.{json,txt}` for inspection. + +## Releasing + +Releases are built and published from **tags**; CI does the heavy lifting. + +### Steps + +1. Bump `VERSION` in `src/index.tsx` (e.g. `0.1.0` → `0.2.0`). Commit and push. +2. Tag and push: + + ```bash + git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0 + ``` + +3. CI (`.github/workflows/release.yml`) runs four builds in parallel, + each producing `podtui--.tar.gz`: + + | Runner | Platform/Arch | + |---------------------|---------------| + | `ubuntu-latest` | linux-x64 | + | `ubuntu-24.04-arm` | linux-arm64 | + | `macos-15-intel` | darwin-x64 | + | `macos-14` | darwin-arm64 | + + Each runner: installs deps → installs fftw → `scripts/build-cavacore.sh` + → `make dist` → smoke-boots the binary from a temp dir → uploads the + tarball. (`macos-15-intel` matters: GitHub's `macos-latest` is arm64 now.) + +4. A release is auto-created with all 4 tarballs attached. `brew` never + sees the new version: the **tap self-updates**: the + `mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that + polls GitHub releases, and when a new tag appears, rewrites + `Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no + secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local + test: `brew install mikefreno/podtui/podtui`. + +### Manual fallback + +If you ever need to sync the tap by hand (or before the hourly job runs): + +```bash +cd +./scripts/sync-formula.sh 0.2.0 +git commit -am 'podtui 0.2.0' && git push +``` + +### Local release build + +```bash +make dist # builds the binary + tarball for THIS machine only +``` + +Bun cannot cross-compile — the other platforms come from CI. + +--- + +## Open items / things to sort out + +- **LICENSE**: `README.md` says "TBD — choose and document a license before + the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the + README footer. +- **Native libs in `dist/` still need committing?** No — they're built from + sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only + `src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64 + ships from it directly until a full rebuild replaces it. On other hosts the + `make native` build is required — see `scripts/build-cavacore.sh`. diff --git a/notes.md b/notes.md deleted file mode 100644 index 4963cc4..0000000 --- a/notes.md +++ /dev/null @@ -1,7 +0,0 @@ -- [x] Audio play can survive quit out -- [x] Discover tab does not move highlight on jk, only moves a star, My Feeds tab -moves nothing, other tabs(and main tab panel) are the correct pattern -- [x] Weird focus colors happen at times, the search panel does not get the correct pane -border color when focused for instance -- [x] Feed tab needs to fully drop the depth 1 panel - its effectively a duplication -of My Shows - Just immediately go into the full list diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index b226e60..a755d15 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -25,6 +25,7 @@ import { onCleanup, } from "solid-js"; import { useSearchStore } from "@/stores/search"; +import { useFeedStore } from "@/stores/feed"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { @@ -44,6 +45,7 @@ export const SearchPaneCount = 1; function SearchPage() { const searchStore = useSearchStore(); + const feedStore = useFeedStore(); const [inputValue, setInputValue] = createSignal(""); const { theme } = useTheme(); const muted = () => theme.muted || theme.text; @@ -127,6 +129,8 @@ function SearchPage() { }; const handleSubscribe = (result: SearchResult) => { + // Actually add the feed to the feed store, then mark the result subscribed + feedStore.addFeed(result.podcast, result.sourceId).catch(() => {}); searchStore.markSubscribed(result.podcast.id); }; diff --git a/src/stores/discover.ts b/src/stores/discover.ts index 441a9fa..65e600e 100644 --- a/src/stores/discover.ts +++ b/src/stores/discover.ts @@ -3,213 +3,233 @@ * Manages trending/popular podcasts and category filtering */ -import { createSignal } from "solid-js" -import type { Podcast } from "../types/podcast" +import { createSignal } from "solid-js"; +import type { Podcast } from "../types/podcast"; +import { useFeedStore } from "./feed"; export interface DiscoverCategory { - id: string - name: string - icon: string + id: string; + name: string; + icon: string; } export const DISCOVER_CATEGORIES: DiscoverCategory[] = [ - { id: "all", name: "All", icon: "*" }, - { id: "technology", name: "Technology", icon: ">" }, - { id: "science", name: "Science", icon: "~" }, - { id: "comedy", name: "Comedy", icon: ")" }, - { id: "news", name: "News", icon: "!" }, - { id: "business", name: "Business", icon: "$" }, - { id: "health", name: "Health", icon: "+" }, - { id: "education", name: "Education", icon: "?" }, - { id: "sports", name: "Sports", icon: "#" }, - { id: "true-crime", name: "True Crime", icon: "%" }, - { id: "arts", name: "Arts", icon: "@" }, -] + { id: "all", name: "All", icon: "*" }, + { id: "technology", name: "Technology", icon: ">" }, + { id: "science", name: "Science", icon: "~" }, + { id: "comedy", name: "Comedy", icon: ")" }, + { id: "news", name: "News", icon: "!" }, + { id: "business", name: "Business", icon: "$" }, + { id: "health", name: "Health", icon: "+" }, + { id: "education", name: "Education", icon: "?" }, + { id: "sports", name: "Sports", icon: "#" }, + { id: "true-crime", name: "True Crime", icon: "%" }, + { id: "arts", name: "Arts", icon: "@" }, +]; /** Mock trending podcasts */ const TRENDING_PODCASTS: Podcast[] = [ - { - id: "trend-1", - title: "AI Today", - description: "The latest developments in artificial intelligence, machine learning, and their impact on society.", - feedUrl: "https://example.com/aitoday.rss", - author: "Tech Futures", - categories: ["Technology", "Science"], - coverUrl: undefined, - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-2", - title: "The History Hour", - description: "Fascinating stories from history that shaped our world today.", - feedUrl: "https://example.com/historyhour.rss", - author: "History Channel", - categories: ["Education", "History"], - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-3", - title: "Comedy Gold", - description: "Weekly stand-up comedy, sketches, and hilarious conversations.", - feedUrl: "https://example.com/comedygold.rss", - author: "Laugh Factory", - categories: ["Comedy", "Entertainment"], - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-4", - title: "Market Watch", - description: "Daily financial news, stock analysis, and investing tips.", - feedUrl: "https://example.com/marketwatch.rss", - author: "Finance Daily", - categories: ["Business", "News"], - lastUpdated: new Date(), - isSubscribed: true, - }, - { - id: "trend-5", - title: "Science Weekly", - description: "Breaking science news and in-depth analysis of the latest research.", - feedUrl: "https://example.com/scienceweekly.rss", - author: "Science Network", - categories: ["Science", "Education"], - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-6", - title: "True Crime Files", - description: "Investigative journalism into real criminal cases and unsolved mysteries.", - feedUrl: "https://example.com/truecrime.rss", - author: "Crime Network", - categories: ["True Crime", "Documentary"], - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-7", - title: "Wellness Journey", - description: "Tips for mental and physical health, meditation, and mindful living.", - feedUrl: "https://example.com/wellness.rss", - author: "Health Media", - categories: ["Health", "Self-Help"], - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-8", - title: "Sports Talk Live", - description: "Live commentary, analysis, and interviews from the world of sports.", - feedUrl: "https://example.com/sportstalk.rss", - author: "Sports Network", - categories: ["Sports", "News"], - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-9", - title: "Creative Minds", - description: "Interviews with artists, designers, and creative professionals.", - feedUrl: "https://example.com/creativeminds.rss", - author: "Arts Weekly", - categories: ["Arts", "Culture"], - lastUpdated: new Date(), - isSubscribed: false, - }, - { - id: "trend-10", - title: "Dev Talk", - description: "Software development, programming tutorials, and tech career advice.", - feedUrl: "https://example.com/devtalk.rss", - author: "Code Academy", - categories: ["Technology", "Education"], - lastUpdated: new Date(), - isSubscribed: true, - }, -] + { + id: "trend-1", + title: "AI Today", + description: + "The latest developments in artificial intelligence, machine learning, and their impact on society.", + feedUrl: "https://example.com/aitoday.rss", + author: "Tech Futures", + categories: ["Technology", "Science"], + coverUrl: undefined, + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-2", + title: "The History Hour", + description: + "Fascinating stories from history that shaped our world today.", + feedUrl: "https://example.com/historyhour.rss", + author: "History Channel", + categories: ["Education", "History"], + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-3", + title: "Comedy Gold", + description: + "Weekly stand-up comedy, sketches, and hilarious conversations.", + feedUrl: "https://example.com/comedygold.rss", + author: "Laugh Factory", + categories: ["Comedy", "Entertainment"], + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-4", + title: "Market Watch", + description: "Daily financial news, stock analysis, and investing tips.", + feedUrl: "https://example.com/marketwatch.rss", + author: "Finance Daily", + categories: ["Business", "News"], + lastUpdated: new Date(), + isSubscribed: true, + }, + { + id: "trend-5", + title: "Science Weekly", + description: + "Breaking science news and in-depth analysis of the latest research.", + feedUrl: "https://example.com/scienceweekly.rss", + author: "Science Network", + categories: ["Science", "Education"], + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-6", + title: "True Crime Files", + description: + "Investigative journalism into real criminal cases and unsolved mysteries.", + feedUrl: "https://example.com/truecrime.rss", + author: "Crime Network", + categories: ["True Crime", "Documentary"], + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-7", + title: "Wellness Journey", + description: + "Tips for mental and physical health, meditation, and mindful living.", + feedUrl: "https://example.com/wellness.rss", + author: "Health Media", + categories: ["Health", "Self-Help"], + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-8", + title: "Sports Talk Live", + description: + "Live commentary, analysis, and interviews from the world of sports.", + feedUrl: "https://example.com/sportstalk.rss", + author: "Sports Network", + categories: ["Sports", "News"], + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-9", + title: "Creative Minds", + description: + "Interviews with artists, designers, and creative professionals.", + feedUrl: "https://example.com/creativeminds.rss", + author: "Arts Weekly", + categories: ["Arts", "Culture"], + lastUpdated: new Date(), + isSubscribed: false, + }, + { + id: "trend-10", + title: "Dev Talk", + description: + "Software development, programming tutorials, and tech career advice.", + feedUrl: "https://example.com/devtalk.rss", + author: "Code Academy", + categories: ["Technology", "Education"], + lastUpdated: new Date(), + isSubscribed: true, + }, +]; /** Create discover store */ export function createDiscoverStore() { - const [selectedCategory, setSelectedCategory] = createSignal("all") - const [isLoading, setIsLoading] = createSignal(false) - const [podcasts, setPodcasts] = createSignal(TRENDING_PODCASTS) + const [selectedCategory, setSelectedCategory] = createSignal("all"); + const [isLoading, setIsLoading] = createSignal(false); + const [podcasts, setPodcasts] = createSignal(TRENDING_PODCASTS); - /** Get filtered podcasts by category */ - const filteredPodcasts = () => { - const category = selectedCategory() - if (category === "all") { - return podcasts() - } + /** Get filtered podcasts by category */ + const filteredPodcasts = () => { + const category = selectedCategory(); + if (category === "all") { + return podcasts(); + } - return podcasts().filter((p) => { - const cats = p.categories?.map((c) => c.toLowerCase()) ?? [] - return cats.some((c) => c.includes(category.toLowerCase().replace("-", " "))) - }) - } + return podcasts().filter((p) => { + const cats = p.categories?.map((c) => c.toLowerCase()) ?? []; + return cats.some((c) => + c.includes(category.toLowerCase().replace("-", " ")), + ); + }); + }; - /** Subscribe to a podcast */ - const subscribe = (podcastId: string) => { - setPodcasts((prev) => - prev.map((p) => - p.id === podcastId ? { ...p, isSubscribed: true } : p - ) - ) - } + /** Subscribe to a podcast */ + const subscribe = (podcastId: string) => { + const podcast = podcasts().find((p) => p.id === podcastId); + if (podcast) { + // Actually add the feed to the feed store + const feedStore = useFeedStore(); + feedStore.addFeed(podcast, "discover").catch(() => {}); + } + setPodcasts((prev) => + prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: true } : p)), + ); + }; - /** Unsubscribe from a podcast */ - const unsubscribe = (podcastId: string) => { - setPodcasts((prev) => - prev.map((p) => - p.id === podcastId ? { ...p, isSubscribed: false } : p - ) - ) - } + /** Unsubscribe from a podcast */ + 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); + } + setPodcasts((prev) => + prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)), + ); + }; - /** Toggle subscription */ - const toggleSubscription = (podcastId: string) => { - const podcast = podcasts().find((p) => p.id === podcastId) - if (podcast?.isSubscribed) { - unsubscribe(podcastId) - } else { - subscribe(podcastId) - } - } + /** Toggle subscription */ + const toggleSubscription = (podcastId: string) => { + const podcast = podcasts().find((p) => p.id === podcastId); + if (podcast?.isSubscribed) { + unsubscribe(podcastId); + } else { + subscribe(podcastId); + } + }; - /** Refresh trending podcasts (mock) */ - const refresh = async () => { - setIsLoading(true) - // Simulate network delay - await new Promise((r) => setTimeout(r, 500)) - // In real app, would fetch from API - setIsLoading(false) - } + /** Refresh trending podcasts (mock) */ + const refresh = async () => { + setIsLoading(true); + // Simulate network delay + await new Promise((r) => setTimeout(r, 500)); + // In real app, would fetch from API + setIsLoading(false); + }; - return { - // State - selectedCategory, - isLoading, - podcasts, - filteredPodcasts, - categories: DISCOVER_CATEGORIES, + return { + // State + selectedCategory, + isLoading, + podcasts, + filteredPodcasts, + categories: DISCOVER_CATEGORIES, - // Actions - setSelectedCategory, - subscribe, - unsubscribe, - toggleSubscription, - refresh, - } + // Actions + setSelectedCategory, + subscribe, + unsubscribe, + toggleSubscription, + refresh, + }; } /** Singleton discover store */ -let discoverStoreInstance: ReturnType | null = null +let discoverStoreInstance: ReturnType | null = null; export function useDiscoverStore() { - if (!discoverStoreInstance) { - discoverStoreInstance = createDiscoverStore() - } - return discoverStoreInstance + if (!discoverStoreInstance) { + discoverStoreInstance = createDiscoverStore(); + } + return discoverStoreInstance; } diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 0b969fe..82d19fd 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -7,15 +7,15 @@ import { createSignal } from "solid-js"; import { FeedVisibility } from "../types/feed"; import type { Feed, FeedFilter, FeedSortField } from "../types/feed"; import type { Podcast } from "../types/podcast"; -import type { Episode, EpisodeStatus } from "../types/episode"; -import type { PodcastSource, SourceType } from "../types/source"; +import type { Episode } from "../types/episode"; +import type { PodcastSource } from "../types/source"; import { DEFAULT_SOURCES } from "../types/source"; import { parseRSSFeed } from "../api/rss-parser"; import { - loadFeedsFromFile, - saveFeedsToFile, - loadSourcesFromFile, - saveSourcesToFile, + loadFeedsFromFile, + saveFeedsToFile, + loadSourcesFromFile, + saveSourcesToFile, } from "../utils/feeds-persistence"; import { useDownloadStore } from "./download"; import { DownloadStatus } from "../types/episode"; @@ -35,461 +35,491 @@ const episodeLoadCount = new Map(); /** Save feeds to file (async, fire-and-forget) */ function saveFeeds(feeds: Feed[]): void { - saveFeedsToFile(feeds).catch(() => {}); + saveFeedsToFile(feeds).catch(() => {}); } /** Save sources to file (async, fire-and-forget) */ function saveSources(sources: PodcastSource[]): void { - saveSourcesToFile(sources).catch(() => {}); + saveSourcesToFile(sources).catch(() => {}); } /** Create feed store */ export function createFeedStore() { - const [feeds, setFeeds] = createSignal([]); - const [sources, setSources] = createSignal([ - ...DEFAULT_SOURCES, - ]); - const [filter, setFilter] = createSignal({ - visibility: "all", - sortBy: "updated" as FeedSortField, - sortDirection: "desc", - }); - const [selectedFeedId, setSelectedFeedId] = createSignal(null); - const [isLoadingMore, setIsLoadingMore] = createSignal(false); - const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false); + const [feeds, setFeeds] = createSignal([]); + const [sources, setSources] = createSignal([ + ...DEFAULT_SOURCES, + ]); + const [filter, setFilter] = createSignal({ + visibility: "all", + sortBy: "updated" as FeedSortField, + sortDirection: "desc", + }); + const [selectedFeedId, setSelectedFeedId] = createSignal(null); + const [isLoadingMore, setIsLoadingMore] = createSignal(false); + const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false); - /** Get filtered and sorted feeds */ - const getFilteredFeeds = (): Feed[] => { - let result = [...feeds()]; - const f = filter(); - const authStore = useAuthStore(); + /** Get filtered and sorted feeds */ + const getFilteredFeeds = (): Feed[] => { + let result = [...feeds()]; + const f = filter(); + const authStore = useAuthStore(); - // Filter by visibility - if (f.visibility && f.visibility !== "all") { - result = result.filter((feed) => feed.visibility === f.visibility); - } else if (f.visibility === "all") { - // Only show private feeds if authenticated - result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated); - } + // Filter by visibility + if (f.visibility && f.visibility !== "all") { + result = result.filter((feed) => feed.visibility === f.visibility); + } else if (f.visibility === "all") { + // Only show private feeds if authenticated + result = result.filter( + (feed) => + feed.visibility === FeedVisibility.PUBLIC || + authStore.isAuthenticated, + ); + } - // Filter by source - if (f.sourceId) { - result = result.filter((feed) => feed.sourceId === f.sourceId); - } + // 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 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( - (feed) => - feed.podcast.title.toLowerCase().includes(query) || - feed.customName?.toLowerCase().includes(query) || - feed.podcast.description?.toLowerCase().includes(query), - ); - } + // Filter by search query + if (f.searchQuery) { + const query = f.searchQuery.toLowerCase(); + result = result.filter( + (feed) => + feed.podcast.title.toLowerCase().includes(query) || + feed.customName?.toLowerCase().includes(query) || + feed.podcast.description?.toLowerCase().includes(query), + ); + } - // Sort by selected field - const sortDir = f.sortDirection === "asc" ? 1 : -1; - result.sort((a, b) => { - switch (f.sortBy) { - case "title": - return ( - sortDir * - (a.customName || a.podcast.title).localeCompare( - b.customName || b.podcast.title, - ) - ); - case "episodeCount": - return sortDir * (a.episodes.length - b.episodes.length); - case "latestEpisode": - const aLatest = a.episodes[0]?.pubDate?.getTime() || 0; - const bLatest = b.episodes[0]?.pubDate?.getTime() || 0; - return sortDir * (aLatest - bLatest); - case "updated": - default: - return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime()); - } - }); + // Sort by selected field + const sortDir = f.sortDirection === "asc" ? 1 : -1; + result.sort((a, b) => { + switch (f.sortBy) { + case "title": + return ( + sortDir * + (a.customName || a.podcast.title).localeCompare( + b.customName || b.podcast.title, + ) + ); + case "episodeCount": + return sortDir * (a.episodes.length - b.episodes.length); + case "latestEpisode": + const aLatest = a.episodes[0]?.pubDate?.getTime() || 0; + const bLatest = b.episodes[0]?.pubDate?.getTime() || 0; + return sortDir * (aLatest - bLatest); + case "updated": + default: + return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime()); + } + }); - // Pinned feeds always first - result.sort((a, b) => { - if (a.isPinned && !b.isPinned) return -1; - if (!a.isPinned && b.isPinned) return 1; - return 0; - }); + // Pinned feeds always first + result.sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + return 0; + }); - return result; - }; + return result; + }; - /** Get episodes in reverse chronological order across all feeds */ - const getAllEpisodesChronological = (): Array<{ - episode: Episode; - feed: Feed; - }> => { - const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []; + /** Get episodes in reverse chronological order across all feeds */ + const getAllEpisodesChronological = (): Array<{ + episode: Episode; + feed: Feed; + }> => { + const allEpisodes: Array<{ episode: Episode; feed: Feed }> = []; - for (const feed of feeds()) { - for (const episode of feed.episodes) { - allEpisodes.push({ episode, feed }); - } - } + for (const feed of feeds()) { + for (const episode of feed.episodes) { + allEpisodes.push({ episode, feed }); + } + } - // Sort by publication date (newest first) - allEpisodes.sort( - (a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(), - ); + // Sort by publication date (newest first) + allEpisodes.sort( + (a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(), + ); - return allEpisodes; - }; + return allEpisodes; + }; - /** Sort episodes in reverse chronological order (newest first) */ - const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => { - return [...episodes].sort( - (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), - ); - }; + /** Sort episodes in reverse chronological order (newest first) */ + const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => { + return [...episodes].sort( + (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), + ); + }; - /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ - const fetchEpisodes = async ( - feedUrl: string, - limit: number, - feedId?: string, - ): Promise => { - try { - const response = await fetch(feedUrl, { - headers: { - "Accept-Encoding": "identity", - Accept: "application/rss+xml, application/xml, text/xml, */*", - }, - }); - if (!response.ok) return []; - const xml = await response.text(); - const parsed = parseRSSFeed(xml, feedUrl); - const allEpisodes = sortEpisodesReverseChronological(parsed.episodes); + /** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */ + const fetchEpisodes = async ( + feedUrl: string, + limit: number, + feedId?: string, + ): Promise => { + try { + const response = await fetch(feedUrl, { + headers: { + "Accept-Encoding": "identity", + Accept: "application/rss+xml, application/xml, text/xml, */*", + }, + }); + if (!response.ok) return []; + const xml = await response.text(); + const parsed = parseRSSFeed(xml, feedUrl); + const allEpisodes = sortEpisodesReverseChronological(parsed.episodes); - // Cache all parsed episodes for pagination - if (feedId) { - fullEpisodeCache.set(feedId, allEpisodes); - episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)); - } + // Cache all parsed episodes for pagination + if (feedId) { + fullEpisodeCache.set(feedId, allEpisodes); + episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length)); + } - return allEpisodes.slice(0, limit); - } catch { - return []; - } - }; + return allEpisodes.slice(0, limit); + } catch { + return []; + } + }; - /** Add a new feed and auto-fetch latest 20 episodes */ - const addFeed = async ( - podcast: Podcast, - sourceId: string, - visibility: FeedVisibility = FeedVisibility.PUBLIC, - ) => { - const feedId = crypto.randomUUID(); - const episodes = await fetchEpisodes( - podcast.feedUrl, - MAX_EPISODES_SUBSCRIBE, - feedId, - ); - const newFeed: Feed = { - id: feedId, - podcast, - episodes, - visibility, - sourceId, - lastUpdated: new Date(), - isPinned: false, - }; - setFeeds((prev) => { - const updated = [...prev, newFeed]; - saveFeeds(updated); - return updated; - }); - return newFeed; - }; + /** Check if a feed with this URL already exists */ + const hasFeedByUrl = (feedUrl: string): boolean => { + return feeds().some((f) => f.podcast.feedUrl === feedUrl); + }; - /** Auto-download newest episodes for a feed */ - const autoDownloadEpisodes = ( - feedId: string, - 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); - } - } - } catch { - // Download store may not be available yet - } - }; + /** Add a new feed and auto-fetch latest 20 episodes */ + const addFeed = async ( + podcast: Podcast, + sourceId: string, + visibility: FeedVisibility = FeedVisibility.PUBLIC, + ): Promise => { + // Guard: don't add a feed we already have (matched by feedUrl) + if (hasFeedByUrl(podcast.feedUrl)) { + return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null; + } - /** Refresh a single feed - re-fetch latest 50 episodes */ - const refreshFeed = async (feedId: string) => { - const feed = getFeed(feedId); - if (!feed) return; - const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id)); - const episodes = await fetchEpisodes( - feed.podcast.feedUrl, - MAX_EPISODES_REFRESH, - feedId, - ); - setFeeds((prev) => { - const updated = prev.map((f) => - f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f, - ); - saveFeeds(updated); - return updated; - }); + const feedId = crypto.randomUUID(); + const episodes = await fetchEpisodes( + podcast.feedUrl, + MAX_EPISODES_SUBSCRIBE, + feedId, + ); + const newFeed: Feed = { + id: feedId, + podcast, + episodes, + visibility, + sourceId, + lastUpdated: new Date(), + isPinned: false, + }; + setFeeds((prev) => { + const updated = [...prev, newFeed]; + saveFeeds(updated); + return updated; + }); + return newFeed; + }; - // Auto-download new episodes if enabled for this feed - if (feed.autoDownload) { - const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id)); - if (newEpisodes.length > 0) { - autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0); - } - } - }; + /** Auto-download newest episodes for a feed */ + const autoDownloadEpisodes = ( + feedId: string, + 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); + } + } + } catch { + // Download store may not be available yet + } + }; - /** Refresh all feeds */ - const refreshAllFeeds = async () => { - setIsLoadingFeeds(true); - try { - const currentFeeds = feeds(); - for (const feed of currentFeeds) { - await refreshFeed(feed.id); - } - } finally { - setIsLoadingFeeds(false); - } - }; + /** Refresh a single feed - re-fetch latest 50 episodes */ + const refreshFeed = async (feedId: string) => { + const feed = getFeed(feedId); + if (!feed) return; + const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id)); + const episodes = await fetchEpisodes( + feed.podcast.feedUrl, + MAX_EPISODES_REFRESH, + feedId, + ); + setFeeds((prev) => { + const updated = prev.map((f) => + f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f, + ); + saveFeeds(updated); + return updated; + }); - (async () => { - const loadedFeeds = await loadFeedsFromFile(); - if (loadedFeeds.length > 0) setFeeds(loadedFeeds); - const loadedSources = await loadSourcesFromFile(); - if (loadedSources && loadedSources.length > 0) setSources(loadedSources); - await refreshAllFeeds(); - })(); + // Auto-download new episodes if enabled for this feed + if (feed.autoDownload) { + const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id)); + if (newEpisodes.length > 0) { + autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0); + } + } + }; - /** Remove a feed */ - const removeFeed = (feedId: string) => { - fullEpisodeCache.delete(feedId); - episodeLoadCount.delete(feedId); - setFeeds((prev) => { - const updated = prev.filter((f) => f.id !== feedId); - saveFeeds(updated); - return updated; - }); - }; + /** Refresh all feeds */ + const refreshAllFeeds = async () => { + setIsLoadingFeeds(true); + try { + const currentFeeds = feeds(); + for (const feed of currentFeeds) { + await refreshFeed(feed.id); + } + } finally { + setIsLoadingFeeds(false); + } + }; - /** Update a feed */ - const updateFeed = (feedId: string, updates: Partial) => { - setFeeds((prev) => { - const updated = prev.map((f) => - f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f, - ); - saveFeeds(updated); - return updated; - }); - }; + (async () => { + const loadedFeeds = await loadFeedsFromFile(); + if (loadedFeeds.length > 0) setFeeds(loadedFeeds); + const loadedSources = await loadSourcesFromFile(); + if (loadedSources && loadedSources.length > 0) setSources(loadedSources); + await refreshAllFeeds(); + })(); - /** Toggle feed pinned status */ - const togglePinned = (feedId: string) => { - setFeeds((prev) => { - const updated = prev.map((f) => - f.id === feedId ? { ...f, isPinned: !f.isPinned } : f, - ); - saveFeeds(updated); - return updated; - }); - }; + /** Remove a feed */ + const removeFeed = (feedId: string) => { + fullEpisodeCache.delete(feedId); + episodeLoadCount.delete(feedId); + setFeeds((prev) => { + const updated = prev.filter((f) => f.id !== feedId); + saveFeeds(updated); + return updated; + }); + }; - /** Add a source */ - const addSource = (source: Omit) => { - const newSource: PodcastSource = { - ...source, - id: crypto.randomUUID(), - }; - setSources((prev) => { - const updated = [...prev, newSource]; - saveSources(updated); - return updated; - }); - return newSource; - }; + /** Remove a feed by its RSS URL (for sources that match by URL, not ID) */ + const removeFeedByUrl = (feedUrl: string) => { + const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl); + if (feed) { + fullEpisodeCache.delete(feed.id); + episodeLoadCount.delete(feed.id); + setFeeds((prev) => { + const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl); + saveFeeds(updated); + return updated; + }); + } + }; - /** Update a source */ - const updateSource = (sourceId: string, updates: Partial) => { - setSources((prev) => { - const updated = prev.map((source) => - source.id === sourceId ? { ...source, ...updates } : source, - ); - saveSources(updated); - return updated; - }); - }; + /** Update a feed */ + const updateFeed = (feedId: string, updates: Partial) => { + setFeeds((prev) => { + const updated = prev.map((f) => + f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f, + ); + saveFeeds(updated); + return updated; + }); + }; - /** Remove a source */ - const removeSource = (sourceId: string) => { - // Don't remove default sources - if (sourceId === "itunes" || sourceId === "rss") return false; + /** Toggle feed pinned status */ + const togglePinned = (feedId: string) => { + setFeeds((prev) => { + const updated = prev.map((f) => + f.id === feedId ? { ...f, isPinned: !f.isPinned } : f, + ); + saveFeeds(updated); + return updated; + }); + }; - setSources((prev) => { - const updated = prev.filter((s) => s.id !== sourceId); - saveSources(updated); - return updated; - }); - return true; - }; + /** Add a source */ + const addSource = (source: Omit) => { + const newSource: PodcastSource = { + ...source, + id: crypto.randomUUID(), + }; + setSources((prev) => { + const updated = [...prev, newSource]; + saveSources(updated); + return updated; + }); + return newSource; + }; - /** Toggle source enabled status */ - const toggleSource = (sourceId: string) => { - setSources((prev) => { - const updated = prev.map((s) => - s.id === sourceId ? { ...s, enabled: !s.enabled } : s, - ); - saveSources(updated); - return updated; - }); - }; + /** Update a source */ + const updateSource = (sourceId: string, updates: Partial) => { + setSources((prev) => { + const updated = prev.map((source) => + source.id === sourceId ? { ...source, ...updates } : source, + ); + saveSources(updated); + return updated; + }); + }; - /** Get feed by ID */ - const getFeed = (feedId: string): Feed | undefined => { - return feeds().find((f) => f.id === feedId); - }; + /** Remove a source */ + const removeSource = (sourceId: string) => { + // Don't remove default sources + if (sourceId === "itunes" || sourceId === "rss") return false; - /** Get selected feed */ - const getSelectedFeed = (): Feed | undefined => { - const id = selectedFeedId(); - return id ? getFeed(id) : undefined; - }; + setSources((prev) => { + const updated = prev.filter((s) => s.id !== sourceId); + saveSources(updated); + return updated; + }); + return true; + }; - /** Check if a feed has more episodes available beyond what's currently loaded */ - const hasMoreEpisodes = (feedId: string): boolean => { - const cached = fullEpisodeCache.get(feedId); - if (!cached) return false; - const loaded = episodeLoadCount.get(feedId) ?? 0; - return loaded < cached.length; - }; + /** Toggle source enabled status */ + const toggleSource = (sourceId: string) => { + setSources((prev) => { + const updated = prev.map((s) => + s.id === sourceId ? { ...s, enabled: !s.enabled } : s, + ); + saveSources(updated); + return updated; + }); + }; - /** Load the next chunk of episodes for a feed from the cache. - * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ - const loadMoreEpisodes = async (feedId: string) => { - if (isLoadingMore()) return; - const feed = getFeed(feedId); - if (!feed) return; + /** Get feed by ID */ + const getFeed = (feedId: string): Feed | undefined => { + return feeds().find((f) => f.id === feedId); + }; - setIsLoadingMore(true); - try { - let cached = fullEpisodeCache.get(feedId); + /** Get selected feed */ + const getSelectedFeed = (): Feed | undefined => { + const id = selectedFeedId(); + return id ? getFeed(id) : undefined; + }; - // If no cache, re-fetch and parse the full feed - if (!cached) { - const response = await fetch(feed.podcast.feedUrl, { - headers: { - "Accept-Encoding": "identity", - Accept: "application/rss+xml, application/xml, text/xml, */*", - }, - }); - if (!response.ok) return; - const xml = await response.text(); - const parsed = parseRSSFeed(xml, feed.podcast.feedUrl); - cached = parsed.episodes; - fullEpisodeCache.set(feedId, cached); - // Set current load count to match what's already displayed - episodeLoadCount.set(feedId, feed.episodes.length); - } + /** Check if a feed has more episodes available beyond what's currently loaded */ + const hasMoreEpisodes = (feedId: string): boolean => { + const cached = fullEpisodeCache.get(feedId); + if (!cached) return false; + const loaded = episodeLoadCount.get(feedId) ?? 0; + return loaded < cached.length; + }; - const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length; - const newCount = Math.min( - currentCount + MAX_EPISODES_REFRESH, - cached.length, - ); + /** Load the next chunk of episodes for a feed from the cache. + * If no cache exists (e.g. app restart), re-fetches from the RSS feed. */ + const loadMoreEpisodes = async (feedId: string) => { + if (isLoadingMore()) return; + const feed = getFeed(feedId); + if (!feed) return; - if (newCount <= currentCount) return; // nothing more to load + setIsLoadingMore(true); + try { + let cached = fullEpisodeCache.get(feedId); - episodeLoadCount.set(feedId, newCount); - const episodes = cached.slice(0, newCount); + // If no cache, re-fetch and parse the full feed + if (!cached) { + const response = await fetch(feed.podcast.feedUrl, { + headers: { + "Accept-Encoding": "identity", + Accept: "application/rss+xml, application/xml, text/xml, */*", + }, + }); + if (!response.ok) return; + const xml = await response.text(); + const parsed = parseRSSFeed(xml, feed.podcast.feedUrl); + cached = parsed.episodes; + fullEpisodeCache.set(feedId, cached); + // Set current load count to match what's already displayed + episodeLoadCount.set(feedId, feed.episodes.length); + } - setFeeds((prev) => { - const updated = prev.map((f) => - f.id === feedId ? { ...f, episodes } : f, - ); - saveFeeds(updated); - return updated; - }); - } finally { - setIsLoadingMore(false); - } - }; + const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length; + const newCount = Math.min( + currentCount + MAX_EPISODES_REFRESH, + cached.length, + ); - /** Set auto-download settings for a feed */ - const setAutoDownload = ( - feedId: string, - enabled: boolean, - count: number = 0, - ) => { - updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count }); - }; + if (newCount <= currentCount) return; // nothing more to load - return { - // State - feeds, - sources, - filter, - selectedFeedId, - isLoadingMore, + episodeLoadCount.set(feedId, newCount); + const episodes = cached.slice(0, newCount); - // Computed - getFilteredFeeds, - getAllEpisodesChronological, - getFeed, - getSelectedFeed, - hasMoreEpisodes, - isLoadingFeeds, + setFeeds((prev) => { + const updated = prev.map((f) => + f.id === feedId ? { ...f, episodes } : f, + ); + saveFeeds(updated); + return updated; + }); + } finally { + setIsLoadingMore(false); + } + }; - // Actions - setFilter, - setSelectedFeedId, - addFeed, - removeFeed, - updateFeed, - togglePinned, - refreshFeed, - refreshAllFeeds, - loadMoreEpisodes, - addSource, - removeSource, - toggleSource, - updateSource, - setAutoDownload, - }; + /** Set auto-download settings for a feed */ + const setAutoDownload = ( + feedId: string, + enabled: boolean, + count: number = 0, + ) => { + updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count }); + }; + + return { + // State + feeds, + sources, + filter, + selectedFeedId, + isLoadingMore, + + // Computed + getFilteredFeeds, + getAllEpisodesChronological, + getFeed, + getSelectedFeed, + hasMoreEpisodes, + isLoadingFeeds, + + // Actions + setFilter, + setSelectedFeedId, + addFeed, + hasFeedByUrl, + removeFeed, + removeFeedByUrl, + updateFeed, + togglePinned, + refreshFeed, + refreshAllFeeds, + loadMoreEpisodes, + addSource, + removeSource, + toggleSource, + updateSource, + setAutoDownload, + }; } /** Singleton feed store */ let feedStoreInstance: ReturnType | null = null; export function useFeedStore() { - if (!feedStoreInstance) { - feedStoreInstance = createFeedStore(); - } - return feedStoreInstance; + if (!feedStoreInstance) { + feedStoreInstance = createFeedStore(); + } + return feedStoreInstance; }