docs: add human-oriented CONTRIBUTING.md (repo map, FFI notes, gotchas, release & tap auto-sync workflow)
This commit is contained in:
200
CONTRIBUTING.md
Normal file
200
CONTRIBUTING.md
Normal file
@@ -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.<dylib\|so>` |
|
||||||
|
| `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-<platform>-<arch>` 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-<platform>-<arch>.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 <clone of mikefreno/homebrew-podtui>
|
||||||
|
./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`.
|
||||||
7
notes.md
7
notes.md
@@ -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
|
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
onCleanup,
|
onCleanup,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useSearchStore } from "@/stores/search";
|
import { useSearchStore } from "@/stores/search";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +45,7 @@ export const SearchPaneCount = 1;
|
|||||||
|
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
@@ -127,6 +129,8 @@ function SearchPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscribe = (result: SearchResult) => {
|
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);
|
searchStore.markSubscribed(result.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,213 +3,233 @@
|
|||||||
* Manages trending/popular podcasts and category filtering
|
* Manages trending/popular podcasts and category filtering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js";
|
||||||
import type { Podcast } from "../types/podcast"
|
import type { Podcast } from "../types/podcast";
|
||||||
|
import { useFeedStore } from "./feed";
|
||||||
|
|
||||||
export interface DiscoverCategory {
|
export interface DiscoverCategory {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||||
{ id: "all", name: "All", icon: "*" },
|
{ id: "all", name: "All", icon: "*" },
|
||||||
{ id: "technology", name: "Technology", icon: ">" },
|
{ id: "technology", name: "Technology", icon: ">" },
|
||||||
{ id: "science", name: "Science", icon: "~" },
|
{ id: "science", name: "Science", icon: "~" },
|
||||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
{ id: "comedy", name: "Comedy", icon: ")" },
|
||||||
{ id: "news", name: "News", icon: "!" },
|
{ id: "news", name: "News", icon: "!" },
|
||||||
{ id: "business", name: "Business", icon: "$" },
|
{ id: "business", name: "Business", icon: "$" },
|
||||||
{ id: "health", name: "Health", icon: "+" },
|
{ id: "health", name: "Health", icon: "+" },
|
||||||
{ id: "education", name: "Education", icon: "?" },
|
{ id: "education", name: "Education", icon: "?" },
|
||||||
{ id: "sports", name: "Sports", icon: "#" },
|
{ id: "sports", name: "Sports", icon: "#" },
|
||||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
{ id: "true-crime", name: "True Crime", icon: "%" },
|
||||||
{ id: "arts", name: "Arts", icon: "@" },
|
{ id: "arts", name: "Arts", icon: "@" },
|
||||||
]
|
];
|
||||||
|
|
||||||
/** Mock trending podcasts */
|
/** Mock trending podcasts */
|
||||||
const TRENDING_PODCASTS: Podcast[] = [
|
const TRENDING_PODCASTS: Podcast[] = [
|
||||||
{
|
{
|
||||||
id: "trend-1",
|
id: "trend-1",
|
||||||
title: "AI Today",
|
title: "AI Today",
|
||||||
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
description:
|
||||||
feedUrl: "https://example.com/aitoday.rss",
|
"The latest developments in artificial intelligence, machine learning, and their impact on society.",
|
||||||
author: "Tech Futures",
|
feedUrl: "https://example.com/aitoday.rss",
|
||||||
categories: ["Technology", "Science"],
|
author: "Tech Futures",
|
||||||
coverUrl: undefined,
|
categories: ["Technology", "Science"],
|
||||||
lastUpdated: new Date(),
|
coverUrl: undefined,
|
||||||
isSubscribed: false,
|
lastUpdated: new Date(),
|
||||||
},
|
isSubscribed: false,
|
||||||
{
|
},
|
||||||
id: "trend-2",
|
{
|
||||||
title: "The History Hour",
|
id: "trend-2",
|
||||||
description: "Fascinating stories from history that shaped our world today.",
|
title: "The History Hour",
|
||||||
feedUrl: "https://example.com/historyhour.rss",
|
description:
|
||||||
author: "History Channel",
|
"Fascinating stories from history that shaped our world today.",
|
||||||
categories: ["Education", "History"],
|
feedUrl: "https://example.com/historyhour.rss",
|
||||||
lastUpdated: new Date(),
|
author: "History Channel",
|
||||||
isSubscribed: false,
|
categories: ["Education", "History"],
|
||||||
},
|
lastUpdated: new Date(),
|
||||||
{
|
isSubscribed: false,
|
||||||
id: "trend-3",
|
},
|
||||||
title: "Comedy Gold",
|
{
|
||||||
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
|
id: "trend-3",
|
||||||
feedUrl: "https://example.com/comedygold.rss",
|
title: "Comedy Gold",
|
||||||
author: "Laugh Factory",
|
description:
|
||||||
categories: ["Comedy", "Entertainment"],
|
"Weekly stand-up comedy, sketches, and hilarious conversations.",
|
||||||
lastUpdated: new Date(),
|
feedUrl: "https://example.com/comedygold.rss",
|
||||||
isSubscribed: false,
|
author: "Laugh Factory",
|
||||||
},
|
categories: ["Comedy", "Entertainment"],
|
||||||
{
|
lastUpdated: new Date(),
|
||||||
id: "trend-4",
|
isSubscribed: false,
|
||||||
title: "Market Watch",
|
},
|
||||||
description: "Daily financial news, stock analysis, and investing tips.",
|
{
|
||||||
feedUrl: "https://example.com/marketwatch.rss",
|
id: "trend-4",
|
||||||
author: "Finance Daily",
|
title: "Market Watch",
|
||||||
categories: ["Business", "News"],
|
description: "Daily financial news, stock analysis, and investing tips.",
|
||||||
lastUpdated: new Date(),
|
feedUrl: "https://example.com/marketwatch.rss",
|
||||||
isSubscribed: true,
|
author: "Finance Daily",
|
||||||
},
|
categories: ["Business", "News"],
|
||||||
{
|
lastUpdated: new Date(),
|
||||||
id: "trend-5",
|
isSubscribed: true,
|
||||||
title: "Science Weekly",
|
},
|
||||||
description: "Breaking science news and in-depth analysis of the latest research.",
|
{
|
||||||
feedUrl: "https://example.com/scienceweekly.rss",
|
id: "trend-5",
|
||||||
author: "Science Network",
|
title: "Science Weekly",
|
||||||
categories: ["Science", "Education"],
|
description:
|
||||||
lastUpdated: new Date(),
|
"Breaking science news and in-depth analysis of the latest research.",
|
||||||
isSubscribed: false,
|
feedUrl: "https://example.com/scienceweekly.rss",
|
||||||
},
|
author: "Science Network",
|
||||||
{
|
categories: ["Science", "Education"],
|
||||||
id: "trend-6",
|
lastUpdated: new Date(),
|
||||||
title: "True Crime Files",
|
isSubscribed: false,
|
||||||
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
|
},
|
||||||
feedUrl: "https://example.com/truecrime.rss",
|
{
|
||||||
author: "Crime Network",
|
id: "trend-6",
|
||||||
categories: ["True Crime", "Documentary"],
|
title: "True Crime Files",
|
||||||
lastUpdated: new Date(),
|
description:
|
||||||
isSubscribed: false,
|
"Investigative journalism into real criminal cases and unsolved mysteries.",
|
||||||
},
|
feedUrl: "https://example.com/truecrime.rss",
|
||||||
{
|
author: "Crime Network",
|
||||||
id: "trend-7",
|
categories: ["True Crime", "Documentary"],
|
||||||
title: "Wellness Journey",
|
lastUpdated: new Date(),
|
||||||
description: "Tips for mental and physical health, meditation, and mindful living.",
|
isSubscribed: false,
|
||||||
feedUrl: "https://example.com/wellness.rss",
|
},
|
||||||
author: "Health Media",
|
{
|
||||||
categories: ["Health", "Self-Help"],
|
id: "trend-7",
|
||||||
lastUpdated: new Date(),
|
title: "Wellness Journey",
|
||||||
isSubscribed: false,
|
description:
|
||||||
},
|
"Tips for mental and physical health, meditation, and mindful living.",
|
||||||
{
|
feedUrl: "https://example.com/wellness.rss",
|
||||||
id: "trend-8",
|
author: "Health Media",
|
||||||
title: "Sports Talk Live",
|
categories: ["Health", "Self-Help"],
|
||||||
description: "Live commentary, analysis, and interviews from the world of sports.",
|
lastUpdated: new Date(),
|
||||||
feedUrl: "https://example.com/sportstalk.rss",
|
isSubscribed: false,
|
||||||
author: "Sports Network",
|
},
|
||||||
categories: ["Sports", "News"],
|
{
|
||||||
lastUpdated: new Date(),
|
id: "trend-8",
|
||||||
isSubscribed: false,
|
title: "Sports Talk Live",
|
||||||
},
|
description:
|
||||||
{
|
"Live commentary, analysis, and interviews from the world of sports.",
|
||||||
id: "trend-9",
|
feedUrl: "https://example.com/sportstalk.rss",
|
||||||
title: "Creative Minds",
|
author: "Sports Network",
|
||||||
description: "Interviews with artists, designers, and creative professionals.",
|
categories: ["Sports", "News"],
|
||||||
feedUrl: "https://example.com/creativeminds.rss",
|
lastUpdated: new Date(),
|
||||||
author: "Arts Weekly",
|
isSubscribed: false,
|
||||||
categories: ["Arts", "Culture"],
|
},
|
||||||
lastUpdated: new Date(),
|
{
|
||||||
isSubscribed: false,
|
id: "trend-9",
|
||||||
},
|
title: "Creative Minds",
|
||||||
{
|
description:
|
||||||
id: "trend-10",
|
"Interviews with artists, designers, and creative professionals.",
|
||||||
title: "Dev Talk",
|
feedUrl: "https://example.com/creativeminds.rss",
|
||||||
description: "Software development, programming tutorials, and tech career advice.",
|
author: "Arts Weekly",
|
||||||
feedUrl: "https://example.com/devtalk.rss",
|
categories: ["Arts", "Culture"],
|
||||||
author: "Code Academy",
|
lastUpdated: new Date(),
|
||||||
categories: ["Technology", "Education"],
|
isSubscribed: false,
|
||||||
lastUpdated: new Date(),
|
},
|
||||||
isSubscribed: true,
|
{
|
||||||
},
|
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 */
|
/** Create discover store */
|
||||||
export function createDiscoverStore() {
|
export function createDiscoverStore() {
|
||||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS);
|
||||||
|
|
||||||
/** Get filtered podcasts by category */
|
/** Get filtered podcasts by category */
|
||||||
const filteredPodcasts = () => {
|
const filteredPodcasts = () => {
|
||||||
const category = selectedCategory()
|
const category = selectedCategory();
|
||||||
if (category === "all") {
|
if (category === "all") {
|
||||||
return podcasts()
|
return podcasts();
|
||||||
}
|
}
|
||||||
|
|
||||||
return podcasts().filter((p) => {
|
return podcasts().filter((p) => {
|
||||||
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
|
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
|
||||||
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
|
return cats.some((c) =>
|
||||||
})
|
c.includes(category.toLowerCase().replace("-", " ")),
|
||||||
}
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/** Subscribe to a podcast */
|
/** Subscribe to a podcast */
|
||||||
const subscribe = (podcastId: string) => {
|
const subscribe = (podcastId: string) => {
|
||||||
setPodcasts((prev) =>
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
prev.map((p) =>
|
if (podcast) {
|
||||||
p.id === podcastId ? { ...p, isSubscribed: true } : p
|
// 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 */
|
/** Unsubscribe from a podcast */
|
||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
setPodcasts((prev) =>
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
prev.map((p) =>
|
if (podcast) {
|
||||||
p.id === podcastId ? { ...p, isSubscribed: false } : p
|
// 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 */
|
/** Toggle subscription */
|
||||||
const toggleSubscription = (podcastId: string) => {
|
const toggleSubscription = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId)
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast?.isSubscribed) {
|
if (podcast?.isSubscribed) {
|
||||||
unsubscribe(podcastId)
|
unsubscribe(podcastId);
|
||||||
} else {
|
} else {
|
||||||
subscribe(podcastId)
|
subscribe(podcastId);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Refresh trending podcasts (mock) */
|
/** Refresh trending podcasts (mock) */
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
// Simulate network delay
|
// Simulate network delay
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
// In real app, would fetch from API
|
// In real app, would fetch from API
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
isLoading,
|
isLoading,
|
||||||
podcasts,
|
podcasts,
|
||||||
filteredPodcasts,
|
filteredPodcasts,
|
||||||
categories: DISCOVER_CATEGORIES,
|
categories: DISCOVER_CATEGORIES,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setSelectedCategory,
|
setSelectedCategory,
|
||||||
subscribe,
|
subscribe,
|
||||||
unsubscribe,
|
unsubscribe,
|
||||||
toggleSubscription,
|
toggleSubscription,
|
||||||
refresh,
|
refresh,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton discover store */
|
/** Singleton discover store */
|
||||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
|
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||||
|
|
||||||
export function useDiscoverStore() {
|
export function useDiscoverStore() {
|
||||||
if (!discoverStoreInstance) {
|
if (!discoverStoreInstance) {
|
||||||
discoverStoreInstance = createDiscoverStore()
|
discoverStoreInstance = createDiscoverStore();
|
||||||
}
|
}
|
||||||
return discoverStoreInstance
|
return discoverStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ import { createSignal } from "solid-js";
|
|||||||
import { FeedVisibility } from "../types/feed";
|
import { FeedVisibility } from "../types/feed";
|
||||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
import type { Episode, EpisodeStatus } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import type { PodcastSource, SourceType } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } from "../types/source";
|
import { DEFAULT_SOURCES } from "../types/source";
|
||||||
import { parseRSSFeed } from "../api/rss-parser";
|
import { parseRSSFeed } from "../api/rss-parser";
|
||||||
import {
|
import {
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
loadSourcesFromFile,
|
loadSourcesFromFile,
|
||||||
saveSourcesToFile,
|
saveSourcesToFile,
|
||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
@@ -35,461 +35,491 @@ const episodeLoadCount = new Map<string, number>();
|
|||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget) */
|
/** Save feeds to file (async, fire-and-forget) */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
saveFeedsToFile(feeds).catch(() => {});
|
saveFeedsToFile(feeds).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
/** Save sources to file (async, fire-and-forget) */
|
||||||
function saveSources(sources: PodcastSource[]): void {
|
function saveSources(sources: PodcastSource[]): void {
|
||||||
saveSourcesToFile(sources).catch(() => {});
|
saveSourcesToFile(sources).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
export function createFeedStore() {
|
export function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
...DEFAULT_SOURCES,
|
...DEFAULT_SOURCES,
|
||||||
]);
|
]);
|
||||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||||
visibility: "all",
|
visibility: "all",
|
||||||
sortBy: "updated" as FeedSortField,
|
sortBy: "updated" as FeedSortField,
|
||||||
sortDirection: "desc",
|
sortDirection: "desc",
|
||||||
});
|
});
|
||||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
/** Get filtered and sorted feeds */
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
// Filter by visibility
|
// Filter by visibility
|
||||||
if (f.visibility && f.visibility !== "all") {
|
if (f.visibility && f.visibility !== "all") {
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||||
} else if (f.visibility === "all") {
|
} else if (f.visibility === "all") {
|
||||||
// Only show private feeds if authenticated
|
// Only show private feeds if authenticated
|
||||||
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated);
|
result = result.filter(
|
||||||
}
|
(feed) =>
|
||||||
|
feed.visibility === FeedVisibility.PUBLIC ||
|
||||||
|
authStore.isAuthenticated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by source
|
// Filter by source
|
||||||
if (f.sourceId) {
|
if (f.sourceId) {
|
||||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by pinned
|
// Filter by pinned
|
||||||
if (f.pinnedOnly) {
|
if (f.pinnedOnly) {
|
||||||
result = result.filter((feed) => feed.isPinned);
|
result = result.filter((feed) => feed.isPinned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
if (f.searchQuery) {
|
if (f.searchQuery) {
|
||||||
const query = f.searchQuery.toLowerCase();
|
const query = f.searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(feed) =>
|
(feed) =>
|
||||||
feed.podcast.title.toLowerCase().includes(query) ||
|
feed.podcast.title.toLowerCase().includes(query) ||
|
||||||
feed.customName?.toLowerCase().includes(query) ||
|
feed.customName?.toLowerCase().includes(query) ||
|
||||||
feed.podcast.description?.toLowerCase().includes(query),
|
feed.podcast.description?.toLowerCase().includes(query),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by selected field
|
// Sort by selected field
|
||||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
switch (f.sortBy) {
|
switch (f.sortBy) {
|
||||||
case "title":
|
case "title":
|
||||||
return (
|
return (
|
||||||
sortDir *
|
sortDir *
|
||||||
(a.customName || a.podcast.title).localeCompare(
|
(a.customName || a.podcast.title).localeCompare(
|
||||||
b.customName || b.podcast.title,
|
b.customName || b.podcast.title,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
case "episodeCount":
|
case "episodeCount":
|
||||||
return sortDir * (a.episodes.length - b.episodes.length);
|
return sortDir * (a.episodes.length - b.episodes.length);
|
||||||
case "latestEpisode":
|
case "latestEpisode":
|
||||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||||
return sortDir * (aLatest - bLatest);
|
return sortDir * (aLatest - bLatest);
|
||||||
case "updated":
|
case "updated":
|
||||||
default:
|
default:
|
||||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pinned feeds always first
|
// Pinned feeds always first
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (a.isPinned && !b.isPinned) return -1;
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
if (!a.isPinned && b.isPinned) return 1;
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get episodes in reverse chronological order across all feeds */
|
/** Get episodes in reverse chronological order across all feeds */
|
||||||
const getAllEpisodesChronological = (): Array<{
|
const getAllEpisodesChronological = (): Array<{
|
||||||
episode: Episode;
|
episode: Episode;
|
||||||
feed: Feed;
|
feed: Feed;
|
||||||
}> => {
|
}> => {
|
||||||
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||||
|
|
||||||
for (const feed of feeds()) {
|
for (const feed of feeds()) {
|
||||||
for (const episode of feed.episodes) {
|
for (const episode of feed.episodes) {
|
||||||
allEpisodes.push({ episode, feed });
|
allEpisodes.push({ episode, feed });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by publication date (newest first)
|
// Sort by publication date (newest first)
|
||||||
allEpisodes.sort(
|
allEpisodes.sort(
|
||||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return allEpisodes;
|
return allEpisodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sort episodes in reverse chronological order (newest first) */
|
/** Sort episodes in reverse chronological order (newest first) */
|
||||||
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||||
return [...episodes].sort(
|
return [...episodes].sort(
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||||
const fetchEpisodes = async (
|
const fetchEpisodes = async (
|
||||||
feedUrl: string,
|
feedUrl: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
feedId?: string,
|
feedId?: string,
|
||||||
): Promise<Episode[]> => {
|
): Promise<Episode[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const response = await fetch(feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const parsed = parseRSSFeed(xml, feedUrl);
|
const parsed = parseRSSFeed(xml, feedUrl);
|
||||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||||
|
|
||||||
// Cache all parsed episodes for pagination
|
// Cache all parsed episodes for pagination
|
||||||
if (feedId) {
|
if (feedId) {
|
||||||
fullEpisodeCache.set(feedId, allEpisodes);
|
fullEpisodeCache.set(feedId, allEpisodes);
|
||||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
return allEpisodes.slice(0, limit);
|
return allEpisodes.slice(0, limit);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add a new feed and auto-fetch latest 20 episodes */
|
/** Check if a feed with this URL already exists */
|
||||||
const addFeed = async (
|
const hasFeedByUrl = (feedUrl: string): boolean => {
|
||||||
podcast: Podcast,
|
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Auto-download newest episodes for a feed */
|
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||||
const autoDownloadEpisodes = (
|
const addFeed = async (
|
||||||
feedId: string,
|
podcast: Podcast,
|
||||||
newEpisodes: Episode[],
|
sourceId: string,
|
||||||
count: number,
|
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||||
) => {
|
): Promise<Feed | null> => {
|
||||||
try {
|
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||||
const dlStore = useDownloadStore();
|
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||||
// Sort by pubDate descending (newest first)
|
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||||
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 a single feed - re-fetch latest 50 episodes */
|
const feedId = crypto.randomUUID();
|
||||||
const refreshFeed = async (feedId: string) => {
|
const episodes = await fetchEpisodes(
|
||||||
const feed = getFeed(feedId);
|
podcast.feedUrl,
|
||||||
if (!feed) return;
|
MAX_EPISODES_SUBSCRIBE,
|
||||||
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
feedId,
|
||||||
const episodes = await fetchEpisodes(
|
);
|
||||||
feed.podcast.feedUrl,
|
const newFeed: Feed = {
|
||||||
MAX_EPISODES_REFRESH,
|
id: feedId,
|
||||||
feedId,
|
podcast,
|
||||||
);
|
episodes,
|
||||||
setFeeds((prev) => {
|
visibility,
|
||||||
const updated = prev.map((f) =>
|
sourceId,
|
||||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
lastUpdated: new Date(),
|
||||||
);
|
isPinned: false,
|
||||||
saveFeeds(updated);
|
};
|
||||||
return updated;
|
setFeeds((prev) => {
|
||||||
});
|
const updated = [...prev, newFeed];
|
||||||
|
saveFeeds(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
return newFeed;
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-download new episodes if enabled for this feed
|
/** Auto-download newest episodes for a feed */
|
||||||
if (feed.autoDownload) {
|
const autoDownloadEpisodes = (
|
||||||
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
feedId: string,
|
||||||
if (newEpisodes.length > 0) {
|
newEpisodes: Episode[],
|
||||||
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
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 */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
const refreshAllFeeds = async () => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
setIsLoadingFeeds(true);
|
const feed = getFeed(feedId);
|
||||||
try {
|
if (!feed) return;
|
||||||
const currentFeeds = feeds();
|
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
|
||||||
for (const feed of currentFeeds) {
|
const episodes = await fetchEpisodes(
|
||||||
await refreshFeed(feed.id);
|
feed.podcast.feedUrl,
|
||||||
}
|
MAX_EPISODES_REFRESH,
|
||||||
} finally {
|
feedId,
|
||||||
setIsLoadingFeeds(false);
|
);
|
||||||
}
|
setFeeds((prev) => {
|
||||||
};
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||||
|
);
|
||||||
|
saveFeeds(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
(async () => {
|
// Auto-download new episodes if enabled for this feed
|
||||||
const loadedFeeds = await loadFeedsFromFile();
|
if (feed.autoDownload) {
|
||||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
|
||||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
if (newEpisodes.length > 0) {
|
||||||
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
|
||||||
await refreshAllFeeds();
|
}
|
||||||
})();
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Remove a feed */
|
/** Refresh all feeds */
|
||||||
const removeFeed = (feedId: string) => {
|
const refreshAllFeeds = async () => {
|
||||||
fullEpisodeCache.delete(feedId);
|
setIsLoadingFeeds(true);
|
||||||
episodeLoadCount.delete(feedId);
|
try {
|
||||||
setFeeds((prev) => {
|
const currentFeeds = feeds();
|
||||||
const updated = prev.filter((f) => f.id !== feedId);
|
for (const feed of currentFeeds) {
|
||||||
saveFeeds(updated);
|
await refreshFeed(feed.id);
|
||||||
return updated;
|
}
|
||||||
});
|
} finally {
|
||||||
};
|
setIsLoadingFeeds(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Update a feed */
|
(async () => {
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
const loadedFeeds = await loadFeedsFromFile();
|
||||||
setFeeds((prev) => {
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||||
const updated = prev.map((f) =>
|
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
|
||||||
);
|
await refreshAllFeeds();
|
||||||
saveFeeds(updated);
|
})();
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Toggle feed pinned status */
|
/** Remove a feed */
|
||||||
const togglePinned = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
setFeeds((prev) => {
|
fullEpisodeCache.delete(feedId);
|
||||||
const updated = prev.map((f) =>
|
episodeLoadCount.delete(feedId);
|
||||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
setFeeds((prev) => {
|
||||||
);
|
const updated = prev.filter((f) => f.id !== feedId);
|
||||||
saveFeeds(updated);
|
saveFeeds(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add a source */
|
/** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
|
||||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
const removeFeedByUrl = (feedUrl: string) => {
|
||||||
const newSource: PodcastSource = {
|
const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
|
||||||
...source,
|
if (feed) {
|
||||||
id: crypto.randomUUID(),
|
fullEpisodeCache.delete(feed.id);
|
||||||
};
|
episodeLoadCount.delete(feed.id);
|
||||||
setSources((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = [...prev, newSource];
|
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
||||||
saveSources(updated);
|
saveFeeds(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
return newSource;
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Update a source */
|
/** Update a feed */
|
||||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
setSources((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((source) =>
|
const updated = prev.map((f) =>
|
||||||
source.id === sourceId ? { ...source, ...updates } : source,
|
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||||
);
|
);
|
||||||
saveSources(updated);
|
saveFeeds(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove a source */
|
/** Toggle feed pinned status */
|
||||||
const removeSource = (sourceId: string) => {
|
const togglePinned = (feedId: string) => {
|
||||||
// Don't remove default sources
|
setFeeds((prev) => {
|
||||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||||
|
);
|
||||||
|
saveFeeds(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
setSources((prev) => {
|
/** Add a source */
|
||||||
const updated = prev.filter((s) => s.id !== sourceId);
|
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||||
saveSources(updated);
|
const newSource: PodcastSource = {
|
||||||
return updated;
|
...source,
|
||||||
});
|
id: crypto.randomUUID(),
|
||||||
return true;
|
};
|
||||||
};
|
setSources((prev) => {
|
||||||
|
const updated = [...prev, newSource];
|
||||||
|
saveSources(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
return newSource;
|
||||||
|
};
|
||||||
|
|
||||||
/** Toggle source enabled status */
|
/** Update a source */
|
||||||
const toggleSource = (sourceId: string) => {
|
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.map((s) =>
|
const updated = prev.map((source) =>
|
||||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
source.id === sourceId ? { ...source, ...updates } : source,
|
||||||
);
|
);
|
||||||
saveSources(updated);
|
saveSources(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get feed by ID */
|
/** Remove a source */
|
||||||
const getFeed = (feedId: string): Feed | undefined => {
|
const removeSource = (sourceId: string) => {
|
||||||
return feeds().find((f) => f.id === feedId);
|
// Don't remove default sources
|
||||||
};
|
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||||
|
|
||||||
/** Get selected feed */
|
setSources((prev) => {
|
||||||
const getSelectedFeed = (): Feed | undefined => {
|
const updated = prev.filter((s) => s.id !== sourceId);
|
||||||
const id = selectedFeedId();
|
saveSources(updated);
|
||||||
return id ? getFeed(id) : undefined;
|
return updated;
|
||||||
};
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
/** Toggle source enabled status */
|
||||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
const toggleSource = (sourceId: string) => {
|
||||||
const cached = fullEpisodeCache.get(feedId);
|
setSources((prev) => {
|
||||||
if (!cached) return false;
|
const updated = prev.map((s) =>
|
||||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||||
return loaded < cached.length;
|
);
|
||||||
};
|
saveSources(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/** Load the next chunk of episodes for a feed from the cache.
|
/** Get feed by ID */
|
||||||
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
const loadMoreEpisodes = async (feedId: string) => {
|
return feeds().find((f) => f.id === feedId);
|
||||||
if (isLoadingMore()) return;
|
};
|
||||||
const feed = getFeed(feedId);
|
|
||||||
if (!feed) return;
|
|
||||||
|
|
||||||
setIsLoadingMore(true);
|
/** Get selected feed */
|
||||||
try {
|
const getSelectedFeed = (): Feed | undefined => {
|
||||||
let cached = fullEpisodeCache.get(feedId);
|
const id = selectedFeedId();
|
||||||
|
return id ? getFeed(id) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
// If no cache, re-fetch and parse the full feed
|
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||||
if (!cached) {
|
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||||
const response = await fetch(feed.podcast.feedUrl, {
|
const cached = fullEpisodeCache.get(feedId);
|
||||||
headers: {
|
if (!cached) return false;
|
||||||
"Accept-Encoding": "identity",
|
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
return loaded < cached.length;
|
||||||
},
|
};
|
||||||
});
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
/** Load the next chunk of episodes for a feed from the cache.
|
||||||
const newCount = Math.min(
|
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
|
||||||
currentCount + MAX_EPISODES_REFRESH,
|
const loadMoreEpisodes = async (feedId: string) => {
|
||||||
cached.length,
|
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);
|
// If no cache, re-fetch and parse the full feed
|
||||||
const episodes = cached.slice(0, newCount);
|
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 currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||||
const updated = prev.map((f) =>
|
const newCount = Math.min(
|
||||||
f.id === feedId ? { ...f, episodes } : f,
|
currentCount + MAX_EPISODES_REFRESH,
|
||||||
);
|
cached.length,
|
||||||
saveFeeds(updated);
|
);
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoadingMore(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Set auto-download settings for a feed */
|
if (newCount <= currentCount) return; // nothing more to load
|
||||||
const setAutoDownload = (
|
|
||||||
feedId: string,
|
|
||||||
enabled: boolean,
|
|
||||||
count: number = 0,
|
|
||||||
) => {
|
|
||||||
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
episodeLoadCount.set(feedId, newCount);
|
||||||
// State
|
const episodes = cached.slice(0, newCount);
|
||||||
feeds,
|
|
||||||
sources,
|
|
||||||
filter,
|
|
||||||
selectedFeedId,
|
|
||||||
isLoadingMore,
|
|
||||||
|
|
||||||
// Computed
|
setFeeds((prev) => {
|
||||||
getFilteredFeeds,
|
const updated = prev.map((f) =>
|
||||||
getAllEpisodesChronological,
|
f.id === feedId ? { ...f, episodes } : f,
|
||||||
getFeed,
|
);
|
||||||
getSelectedFeed,
|
saveFeeds(updated);
|
||||||
hasMoreEpisodes,
|
return updated;
|
||||||
isLoadingFeeds,
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Actions
|
/** Set auto-download settings for a feed */
|
||||||
setFilter,
|
const setAutoDownload = (
|
||||||
setSelectedFeedId,
|
feedId: string,
|
||||||
addFeed,
|
enabled: boolean,
|
||||||
removeFeed,
|
count: number = 0,
|
||||||
updateFeed,
|
) => {
|
||||||
togglePinned,
|
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
|
||||||
refreshFeed,
|
};
|
||||||
refreshAllFeeds,
|
|
||||||
loadMoreEpisodes,
|
return {
|
||||||
addSource,
|
// State
|
||||||
removeSource,
|
feeds,
|
||||||
toggleSource,
|
sources,
|
||||||
updateSource,
|
filter,
|
||||||
setAutoDownload,
|
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 */
|
/** Singleton feed store */
|
||||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||||
|
|
||||||
export function useFeedStore() {
|
export function useFeedStore() {
|
||||||
if (!feedStoreInstance) {
|
if (!feedStoreInstance) {
|
||||||
feedStoreInstance = createFeedStore();
|
feedStoreInstance = createFeedStore();
|
||||||
}
|
}
|
||||||
return feedStoreInstance;
|
return feedStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user