16 Commits

Author SHA1 Message Date
20d5b57cb6 bump VERSION to 0.6.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m9s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 22:12:40 -04:00
d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz
mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode
pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled
the whole remote file even when only minutes were listened to.

- audio-pcm-cache: sliding window around the playback position — the
  decode head caps at maxAheadSec (600s) ahead of the cursor, segments
  older than keepBehindSec (300s) are pruned, and the tail refills as
  playback advances. Steady state ~40 MB regardless of episode length;
  a backward seek past the window restarts a segment there (the existing
  seek-hole mechanism, no new failure mode).
- feed: cap the full-parse episode cache at 1000 episodes/feed so
  archive-heavy subscriptions can't pin their entire history in RAM;
  the visible list stays bounded by the user's cache preference and
  fetch-more keeps working within the ceiling.
- tests: pin the new head-cap and prune contracts (8/8 in
  audio-pcm-cache.test.ts; full suite 193 pass).

Also includes the in-flight cleanup/refactor pass (cover-art resolve
helper, page and comment tightening, ESLint config removal).
2026-08-12 21:02:19 -04:00
77531ce41d bump VERSION to 0.6.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m21s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 16:34:54 -04:00
26729fa5e6 feat(feed): make episode cache bound user-configurable (date window or count)
Add episodeCacheMode/count/days preferences (default: date, 60 days).
Apply the bound when reading instead of writing, so a preference change
takes effect without a refetch; the full parse cache stays intact so
fetch-more can page beyond the bound. Thread the window through
load/saveFeedsToFile and update tests and task docs.
2026-08-12 15:41:22 -04:00
4127fd1181 bump VERSION to 0.6.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 28s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 12:57:57 -04:00
6c99b96b12 fix(feed): eliminate event-loop blocking during feed refresh
The boot refresh blocked the UI for up to 195ms per sync block with 10
large feeds (500 episodes × 10KB descriptions), causing noticeable freezes
when navigating to Feed during startup.

Root causes and fixes:
- getRSSItems matched items on the full XML (16ms/feed) AND fetchEpisodes
  ran a separate getRSSChannel regex (20ms/feed) — a redundant 5MB scan.
  Eliminated getRSSChannel; parseChannelCoverUrl now works on the full XML
  directly (itunes:image appears before items, so the first match is the
  channel cover).
- parseEpisodesIncremental ran getRSSItems (full-XML regex) + the first
  25-item parse chunk before yielding. Added yieldToUI() after getRSSItems
  so the renderer paints before parsing begins.
- Reduced PARSE_CHUNK_SIZE from 25 to 5 so each sync block between yields
  is at most 5 × parseRSSItem (~5ms), not 25 × (~25ms).
- Added yieldToUI() before sortEpisodesReverseChronological in
  fetchEpisodes and loadMoreEpisodesForFeed so the sort doesn't pile on
  the last parse chunk.
- Added yieldToUI() after response.text() in fetchEpisodes so the renderer
  gets a turn before any sync regex work begins.

Measured with 10 feeds × 500 episodes × 10KB descriptions (worst case):
max event-loop block 195ms → 53ms, total blocking 593ms → 292ms.
2026-08-12 12:50:01 -04:00
acbaf2ed1c fix(download): keep .mp3 extension on the ffmpeg tag temp file
ffmpeg infers the muxer from the extension; '.tag' made every download's
tag step fail with 'Unable to choose an output format'.
2026-08-12 11:11:10 -04:00
e7ed89056e fix(now-playing): episode as title, podcast as artist on both paths
The card put the podcast name in the title slot ('Podcast — Episode'
prefix truncating on long names) with an empty artist slot for local
playback — the downloaded files carry no artist tag. Now:
- mediaTitle is the episode title only (UI + CLI); streams keep their
  artist from stream tags, matching Apple Podcasts' title/artist layout.
- downloads are tagged at completion (ffmpeg -c copy, atomic rename):
  title=episode, artist=podcast — verified on a real 72MB file in 1.5s.
2026-08-12 11:10:07 -04:00
b5432e3e5f fix(download): sibling cover via curl + episode image fallback
The download wrote its sibling .jpg with Bun fetch — which hangs in
compiled binaries, so shipped builds never produced offline cover art.
Now curl (same flags as the cover cache). Also falls back to the
episode's own image when the feed has no channel cover (URL-added
feeds like The Fifth Column). mpv --cover-art-auto=exact picks up the
same-basename .jpg (verified against mpv 0.41).
2026-08-12 10:59:06 -04:00
13664c3cec fix(cover+downloads): channel art parsing, episode image fallback, local playback
The Fifth Column (and any feed added by URL) had NO coverUrl — the RSS
parser never captured channel artwork, so Now Playing had nothing to show.
- rss-parser: parseChannelCoverUrl (<itunes:image href> / RSS2 <image><url>);
  parseRSSFeed sets it on the Podcast.
- feed store: fetchEpisodes returns the channel cover; subscribe + both
  refresh paths backfill coverUrl when missing (no second fetch).
- useAudio + CLI --play: cover resolves feed.podcast.coverUrl ?? episode.imageUrl,
  so episodes without channel art still get their own image.
- useAudio play/load/switchBackend: prefer the downloaded file
  (getDownloadedFilePath) over the stream URL — downloaded episodes now
  play from disk.
Verified: Fifth Column episode, cold cache -> cover-art-files set at load
-> albumart track present.
2026-08-12 10:52:54 -04:00
0b4a551744 fix(feed): unblock UI during fetch-more and drop indicator label text
loadMoreEpisodesForFeed's hot-cache path ran fully synchronously (no
await between getFeed and setFeeds), so the isLoadingMore spinner never
painted and keyboard input froze through every feed in a loadMoreAllFeeds
batch. Add await yieldToUI() before the setFeeds so the renderer gets a
macrotask turn to paint and process input between feeds.

Drop the "Loading…/Refreshing…/Downloading N" label text from the global
activity indicator — render the braille spinner only, per request.
2026-08-12 10:47:02 -04:00
ed75c2fff7 fix(cover): await bounded cover fetch on cold-cache play
Prefetch alone can't cover every first play (any surface, feeds outside
the FeedPage focus window, quick plays) — the live session showed
cover-art-files empty with zero albumart tracks on a cold-cache play.
play() now serves the disk cache synchronously and, on a miss, awaits the
single-flight fetch with a 1.2s cap (covers fetch in ~300ms typically);
past the cap it plays bare and warms the cache. Verified: cold-cache play
-> cover present at load -> albumart track.
2026-08-12 10:44:20 -04:00
bd7d988741 fix(cover): art reaches Now Playing on first play (restore + cold cache)
Two gaps left cover art missing for the most common play paths:
- Boot-restore preloaded the episode with cachedCoverPath??undefined racing
  the fire-and-forget prefetch; a cold preload + fast-path play re-applied
  art via video-add, which produces a NON-albumart track (verified) that
  mpv's Now Playing artwork logic ignores. load() now awaits the bounded
  fetch (covers ~300ms, 8s cap) so the cover is present at load-time.
- Removed the dead video-add re-apply (fast path + addCoverArt method +
  interface) and the play() late-add fallback; a cold-cache play now
  prefetches for next time instead.
- FeedPage prefetches covers for the focus window (single-flight, cached)
  so ordinary plays land on a warm cache.
Verified: preload with awaited cover -> albumart track present (2 tracks).
2026-08-12 10:22:49 -04:00
deac6081ca feat(feed): bound feed lifecycle to 30-day window with nonblocking refresh
Persisted feeds keep only episodes from the last 30 days (plus completed
downloads); older episodes live in volatile memory and survive refreshes
via union merge, with per-feed in-memory caches capped at 500. Refresh
batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no
Promise.all barrier), config.json writes are trailing-edge debounced
(250ms, immediate flushPendingSave for unsubscribes), and cold
fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store
powers a global top-right indicator covering refresh, fetch-more,
subscribe, search, and downloads.

Also includes the in-flight incremental RSS parsing (chunked with
event-loop yields) and refresh spinner work this tree already carried.
2026-08-12 10:13:20 -04:00
e09ae15e32 bump VERSION to 0.5.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5m7s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-12 08:10:12 -04:00
33af131b77 revert(macos): drop PodTui.app / bundled-mpv Now Playing attribution
The pinned mpv binary breaks on brew ffmpeg major drift (libavcodec.62 vs
.63 shipped a dead player); making it self-contained costs ~100MB for an
app icon. Removing the whole machinery: build.ts app-bundle assembly,
audio-player bundled-binary resolver + probe (spawns PATH mpv again),
CI mpv install + bundle smoke checks, AppIcon.icns, README note.
Cover-art staging (curl + --cover-art-files) is unrelated and stays.
2026-08-11 23:09:44 -04:00
61 changed files with 3569 additions and 1167 deletions

View File

@@ -1,11 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
env: {
es2022: true,
node: true,
},
ignorePatterns: ["dist", "node_modules"],
}

View File

@@ -50,10 +50,7 @@ jobs:
- name: Install fftw (cavacore build dependency)
run: |
if uname -s | grep -qi darwin; then
# mpv is required for the release bundle: build.ts copies it into
# PodTui.app (signed with the podtui bundle identifier) so macOS
# Now Playing shows the PodTui icon instead of a blank placeholder.
brew install fftw mpv
brew install fftw
else
sudo apt-get update
sudo apt-get install -y libfftw3-dev
@@ -79,16 +76,6 @@ jobs:
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
cd "$SMOKE_DIR"
./podtui-*/podtui --version
# macOS tarballs must ship PodTui.app with a working bundled mpv
# carrying the podtui bundle identifier — otherwise Now Playing
# attribution silently regresses to a blank icon.
if [ "${{ matrix.plat }}" = "darwin" ]; then
MPV=./podtui-*/PodTui.app/Contents/MacOS/mpv
test -x $MPV || { echo "PodTui.app missing bundled mpv"; exit 1; }
$MPV --version >/dev/null || { echo "bundled mpv does not launch"; exit 1; }
codesign -dvv $MPV 2>&1 | grep -q "Identifier=com.mikefreno.podtui" \
|| { echo "bundled mpv lacks podtui signing identifier"; exit 1; }
fi
- name: Upload artifact
uses: actions/upload-artifact@v6

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.harness/
.ralpi
notes.md
# pygienium run-state and check artifacts
.pygienium/

View File

@@ -15,7 +15,7 @@
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
### Linting
- `bun run lint` - Run ESLint with TypeScript rules
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
## Code Style Guidelines

View File

@@ -54,14 +54,6 @@ Linux (arm64/x64). Pick whichever fits your platform.
brew install mikefreno/tap/podtui
```
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
through a copy of mpv that lives **inside the bundle**, so macOS attributes
the Now Playing session to PodTui — the Control Center / lock-screen entry
shows the PodTui name and icon, and podcast cover art as its artwork —
rather than a blank placeholder for an unbundled binary. Installers can drop
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
### 2. Standalone tarball (all platforms)
Grab `podtui-<platform>-<arch>.tar.gz` from the latest

Binary file not shown.

124
build.ts
View File

@@ -131,130 +131,6 @@ if (COMPILE) {
}
}
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
// Playing session to PodTui — the source-app icon + name in Control
// Center / lock screen — instead of a blank placeholder for an
// unbundled binary. AudioPlayer's resolver prefers this sibling.
if (platform === "darwin") {
const appRoot = join(tarRoot, "PodTui.app");
const macosDir = join(appRoot, "Contents", "MacOS");
const resDir = join(appRoot, "Contents", "Resources");
mkdirSync(macosDir, { recursive: true });
mkdirSync(resDir, { recursive: true });
copyFileSync(outfile, join(macosDir, "podtui"));
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
}
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
const mpvPath =
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
if (mpvPath) {
copyFileSync(mpvPath, join(macosDir, "mpv"));
} else {
// A darwin release tarball without a bundled mpv silently ships
// without Now Playing attribution (blank icon). Fail loudly so CI
// can't produce it — the runner must have mpv installed.
console.error(
"Error: mpv not found in PATH — PodTui.app requires a bundled mpv for macOS Now Playing attribution (brew install mpv on the build machine)",
);
process.exit(1);
}
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
if (existsSync(icnsSrc)) {
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
} else {
console.warn(
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
);
}
// Version for the bundle comes from src/index.tsx (single source of
// truth — release.yml requires bumping it in the tag commit).
const srcIndex = await Bun.file(join("src", "index.tsx")).text();
const versionMatch = srcIndex.match(/const VERSION = "([^"]+)"/);
const bundleVersion = versionMatch?.[1];
if (!bundleVersion) {
console.error("Error: could not read VERSION from src/index.tsx");
process.exit(1);
}
Bun.write(
join(appRoot, "Contents", "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>PodTui</string>
<key>CFBundleDisplayName</key>
<string>PodTui</string>
<key>CFBundleIdentifier</key>
<string>com.mikefreno.podtui</string>
<key>CFBundleExecutable</key>
<string>podtui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleShortVersionString</key>
<string>${bundleVersion}</string>
<key>CFBundleVersion</key>
<string>${bundleVersion}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
</dict>
</plist>
`,
);
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
// ID cert for release builds); default ad-hoc.
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
const sign = Bun.spawnSync([
"codesign",
"--force",
"--deep",
"-s",
signIdentity,
appRoot,
]);
if (sign.exitCode !== 0) {
console.warn(
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
);
}
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
// resolves the Now Playing client from the registering process's
// code-signing identifier — without an explicit --identifier codesign
// stamps "mpv" (its basename) and the audio center shows a blank
// placeholder. Must run after the bundle sign above (a later bundle
// re-seal would re-derive the basename identifier).
const signMpv = Bun.spawnSync([
"codesign",
"--force",
"-s",
signIdentity,
"--identifier",
"com.mikefreno.podtui",
join(macosDir, "mpv"),
]);
if (signMpv.exitCode !== 0) {
console.warn(
`Warning: nested mpv signing failed (${signMpv.stderr
.toString()
.trim()}) — Now Playing attribution won't work`,
);
}
console.log(`App bundle: ${appRoot}`);
}
const tar = Bun.spawnSync([
"tar",
"-czf",

BIN
bun.lockb

Binary file not shown.

View File

@@ -18,9 +18,6 @@
},
"devDependencies": {
"@types/bun": "latest",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {

View File

@@ -74,6 +74,77 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
return undefined
}
/** Extract the `<item>` blocks from an RSS document. Matches items directly
* on the full XML string — scoping to <channel> first is a redundant 5MB
* regex pass that doubles parse cost with no practical benefit (well-formed
* RSS has no items outside <channel>). */
export const getRSSItems = (xml: string): string[] => {
return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
}
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
* `<image><url>`. Works on the full XML — channel-level tags precede
* <item> blocks in RSS, so the first match is the channel image. */
export const parseChannelCoverUrl = (xml: string): string | undefined => {
const itunesHref = getAttr(xml, "itunes:image", "href")
if (itunesHref) return itunesHref
const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
return url?.trim() || undefined
}
/** Parse a single `<item>` into an Episode. Exported so the feed store can
* parse large feeds in bounded chunks (yielding to the event loop between
* chunks) instead of one synchronous block. */
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
const epDescription = cleanField(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
const audioUrl = enclosure?.[1] ?? ""
const fileSizeStr = getAttr(item, "enclosure", "length")
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
const mimeType = getAttr(item, "enclosure", "type") || undefined
const durationRaw = getTagValue(item, "itunes:duration")
const duration = parseDuration(durationRaw)
const episodeNumRaw = getTagValue(item, "itunes:episode")
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
const seasonNumRaw = getTagValue(item, "itunes:season")
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
// Episode image (itunes:image has href attribute)
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = {
id: `${feedUrl}#${index}`,
podcastId: feedUrl,
title: epTitle,
description: epDescription,
audioUrl,
duration,
pubDate,
}
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
if (episodeType) ep.episodeType = episodeType
if (explicit !== undefined) ep.explicit = explicit
if (imageUrl) ep.imageUrl = imageUrl
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
if (mimeType) ep.mimeType = mimeType
return ep
}
/** Parse a full RSS document (channel metadata + all episodes). The sync
* whole-feed variant — callers that parse potentially huge feeds on a UI
* thread should prefer the store's chunked incremental parse instead. */
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
@@ -81,58 +152,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
const episodes = items.map((item, index) => {
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
const epDescription = cleanField(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
// Audio URL + file size + MIME type from <enclosure>
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
const audioUrl = enclosure?.[1] ?? ""
const fileSizeStr = getAttr(item, "enclosure", "length")
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
const mimeType = getAttr(item, "enclosure", "type") || undefined
// Duration from <itunes:duration>
const durationRaw = getTagValue(item, "itunes:duration")
const duration = parseDuration(durationRaw)
// Episode & season numbers
const episodeNumRaw = getTagValue(item, "itunes:episode")
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
const seasonNumRaw = getTagValue(item, "itunes:season")
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
// Episode type & explicit
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
// Episode image (itunes:image has href attribute)
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = {
id: `${feedUrl}#${index}`,
podcastId: feedUrl,
title: epTitle,
description: epDescription,
audioUrl,
duration,
pubDate,
}
// Only set optional fields if present
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
if (episodeType) ep.episodeType = episodeType
if (explicit !== undefined) ep.explicit = explicit
if (imageUrl) ep.imageUrl = imageUrl
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
if (mimeType) ep.mimeType = mimeType
return ep
})
const items = getRSSItems(xml)
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
return {
id: feedUrl,
@@ -142,6 +163,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
feedUrl,
lastUpdated,
isSubscribed: true,
coverUrl: parseChannelCoverUrl(channel),
episodes,
}
}

View File

@@ -0,0 +1,246 @@
/**
* Shared list-row and preview components for the Feed and My Shows pages.
*
* Both pages render the same episode rows (marker + title, optional subtitle
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
* hovered-episode / fetch-more preview panes; the pages differ only in the
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
* so the previously 3-4-level-nested render blocks run as flat named
* components.
*
* Anything that can change at runtime arrives as a signal getter: Solid
* components do not re-render, so only props that are called inside the
* component's own JSX stay reactive (focus, selection, download state).
*/
import { Show } from "solid-js";
import { format } from "date-fns";
import type { RGBA } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { NF_ICONS } from "@/utils/nerd-fonts";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import type { Episode } from "@/types/episode";
// ── formatting helpers ──────────────────────────────────────────────────────
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
export const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
// ── EpisodeRow ──────────────────────────────────────────────────────────────
export function EpisodeRow(props: {
/** The episode this row renders. */
episode: Episode;
/** Optional second line under the title (podcast/show name). */
subtitle?: () => string | undefined;
/** For index signal (row position). */
index: () => number;
/** Focused row index in this list (-1 while the Fetch More row is
* focused, so no episode row draws the cursor). */
focused: () => number;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
/** Whether this episode is selection-marked. */
selected: () => boolean;
downloadLabel: () => string;
downloadColor: () => RGBA;
marker: () => string;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
<text wrapMode="none" truncate fg={fg()}>
{props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
{props.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the 50%
current pane fits it in full for typical names, and truncate
keeps the row one line tall either way */}
<Show when={props.subtitle?.()}>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.subtitle?.()}
</text>
</box>
</Show>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
{formatDate(props.episode.pubDate)}
</text>
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
{formatDuration(props.episode.duration)}
</text>
<Show when={props.selected()}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
</box>
);
}
// ── FetchMoreRow ────────────────────────────────────────────────────────────
export function FetchMoreRow(props: {
/** Row index of the Fetch More button within the list. */
index: () => number;
/** Focused row index. */
focused: () => number;
/** True while the Fetch More row itself is focused. */
onMore: () => boolean;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
isLoadingMore: () => boolean;
nerd: boolean;
marker: () => string;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(props.onMore);
const bg = () =>
props.index() === props.focused() && props.active()
? theme.primary
: props.index() === props.focused()
? theme.border
: undefined;
const fg = () =>
props.index() === props.focused() && props.active()
? theme.surface
: props.index() === props.focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
{props.nerd && (
<text fg={fg()}>{NF_ICONS.more}</text>
)}
<Show
when={!props.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={fg()}>[Fetch More]</text>
</Show>
</box>
);
}
// ── EpisodePreview ──────────────────────────────────────────────────────────
export function EpisodePreview(props: {
episode: () => Episode;
/** Optional line under the meta row (podcast/show name). */
subtitle?: () => string | undefined;
author: () => string | undefined;
downloadLabel: () => string;
downloadColor: () => RGBA;
/** Page-specific action-hint line. */
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
{props.episode().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
</Show>
</box>
<Show when={props.subtitle?.()}>
<text fg={muted()}>{props.subtitle?.()}</text>
</Show>
<Show when={props.author()}>
<text fg={muted()}>by {props.author()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{props.episode().description?.slice(0, 400) ?? "No description available."}
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
// ── FetchMorePreview ────────────────────────────────────────────────────────
export function FetchMorePreview(props: {
isLoadingMore: () => boolean;
fetchMoreMode: () => string;
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
manualText: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{props.isLoadingMore()
? "Loading the next batch of episodes…"
: props.fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: props.manualText()}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
);
}

View File

@@ -0,0 +1,35 @@
import { Show } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useSearchStore } from "@/stores/search";
import { useDownloadStore } from "@/stores/download";
import { useActivityStore } from "@/stores/activity";
import { LoadingIndicator } from "@/components/LoadingIndicator";
/**
* GlobalActivityIndicator — one global top-right signal that ANY feed
* refresh, fetch-more, subscribe fetch, search, or download is in flight.
* Per-page spinners are unchanged; this overlays the content row and status
* bar as a single app-wide "something is happening" indicator.
*/
export function GlobalActivityIndicator() {
const feedStore = useFeedStore();
const searchStore = useSearchStore();
const downloadStore = useDownloadStore();
const activity = useActivityStore();
/** True while any tracked activity is in flight */
const isActive = () =>
feedStore.isLoadingFeeds() ||
feedStore.isLoadingMore() ||
searchStore.isSearching() ||
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
activity.isActive();
return (
<Show when={isActive()}>
<box position="absolute" top={0} right={0} paddingRight={1}>
<LoadingIndicator />
</box>
</Show>
);
}

View File

@@ -63,6 +63,9 @@ export type PaneRowProps = {
/** Number of visible columns. `3` (default) = parent|current|preview;
* `2` = parent|current (preview omitted, current grows to fill). */
panes?: 2 | 3;
/** Which sides of the current column's border render. Defaults to
* `["left", "right"]` (the standard focused-list frame). */
currentBorder?: boolean | BorderSides[];
};
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -181,6 +184,9 @@ export function PaneRow(props: PaneRowProps) {
? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current,
);
const currentBorder = createMemo<boolean | BorderSides[]>(
() => props.currentBorder ?? ["left", "right"],
);
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
@@ -197,7 +203,7 @@ export function PaneRow(props: PaneRowProps) {
grow={currentGrow()}
label={() => ""}
content={currentContent}
border={["left", "right"]}
border={currentBorder()}
scrollFocused={() => focused()}
/>
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}

View File

@@ -27,6 +27,7 @@ import { TABS } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch";
import { TabListPane } from "@/components/TabPanel";
import { PaneRow } from "@/components/PaneRow";
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
export function Shell() {
const theme = useTheme();
@@ -168,7 +169,6 @@ export function Shell() {
nav.backspaceCommand();
return;
}
// printable char
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
evt.preventDefault();
nav.appendCommand(evt.name);
@@ -396,6 +396,8 @@ export function Shell() {
theme={t as any}
/>
</Show>
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
<GlobalActivityIndicator />
</box>
);
}

View File

@@ -10,7 +10,8 @@ import {
generateSyntax,
generateSubtleSyntax,
} from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { resolveTerminalTheme } from "../utils/theme";
import { getCustomThemes } from "../utils/custom-themes";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper";
import {
@@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function init() {
resolveSystemTheme();
loadThemes()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
@@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("active", "catppuccin");
})
.finally(() => {
// Only set ready if not waiting for system theme
if (store.active !== "system") {
setStore("ready", true);
}

View File

@@ -16,7 +16,6 @@ import { onCleanup } from "solid-js";
import {
cachedCoverPath,
fetchCoverArt,
prefetchCoverArt,
} from "../utils/cover-art";
import {
createAudioBackend,
@@ -57,6 +56,7 @@ import {
import type { Episode, Progress } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { useDownloadStore } from "../stores/download";
import { useFeedStore } from "../stores/feed";
export interface AudioControls {
@@ -297,6 +297,34 @@ function stopPolling(): void {
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
* call site's latency budget:
* "cache" — disk cache only (sync): resume paths must never wait on the
* network, so a miss plays artless and warms for next time.
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
* at file LOAD, but a slow cover server must not stall audio.
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
* while feeds/progress load anyway, so the wait is free and the
* cover must be present when the file loads.
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
* nothing on a warm cache. */
async function resolveCoverArt(
coverUrl: string | undefined,
mode: "cache" | "bounded" | "await",
): Promise<string | null> {
if (!coverUrl) return null;
if (mode === "cache") return cachedCoverPath(coverUrl);
if (mode === "bounded") {
const cached = cachedCoverPath(coverUrl);
if (cached) return cached;
return Promise.race([
fetchCoverArt(coverUrl),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
]);
}
return fetchCoverArt(coverUrl);
}
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
@@ -316,12 +344,20 @@ async function play(episode: Episode): Promise<void> {
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Cover art must NEVER gate playback (it was a curl subprocess blocking
// play() by up to 8s). Serve the disk-cached file synchronously when it
// exists; on a miss, start playback bare and fetch in the background —
// the backend applies late art at runtime (mpv video-add).
const coverUrl = feed?.podcast.coverUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
// Play the downloaded file when present (offline + no network stalls);
// otherwise stream. Cover resolves to the feed art, falling back to the
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
// Cover art only applies at file LOAD (the runtime video-add fallback
// never becomes an albumart track), so a cold-cache play must wait for
// the fetch or play artless. Serve the disk cache synchronously; on a
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
// past the 1.2s cap, play bare and let the fetch warm the cache.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"bounded",
);
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
@@ -330,24 +366,14 @@ async function play(episode: Episode): Promise<void> {
startPos = savedProgress.position;
}
await b.play(episode.audioUrl, {
await b.play(url, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
});
if (coverUrl && !coverArtPath) {
fetchCoverArt(coverUrl)
.then((path) => {
if (path && currentEpisode()?.id === episode.id) {
b.addCoverArt(path).catch(() => {});
}
})
.catch(() => {});
}
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
@@ -423,21 +449,27 @@ async function load(episode: Episode): Promise<void> {
// fills its demuxer cache while parked, so the user's first Play flips
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
// — a failed preload just makes the first play take the cold path.
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
if (episode.audioUrl && backend) {
const coverUrl = feed?.podcast.coverUrl;
if (coverUrl) prefetchCoverArt(coverUrl);
// The preload must carry the cover AT LOAD: cover-art-files only
// applies when the file loads, and the runtime video-add fallback
// never becomes an albumart track (verified). Restore already waits
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
// 8s worst case) is free. Falls back to the episode's own image when
// the feed has no channel cover.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"await",
);
const backendSnap = backend;
backendSnap
.preload(episode.audioUrl, {
.preload(url, {
volume: volume(),
speed: storeSpeed || speed(),
startPosition: pos > 0 ? pos : undefined,
mediaTitle: podcastTitle
? `${podcastTitle}${episode.title}`
: episode.title,
coverArtPath: coverUrl
? (cachedCoverPath(coverUrl) ?? undefined)
: undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
})
.catch(() => {});
}
@@ -602,15 +634,17 @@ async function switchBackend(name: BackendName): Promise<void> {
.feeds()
.find((f) => f.podcast.id === ep.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const coverUrl = feed?.podcast.coverUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
await backend.play(ep.audioUrl, {
const url =
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? ep.imageUrl,
"cache",
);
await backend.play(url, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
mediaTitle: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);

View File

@@ -1,7 +1,7 @@
import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode"
const VERSION = "0.5.1";
const VERSION = "0.6.2";
interface CliArgs {
version: boolean;
@@ -42,7 +42,6 @@ if (cliArgs.version) {
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {
@@ -186,12 +185,16 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
// Stage the podcast cover so the system Now Playing shows
// artwork (mpv --cover-art-files), like the UI path does.
const coverArtPath = feedResult.podcast.coverUrl
? await fetchCoverArt(feedResult.podcast.coverUrl)
// artwork (mpv --cover-art-files), like the UI path does. Falls
// back to the episode's own image when the feed has no channel
// cover (URL-added feeds).
const coverUrl =
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
const coverArtPath = coverUrl
? await fetchCoverArt(coverUrl)
: null
await backend.play(episodeResult.audioUrl, {
mediaTitle: `${feedResult.podcast.title}${episodeResult.title}`,
mediaTitle: episodeResult.title,
coverArtPath: coverArtPath ?? undefined,
})
console.log("Playback started (use the UI to control)")

View File

@@ -20,8 +20,8 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-j
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -32,14 +32,19 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
} from "@/components/EpisodeList";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const FeedPaneCount = 1;
@@ -63,6 +68,22 @@ function FeedPage() {
() => feedStore.getAllEpisodesChronological() as EpItem[],
);
// ── Cover warm-up ────────────────────────────────────────────────────────
// Prefetch covers for episodes around the focus (plus the top of the
// list) so plays land on a warm cache: cover-art-files only applies at
// file load, and there is no working runtime fallback. Single-flight +
// cache short-circuit keep repeat runs cheap (hits resolve immediately).
createEffect(() => {
const list = episodes();
const focusIdx = focusedEpIdx();
const start = Math.max(0, focusIdx - 10);
const end = Math.min(list.length, focusIdx + 11);
for (let i = start; i < end; i++) {
const item = list[i];
if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl);
}
});
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
@@ -70,7 +91,6 @@ function FeedPage() {
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0);
const focusedRow = () =>
@@ -86,7 +106,6 @@ function FeedPage() {
const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
const moreRef = useScrollIntoView(() => focusedOnMore());
const ensureFocus = () => {
if (rowCount() > 0 && focus() >= rowCount())
@@ -112,12 +131,6 @@ function FeedPage() {
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -212,19 +225,6 @@ function FeedPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => `Feed · ${episodes().length}`;
@@ -236,7 +236,7 @@ function FeedPage() {
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<box padding={1} alignItems="center">
<Show
when={feedStore.isLoadingFeeds()}
fallback={
@@ -251,111 +251,41 @@ function FeedPage() {
}
>
<For each={episodes()}>
{(item, index) => {
const fi = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === fi());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), fi(), isActive())}
>
{index() === fi() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), fi(), isActive())}
>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the
50% current pane fits it in full for typical names, and
truncate keeps the row one line tall either way */}
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={index() === fi() ? theme.surface : theme.textSecondary}
>
{item.feed.customName || item.feed.podcast.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : theme.info}
>
{formatDate(item.episode.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : muted()}
>
{formatDuration(item.episode.duration)}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
{(item, index) => (
<EpisodeRow
episode={item.episode}
subtitle={() => item.feed.customName || item.feed.podcast.title}
index={index}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(item.episode.id)}
downloadLabel={() => downloadLabel(item.episode.id)}
downloadColor={() => downloadColor(item.episode.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 0);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
<Show when={feedStore.isLoadingFeeds()}>
<box paddingLeft={2} paddingTop={1}>
<box alignItems="center" paddingTop={1}>
<LoadingIndicator label="Refreshing…" />
</box>
</Show>
@@ -363,23 +293,23 @@ function FeedPage() {
);
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const episodeHint = (item: EpItem) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
? " · D: delete"
: ""
} · space: select · h back`;
const previewContent = () => (
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes across all feeds (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes across all feeds (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -391,46 +321,16 @@ function FeedPage() {
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(item().episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· space: select · h back
</text>
</box>
<EpisodePreview
episode={() => item().episode}
subtitle={() =>
item().feed.customName || item().feed.podcast.title
}
author={() => item().feed.podcast.author}
downloadLabel={() => downloadLabel(item().episode.id)}
downloadColor={() => downloadColor(item().episode.id)}
hint={() => episodeHint(item())}
/>
)}
</Show>
</Show>

View File

@@ -15,11 +15,11 @@
*/
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import type { RGBA } from "@opentui/core";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -31,16 +31,208 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode, DownloadedEpisode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
formatDate,
} from "@/components/EpisodeList";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
// ── render components ────────────────────────────────────────────────────────
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
// preview panes are My Shows-specific; episode rows/previews are shared with
// the Feed page (see EpisodeList.tsx).
/** A subscribed-show row (depth 0). */
function ShowRow(props: {
feed: Feed;
title: string;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
wlScope: () => boolean;
wlInList: () => boolean;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{isFocused() ? props.marker() : " "}</text>
<text fg={fg()}>{props.title}</text>
<text fg={isFocused() ? theme.surface : muted()}>
({props.feed.episodes.length})
</text>
<Show when={props.wlScope()}>
<text
fg={
isFocused()
? theme.surface
: props.wlInList()
? theme.warning
: muted()
}
>
{props.wlInList() ? "●" : "○"}
</text>
</Show>
</box>
);
}
/** An unsubscribed-show download row (depth 0, below the shows list). */
function UnsubscribedRow(props: {
d: DownloadedEpisode;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
downloadLabel: () => string;
downloadColor: () => RGBA;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
<text wrapMode="none" truncate fg={fg()}>
{props.d.episodeTitle ?? props.d.episodeId}
</text>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.d.podcastTitle ?? props.d.feedId}
</text>
</box>
</box>
);
}
/** Depth-0 preview: the hovered subscribed show. */
function ShowPreview(props: {
show: () => Feed;
title: () => string;
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const show = props.show;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{props.title()}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
/** Depth-0 preview: the hovered unsubscribed-show download. */
function UnsubscribedPreview(props: {
d: () => DownloadedEpisode;
downloadLabel: () => string;
downloadColor: () => RGBA;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const d = props.d;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
</Show>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<text fg={muted()}>
Downloaded from episode search the show is not subscribed.
</text>
<box height={1} />
<text fg={muted()}>enter: play · D: delete download · h: back</text>
</box>
);
}
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
@@ -67,7 +259,6 @@ export function MyShowsPage() {
// entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads();
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
const depth0Count = () => shows().length + unsubs().length;
const focusedShowIdx = () =>
@@ -107,7 +298,6 @@ export function MyShowsPage() {
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
@@ -121,7 +311,6 @@ export function MyShowsPage() {
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const moreRef = useScrollIntoView(() => focusedOnMore());
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
@@ -155,12 +344,6 @@ export function MyShowsPage() {
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -323,14 +506,6 @@ export function MyShowsPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () =>
@@ -349,18 +524,21 @@ export function MyShowsPage() {
{(feed, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
const focused = () => index() === lf();
const fg = () =>
focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
backgroundColor={focused() ? theme.border : undefined}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={fg()}>{focused() ? marker() : " "}</text>
<text fg={fg()}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
@@ -385,51 +563,28 @@ export function MyShowsPage() {
}
>
<For each={shows()}>
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
const wlScope =
app.state().preferences.autoDownloadScope === "whitelist";
const wlInList = (
app.state().preferences.autoDownloadWhitelist ?? []
).includes(feed.id);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
<Show when={wlScope}>
<text
fg={
index() === lf()
? theme.surface
: wlInList
? theme.warning
: muted()
}
>
{wlInList ? "●" : "○"}
</text>
</Show>
</box>
);
}}
{(feed, index) => (
<ShowRow
feed={feed}
title={showTitle(feed)}
index={index}
focused={focusedShowIdx}
active={isActive}
marker={marker}
wlScope={() =>
app.state().preferences.autoDownloadScope === "whitelist"
}
wlInList={() =>
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
feed.id,
)
}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
<Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}>
@@ -438,62 +593,21 @@ export function MyShowsPage() {
</text>
</box>
<For each={unsubs()}>
{(d, index) => {
// Rows continue after the shows list.
const rowIdx = () => shows().length + index();
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => rowIdx() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(rowIdx(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(rowIdx(), lf(), isActive())}
>
{rowIdx() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(rowIdx(), lf(), isActive())}
>
{d.episodeTitle ?? d.episodeId}
</text>
<Show when={downloadLabel(d.episodeId)}>
<text
flexShrink={0}
fg={downloadColor(d.episodeId)}
>
{downloadLabel(d.episodeId)}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={
rowIdx() === lf()
? theme.surface
: theme.textSecondary
}
>
{d.podcastTitle ?? d.feedId}
</text>
</box>
</box>
);
}}
{(d, index) => (
<UnsubscribedRow
d={d}
index={() => shows().length + index()}
focused={() => nav.depthFocus(0)}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
downloadColor={() => downloadColor(d.episodeId)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(shows().length + index(), 0);
}}
/>
)}
</For>
</Show>
</Show>
@@ -509,98 +623,37 @@ export function MyShowsPage() {
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), lf(), isActive())}
>
{index() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), lf(), isActive())}
>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : theme.info}
>
{formatDate(ep.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : muted()}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text flexShrink={0} fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
{(ep, index) => (
<EpisodeRow
episode={ep}
index={index}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
downloadLabel={() => downloadLabel(ep.id)}
downloadColor={() => downloadColor(ep.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
/>
)}
</For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(
episodes().length,
focusedRow(),
isActive(),
)}
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
</Show>
</Show>
@@ -608,6 +661,30 @@ export function MyShowsPage() {
);
// ── preview pane ───────────────────────────────────────────────────────────
const episodeHint = (epId: string) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
? " · D: delete"
: ""
}${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
} · space: select · h: back`;
const showHint = (show: Feed) =>
`enter/l: open · h: back · x: unsubscribe${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
}`;
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered unsubscribed-show download, else the
@@ -624,86 +701,34 @@ export function MyShowsPage() {
}
>
{(show) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>
{show().episodes.length} episodes
</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ??
"No description."}
</text>
<box height={1} />
<text fg={muted()}>
enter/l: open · h: back · x: unsubscribe
{app.state().preferences.autoDownloadScope ===
"whitelist"
? (app.state().preferences.autoDownloadWhitelist ??
[]
).includes(show().id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}
</text>
</box>
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<text fg={theme.textSecondary}>
{d().podcastTitle ?? d().feedId}
</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>
{formatDate(new Date(d().pubDate!))}
</text>
</Show>
<Show when={downloadLabel(d().episodeId)}>
<text fg={downloadColor(d().episodeId)}>
{downloadLabel(d().episodeId)}
</text>
</Show>
</box>
<text fg={muted()}>
Downloaded from episode search the show is not
subscribed.
</text>
<box height={1} />
<text fg={muted()}>
enter: play · D: delete download · h: back
</text>
</box>
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes for this show (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes for this show (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -714,53 +739,19 @@ export function MyShowsPage() {
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ?? "No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}{" "}
· space: select · h: back
</text>
</box>
)}
{(ep) => (
<EpisodePreview
episode={() => ep()}
author={() => selectedShow()?.podcast.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() => episodeHint(ep().id)}
/>
)}
</Show>
</Show>
</Show>
</>
);
</>
);
return (
<PaneRow

View File

@@ -133,6 +133,7 @@ export function PlayerPage() {
currentLabel="Player"
panes={2}
focused={isActive}
currentBorder={["left"]}
/>
);
}

View File

@@ -45,6 +45,10 @@ export function ProgressBar() {
padding={0}
flexDirection="row"
gap={0}
// The bar's block-char texts are non-selectable below: a drag
// over the bar is a seek gesture, not a text selection — otherwise
// mouse-up would copy █/░ to the clipboard via the global
// selection handler.
ref={(el) => {
bar = el;
}}
@@ -58,9 +62,11 @@ export function ProgressBar() {
}}
>
{playedChars() > 0 && (
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text>
<text fg={theme.primary} selectable={false}>
{"\u2588".repeat(playedChars())}
</text>
)}
<text fg={remainingColor}>
<text fg={remainingColor} selectable={false}>
{"\u2591".repeat(width() - playedChars())}
</text>
</box>

View File

@@ -77,30 +77,15 @@ function SearchPage() {
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
// ── input focusing ────────────────────────────────────────────────────────
// `inputFocused` is true while the query input is being typed in. The Shell
// router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true.
//
// The input's REAL focus is the source of truth for the flag:
// useInputFocusNav (the same hook the Settings forms use) flips
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
// and the renderable in lockstep. That matters when the user clicks OFF the
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
// event drops the flag, so the Shell router immediately resumes j/k/h
// instead of swallowing keys with no input to receive them — no more
// stuck "typing" state where Esc/j/k/s all do nothing.
//
// The depth stack still SEEDS the flag on transitions, since the query
// depth defaults to typing: re-entering depth 0 (h back from results, or a
// fresh mount) focuses the input; mounting at depth 1 (returning to the
// tab after a search) stays list-navigation — a stuck-on flag there would
// have the Shell yield j/k to a non-existent input. The depth STACK signal
// is also written by focus moves (setDepthFocus), so gate the seed on the
// depth VALUE via a memo: the effect must re-run only on an actual depth
// transition. Without the memo every j/k at the query depth re-focuses the
// input (undoing Escape), which keeps the recents list unreachable by
// keyboard.
// `inputFocused` tells the Shell router to yield keys to the query input.
// The input's REAL focus is the source of truth: useInputFocusNav flips
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
// input drops it and the router resumes j/k/h — no stuck "typing" state.
// The depth stack only SEEDS it on transitions (re-entering depth 0
// focuses the input; mounting at depth 1 stays list-nav), gated on the
// depth VALUE via a memo because setDepthFocus also writes the stack
// signal — without the memo every j/k at query depth re-focuses the input
// and strands the recents list.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();

View File

@@ -4,13 +4,15 @@
* driven by the Shell router via nav.action.
*
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
* • Auto Download — master toggle (default: off)
* • Auto Download Count — X most recent episodes per show (default: 2,
* any positive integer — type it in the editor)
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
* • Auto Download Whitelist — shown only when scope is "whitelist": search
* field over subscribed shows; suggestions toggle
* in/out with Space (j/k to move, Esc to browse).
* • Episode Cache Mode — date or count bound for the episode list
* (default: date)
* • Episode Cache Count — N most recent episodes when mode is count
* (default: 25)
* • Episode Cache Days — rolling N-day window when mode is date
* (default: 60)
*/
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
@@ -30,7 +32,7 @@ import {
import { on } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { TABS } from "@/utils/navigation";
import type { AutoDownloadScope, ThemeName } from "@/types/settings";
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
import type { Feed } from "@/types/feed";
import type { SettingItem } from "./types";
@@ -48,6 +50,14 @@ const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
{ value: "none", label: "None" },
{ value: "whitelist", label: "Whitelist" },
];
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
{ value: "date", label: "Date" },
{ value: "count", label: "Count" },
];
function cacheModeLabel(mode: EpisodeCacheMode): string {
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
}
function scopeLabel(scope: AutoDownloadScope): string {
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
@@ -205,6 +215,79 @@ export function usePreferencesItems(): SettingItem[] {
autoJumpToPlayer: !prefs().autoJumpToPlayer,
}),
},
{
id: "episodeCacheMode",
label: "Episode Cache Mode",
kind: "select",
display: () => cacheModeLabel(prefs().episodeCacheMode),
help: () =>
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below).\nCount: keep the N most recent episodes (see Cache Count below).\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const idx = CACHE_MODE_LABELS.findIndex(
(s) => s.value === prefs().episodeCacheMode,
);
const next =
CACHE_MODE_LABELS[
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
].value;
app.updatePreferences({ episodeCacheMode: next });
},
},
{
id: "episodeCacheCount",
label: "Episode Cache Count",
kind: "number",
display: () =>
prefs().episodeCacheMode === "count"
? `${prefs().episodeCacheCount} eps`
: "(date mode)",
help: () =>
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to /+1 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.max(1, prefs().episodeCacheCount + dir);
app.updatePreferences({ episodeCacheCount: next });
},
renderEditor: () => (
<NumberInputEditor
label="Episode Cache Count"
value={() => prefs().episodeCacheCount}
commit={(n) => {
app.updatePreferences({
episodeCacheCount: Math.max(1, n),
});
}}
/>
),
},
{
id: "episodeCacheDays",
label: "Episode Cache Days",
kind: "number",
display: () =>
prefs().episodeCacheMode === "date"
? `${prefs().episodeCacheDays} days`
: "(count mode)",
help: () =>
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to /+5 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.min(
365,
Math.max(1, prefs().episodeCacheDays + dir * 5),
);
app.updatePreferences({ episodeCacheDays: next });
},
renderEditor: () => (
<NumberInputEditor
label="Episode Cache Days"
value={() => prefs().episodeCacheDays}
commit={(n) => {
app.updatePreferences({
episodeCacheDays: Math.min(365, Math.max(1, n)),
});
}}
/>
),
},
{
id: "fetchMore",
label: "Fetch More",

View File

@@ -140,7 +140,6 @@ export function SettingsPage() {
function open() {
const d = depth();
if (d === 0) {
// drill into the focused section's items
const id = focusedSection().id;
nav.pushDepth({
kind: `settings:${id}`,
@@ -206,7 +205,6 @@ export function SettingsPage() {
function step(delta: number) {
const d = depth();
if (d === 2) {
// editor: j/k nudges the value
const it = editorItem();
if (it?.kind === "number" || it?.kind === "select")
it.cycle?.(delta as -1 | 1);
@@ -220,7 +218,6 @@ export function SettingsPage() {
pane: PaneId;
mode: NavMode;
}) => {
// ignore actions meant for non-center panes
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
const handler = PAGE_ACTIONS[data.action];

73
src/stores/activity.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Activity store for PodTUI
*
* Shared leak-proof activity counter: any store can surface "something is
* loading/downloading" to the global top-right indicator. beginActivity
* returns an end token that removes exactly THAT instance, so concurrent
* overlapping activities compose correctly; prefer track() so callers
* cannot strand the counter.
*/
import { createSignal } from "solid-js";
function createActivityStore() {
const [count, setCount] = createSignal(0);
const [labels, setLabels] = createSignal<string[]>([]);
/** Begin a tracked activity and return its end function. Every begin
* MUST be paired with exactly one call of the returned end (via the
* token); prefer track() so the pairing is automatic. Duplicate labels
* are allowed — each end removes exactly one instance (found by
* indexOf). */
const beginActivity = (label: string): (() => void) => {
setLabels((prev) => [...prev, label]);
setCount((c) => c + 1);
let ended = false;
return () => {
if (ended) return;
ended = true;
setLabels((prev) => {
const idx = prev.indexOf(label);
if (idx === -1) return prev;
const next = [...prev];
next.splice(idx, 1);
return next;
});
setCount((c) => Math.max(0, c - 1));
};
};
/** Track a promise: begin an activity, auto-end when it settles, and
* re-throw on rejection so the caller's error handling is untouched. */
const track = async <T,>(p: Promise<T>, label: string): Promise<T> => {
const end = beginActivity(label);
try {
return await p;
} finally {
end();
}
};
/** True while at least one activity is in flight */
const isActive = (): boolean => count() > 0;
return {
// State
count,
labels,
// Actions
beginActivity,
track,
// Getters
isActive,
};
}
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
export function useActivityStore() {
if (!activityStoreInstance) {
activityStoreInstance = createActivityStore();
}
return activityStoreInstance;
}

View File

@@ -45,6 +45,9 @@ const defaultPreferences: UserPreferences = {
autoJumpToPlayer: true,
fetchMoreMode: "auto",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
};
const defaultState: AppState = {

View File

@@ -9,14 +9,12 @@ import {
saveAudioNavToFile,
} from "../utils/app-persistence";
/** Source type for audio navigation */
export enum AudioSource {
FEED = "feed",
MY_SHOWS = "my_shows",
SEARCH = "search",
}
/** Audio navigation state */
export interface AudioNavState {
/** Current source type */
source: AudioSource;
@@ -28,14 +26,12 @@ export interface AudioNavState {
lastUpdated: Date;
}
/** Default navigation state */
const defaultNavState: AudioNavState = {
source: AudioSource.FEED,
currentIndex: 0,
lastUpdated: new Date(),
};
/** Create audio navigation store */
function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
@@ -56,12 +52,10 @@ function createAudioNavStore() {
init();
return {
/** Get current navigation state */
get state(): AudioNavState {
return navState();
},
/** Update source type */
setSource: (source: AudioSource, podcastId?: string) => {
setNavState((prev) => ({
...prev,
@@ -72,7 +66,6 @@ function createAudioNavStore() {
persist();
},
/** Move to next episode */
next: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -82,7 +75,6 @@ function createAudioNavStore() {
persist();
},
/** Move to previous episode */
prev: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -92,23 +84,19 @@ function createAudioNavStore() {
persist();
},
/** Reset to default state */
reset: () => {
setNavState(defaultNavState);
persist();
},
/** Get current index */
getCurrentIndex: (): number => {
return navState().currentIndex;
},
/** Get current source */
getSource: (): AudioSource => {
return navState().source;
},
/** Get current podcast ID */
getPodcastId: (): string | undefined => {
return navState().podcastId;
},

View File

@@ -85,7 +85,6 @@ function syncSubscriptionState(
}));
}
/** Create discover store */
export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
@@ -107,7 +106,6 @@ export function createDiscoverStore() {
const refresh = async () => {
setIsLoading(true);
try {
// Skip if cache is still fresh
const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions();
@@ -131,7 +129,6 @@ export function createDiscoverStore() {
cachedAt = now;
setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions();
} catch {
// Network failure — keep whatever we have (stale or empty)
@@ -140,7 +137,6 @@ export function createDiscoverStore() {
}
};
/** Get filtered podcasts by category */
const filteredPodcasts = () => {
const category = selectedCategory();
if (category === "all") {
@@ -155,7 +151,6 @@ export function createDiscoverStore() {
});
};
/** Subscribe to a podcast */
const subscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -168,7 +163,6 @@ export function createDiscoverStore() {
);
};
/** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -180,7 +174,6 @@ export function createDiscoverStore() {
);
};
/** Toggle subscription */
const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast?.isSubscribed) {
@@ -207,7 +200,6 @@ export function createDiscoverStore() {
};
}
/** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() {

View File

@@ -63,7 +63,62 @@ interface QueueItem {
episodeTitle: string;
}
/** Create download store */
// ── post-download decoration ─────────────────────────────────────────────────
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
* picks it up for Now Playing art when the local file plays (same basename,
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
* hangs in compiled binaries, so the shipped app never wrote this file. */
function writeCoverArt(filePath: string, coverUrl: string): void {
const dot = filePath.lastIndexOf(".");
if (dot <= 0) return;
const coverPath = filePath.slice(0, dot) + ".jpg";
Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
coverPath,
coverUrl,
])
.exited.catch(() => {});
}
/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing
* metadata for local playback is title=episode, artist=podcast — the source
* streams carry no usable tags and macOS composes "title - artist" from
* exactly these fields. Atomic: ffmpeg writes a temp file, then renames
* into place. */
function tagLocalFile(
filePath: string,
episode: Episode,
podcastTitle: string,
): void {
const tmp = `${filePath}.tag.mp3`;
Bun.spawn([
"ffmpeg",
"-y",
"-i",
filePath,
"-c",
"copy",
"-metadata",
`title=${episode.title}`,
"-metadata",
`artist=${podcastTitle}`,
tmp,
])
.exited.then(async (code) => {
if (code !== 0) return;
const { renameSync } = await import("node:fs");
renameSync(tmp, filePath);
})
.catch(() => {});
}
function createDownloadStore() {
const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode>
@@ -195,7 +250,6 @@ function createDownloadStore() {
}
}
/** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController();
abortControllers.set(item.episodeId, controller);
@@ -236,24 +290,23 @@ function createDownloadStore() {
error: null,
});
// Write the podcast cover beside the audio so mpv's
// --cover-art-auto=exact picks it up for Now Playing art.
const coverUrl = useFeedStore()
.feeds()
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
if (coverUrl && result.filePath) {
const dot = result.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = result.filePath.slice(0, dot) + ".jpg";
fetch(coverUrl)
.then(async (r) => {
if (!r.ok) return;
await Bun.write(
coverPath,
new Uint8Array(await r.arrayBuffer()),
);
})
.catch(() => {});
// Decorate the local file: cover art + ID3 tags (see the
// module-level helpers above) — the source streams carry neither.
// Cover falls back to the episode's own image when the feed has
// no channel cover (URL-added feeds).
const feedStore = useFeedStore();
const episode = feedStore.findEpisode(item.episodeId);
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
if (result.filePath && coverUrl) {
writeCoverArt(result.filePath, coverUrl);
}
if (result.filePath && episode) {
const podcastTitle =
feed?.podcast.title ??
downloads().get(item.episodeId)?.podcastTitle;
if (podcastTitle) {
tagLocalFile(result.filePath, episode, podcastTitle);
}
}
} else {
@@ -269,22 +322,18 @@ function createDownloadStore() {
processQueue();
}
/** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
};
/** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0;
};
/** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId);
};
/** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
@@ -301,7 +350,6 @@ function createDownloadStore() {
podcastFeedUrl?: string;
}
/** Start downloading an episode */
const startDownload = (
episode: Episode,
feedId: string,
@@ -365,7 +413,6 @@ function createDownloadStore() {
});
};
/** Cancel a download */
const cancelDownload = (episodeId: string): void => {
// Abort active download
const controller = abortControllers.get(episodeId);
@@ -386,7 +433,6 @@ function createDownloadStore() {
saveDownloads().catch(() => {});
};
/** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId);
if (dl?.filePath) {
@@ -432,7 +478,6 @@ function createDownloadStore() {
}
};
/** Get all downloads as an array */
const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values());
};
@@ -455,12 +500,10 @@ function createDownloadStore() {
});
};
/** Get the current queue */
const getQueue = (): QueueItem[] => {
return queue();
};
/** Get count of active downloads */
const getActiveCount = (): number => {
return activeCount();
};
@@ -485,7 +528,6 @@ function createDownloadStore() {
};
}
/** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() {

View File

@@ -10,15 +10,18 @@ import type { Podcast } from "../types/podcast";
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 { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import { savePodcastIndexCredentials } from "../utils/source-credentials";
import { mergeEpisodesBounded } from "../utils/episode-merge";
import {
episodeInWindow,
loadFeedsFromFile,
saveFeedsToFile,
loadSourcesFromFile,
saveSourcesToFile,
} from "../utils/feeds-persistence";
import { useActivityStore } from "./activity";
import { useDownloadStore } from "./download";
import { useAppStore } from "./app";
import { DownloadStatus } from "../types/episode";
@@ -33,18 +36,105 @@ const MAX_EPISODES_SUBSCRIBE = 20;
* the background refresh loop. */
const FETCH_TIMEOUT_MS = 20_000;
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
* batch. */
const FETCH_CONCURRENCY = 4;
/** Default minutes between automatic background feed refreshes. */
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
/** Max episodes parsed per chunk before yielding to the event loop — bounds
* the synchronous regex work per frame so one huge feed (or a batch of
* feeds) can't stall the renderer. */
const PARSE_CHUNK_SIZE = 5;
/** Hard ceiling on the in-memory full-parse cache per feed (newest first).
* The cache exists so fetch-more can page deeper without a refetch; without
* a ceiling a 5,000-episode archive pins tens of MB of Episode objects in
* RAM for the whole session (the old cache held EVERY parsed episode of
* every feed, contributing hundreds of MB for archive-heavy
* subscriptions). 1000 covers any realistic show's entire history —
* beyond it, hasMoreEpisodes flips false and the visible list is bounded
* by the user's cache preference as usual. */
const MAX_CACHED_EPISODES_PER_FEED = 1000;
/** Yield to the event loop (task queue) so the renderer can paint between
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
* bun:test fake timers trap those (feed-refresh/pagination tests run under
* vi.useFakeTimers and await refreshes, so a trapped yield would deadlock
* them); MessageChannel posts are real task-queue turns that fire in both
* environments. */
const yieldToUI = (): Promise<void> =>
new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
port1.onmessage = () => {
port1.close();
port2.close();
resolve();
};
port2.postMessage(null);
});
/** Parse all episodes from feed XML in bounded chunks, yielding to the event
* loop between chunks. The whole-feed sync `parseRSSFeed` would otherwise
* block the UI thread for the combined parse time of every feed in a
* refresh batch. */
const parseEpisodesIncremental = async (
xml: string,
feedUrl: string,
): Promise<Episode[]> => {
const items = getRSSItems(xml);
// Yield after the item-extraction regex (which scans the full XML
// synchronously) so the renderer paints before the first parse chunk.
await yieldToUI();
const episodes: Episode[] = new Array(items.length);
for (let start = 0; start < items.length; start += PARSE_CHUNK_SIZE) {
const end = Math.min(start + PARSE_CHUNK_SIZE, items.length);
for (let i = start; i < end; i++) {
episodes[i] = parseRSSItem(items[i], feedUrl, i);
}
if (end < items.length) await yieldToUI();
}
return episodes;
};
/** Cache of ALL parsed episodes per feed (feedId -> Episode[]). Holds the
* full parse — the bound (count or date) is applied when reading, not when
* writing, so changing the preference takes effect without a refetch.
* Fetch-more reads beyond the bound from this cache (volatile only — the
* cache itself is never extended by fetch-more). */
const fullEpisodeCache = new Map<string, Episode[]>();
/** Track how many episodes are currently loaded per feed */
/** Track how many episodes are currently loaded (visible) per feed. The
* loaded window grows via fetch-more but never exceeds what the cache
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
const episodeLoadCount = new Map<string, number>();
/** Save feeds to file (async, fire-and-forget) */
/** Read the episode cache bound from preferences: a closure that decides
* whether the episode at `index` (0 = newest, after sort) is kept. */
function episodeKeepFn(prefs: {
episodeCacheMode: "date" | "count";
episodeCacheCount: number;
episodeCacheDays: number;
}): (ep: Episode, index: number) => boolean {
const now = new Date();
if (prefs.episodeCacheMode === "count") {
const count = Math.max(1, prefs.episodeCacheCount);
return (_ep: Episode, index: number) => index < count;
}
const days = Math.max(1, prefs.episodeCacheDays);
return (ep: Episode) => episodeInWindow(ep, now, days);
}
/** Save feeds to file (async, fire-and-forget). */
function saveFeeds(feeds: Feed[]): void {
saveFeedsToFile(feeds);
const prefs = useAppStore().state().preferences;
const days =
prefs.episodeCacheMode === "date"
? Math.max(1, prefs.episodeCacheDays)
: undefined;
saveFeedsToFile(feeds, days);
}
/** Save sources to file (async, fire-and-forget) */
@@ -96,18 +186,44 @@ async function migratePlaintextCredentials(
return changed ? migrated : sources;
}
/** True when two episode lists hold the same episodes (id-set equality,
* order-insensitive). Refreshes compare fetched content against this so an
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
* "updated" sort — instead of reordering the list on every background
* refresh. */
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
if (a.length !== b.length) return false;
const ids = new Set(a.map((e) => e.id));
return b.every((e) => ids.has(e.id));
/** True when the freshly fetched window matches the corresponding PREFIX of
* the existing episode list (id-set equality, order-insensitive). With
* union semantics the merged list legitimately contains episodes BEYOND the
* fetched window, so unchanged-detection must compare the fetched window
* against the existing list's prefix — comparing full lists would bump
* `lastUpdated` on every refresh. */
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
if (fetched.length === 0) return true;
const prefix = existing.slice(0, fetched.length);
const ids = new Set(prefix.map((e) => e.id));
return fetched.every((e) => ids.has(e.id));
}
/** Run `fn` over every item with at most `limit` executions in flight — a
* classic worker pool. Workers pull indexes from a shared counter, so the
* first `limit` calls start immediately and each completion frees its slot
* for the next item; results are assembled in INPUT order regardless of
* completion order. A hung `fn` holds at most one slot. */
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
let i: number;
while ((i = nextIndex++) < items.length) {
results[i] = await fn(items[i]);
}
},
);
await Promise.all(workers);
return results;
}
/** Create feed store */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
@@ -122,7 +238,39 @@ function createFeedStore() {
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
/** Get filtered and sorted feeds */
// ── Debounced persistence ───────────────────────────────────────────────
/** Trailing-edge debounce window for config.json writes. */
const SAVE_DEBOUNCE_MS = 250;
/** True when a save is scheduled but has not flushed yet. */
let savePending = false;
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null;
/** Schedule a config.json write (trailing edge) — rapid state changes
* (a refresh batch landing feed-by-feed, pin toggles, load-more pages)
* collapse into one final write instead of one file rewrite per step. */
const scheduleSaveFeeds = (): void => {
savePending = true;
if (pendingSaveTimer) clearTimeout(pendingSaveTimer);
pendingSaveTimer = setTimeout(() => {
pendingSaveTimer = null;
flushPendingSave();
}, SAVE_DEBOUNCE_MS);
};
/** Persist immediately when anything is dirty; exported for tests and
* quit hooks. Cancels a pending debounced save — the state it would
* have written is already reflected in feeds(), so writing now is
* strictly more current. */
const flushPendingSave = (): void => {
if (pendingSaveTimer) {
clearTimeout(pendingSaveTimer);
pendingSaveTimer = null;
}
if (!savePending) return;
savePending = false;
saveFeeds(feeds());
};
const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()];
const f = filter();
@@ -180,7 +328,6 @@ function createFeedStore() {
return result;
};
/** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{
episode: Episode;
feed: Feed;
@@ -201,51 +348,80 @@ function createFeedStore() {
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(),
);
};
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
* Returns NULL when the feed could not be fetched (network error, non-OK
* response, timeout) — callers must treat null as "unchanged" and keep
* the previously loaded episodes. A failed refresh must never look like
* an empty feed, or the store would wipe a subscribed show's episodes. */
/** Fetch latest episodes from an RSS feed URL, caching ALL parsed
* episodes in fullEpisodeCache. The visible episodes returned are
* bounded by the user's cache preference (count or date); the full
* cache survives so fetch-more can page beyond the bound without a
* refetch (volatile only — the cache is never extended by fetch-more).
* Returns NULL episodes on any failure — a failed fetch must not look
* like an empty feed, or the store would wipe a subscribed show's
* episodes. Also returns the channel-level artwork so callers can
* backfill a feed's coverUrl (subscribe + refresh). */
const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[] | null> => {
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
try {
const response = await fetch(feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
// Hung feeds must not stall a refresh batch (or the background
// refresh loop) indefinitely.
// Hung feeds must not stall a refresh batch (or the
// background refresh loop) indefinitely.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
if (!response.ok) return { episodes: null, coverUrl: undefined };
const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
// Yield after the network read so the renderer gets a turn
// before the sync regex + parse work begins.
await yieldToUI();
const allEpisodes = sortEpisodesReverseChronological(
await parseEpisodesIncremental(xml, feedUrl),
);
// Cache all parsed episodes for pagination
if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
// Cache the FULL parse — the bound is applied when reading,
// not when writing, so a preference change takes effect
// without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED
// so an archive-heavy feed can't pin its entire history in
// RAM for the session (the visible window below is bounded
// by the user's preference regardless).
fullEpisodeCache.set(
feedId,
allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED),
);
}
return allEpisodes.slice(0, limit);
// Bound the visible window by the user's cache preference.
const prefs = useAppStore().state().preferences;
const keep = episodeKeepFn(prefs);
const bounded = allEpisodes.filter((ep, i) => keep(ep, i));
const visible = bounded.slice(0, limit);
if (feedId) {
// Track how many episodes are visible — the bounded window,
// not the full parse. hasMoreEpisodes compares this to the
// full cache length to decide if fetch-more can page deeper.
episodeLoadCount.set(feedId, visible.length);
}
return {
episodes: visible,
coverUrl: parseChannelCoverUrl(xml),
};
} catch {
return null;
return { episodes: null, coverUrl: undefined };
}
};
/** Check if a feed with this URL already exists */
const hasFeedByUrl = (feedUrl: string): boolean => {
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
};
@@ -256,45 +432,54 @@ function createFeedStore() {
sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
): Promise<Feed | null> => {
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
// feed URL; resolve the real feed from its directory page before
// subscribing. Refuse when it can't be resolved rather than adding a
// broken feed.
if (!podcast.feedUrl) {
if (!podcast.directoryUrl) return null;
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
if (!resolved) return null;
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
}
const activity = useActivityStore();
// The "Subscribing" label covers the directory-resolve + subscribe
// fetch stretch — the gaps no existing signal (isLoadingFeeds,
// per-pane spinners) covers.
return activity.track((async () => {
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
// feed URL; resolve the real feed from its directory page before
// subscribing. Refuse when it can't be resolved rather than adding a
// broken feed.
if (!podcast.feedUrl) {
if (!podcast.directoryUrl) return null;
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
if (!resolved) return null;
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
}
// 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;
}
// 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;
}
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
const newFeed: Feed = {
id: feedId,
podcast,
episodes: episodes ?? [],
visibility,
sourceId,
lastUpdated: new Date(),
isPinned: false,
};
setFeeds((prev) => {
const updated = [...prev, newFeed];
saveFeeds(updated);
return updated;
});
// Global auto-download: newly subscribed shows join the next pass.
runAutoDownload();
return newFeed;
const feedId = crypto.randomUUID();
const { episodes, coverUrl } = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
if (!podcast.coverUrl && coverUrl) {
podcast = { ...podcast, coverUrl };
}
const newFeed: Feed = {
id: feedId,
podcast,
episodes: episodes ?? [],
visibility,
sourceId,
lastUpdated: new Date(),
isPinned: false,
};
setFeeds((prev) => {
const updated = [...prev, newFeed];
scheduleSaveFeeds();
return updated;
});
// Global auto-download: newly subscribed shows join the next pass.
runAutoDownload();
return newFeed;
})(), "Subscribing");
};
/** Download the N most recent episodes of every in-scope show, per the
@@ -332,78 +517,105 @@ function createFeedStore() {
};
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
* only when the content actually changed (see sameEpisodes). Returns the
* ORIGINAL array reference when nothing changed so callers skip
* persistence entirely — a refresh that fetched identical episodes must
* not re-sort the "updated" view. */
* only when the content actually changed (see sameRefreshWindow). The
* fetched window is MERGED into the existing episodes (fetched copy wins
* on id collision) so a refresh never shrinks the in-memory list; the
* union is pruned by the user's cache bound (count or date) so episodes
* outside the bound fall out of the visible list on the next refresh.
* Returns the ORIGINAL array reference when nothing changed so callers
* skip persistence entirely — a refresh that fetched identical episodes
* must not re-sort the "updated" view. */
const applyRefreshedEpisodes = (
prev: Feed[],
feedId: string,
episodes: Episode[],
): Feed[] => {
let changed = false;
const prefs = useAppStore().state().preferences;
const keep = episodeKeepFn(prefs);
const updated = prev.map((f) => {
if (f.id !== feedId) return f;
if (sameEpisodes(f.episodes, episodes)) return f;
const merged = mergeEpisodesBounded(f.episodes, episodes, keep);
if (sameRefreshWindow(f.episodes, episodes)) return f;
changed = true;
return { ...f, episodes, lastUpdated: new Date() };
return { ...f, episodes: merged, lastUpdated: new Date() };
});
return changed ? updated : prev;
};
/** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId);
if (!feed) return;
const episodes = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
// Fetch failed (null): keep the currently loaded episodes untouched.
if (!episodes) return;
setFeeds((prev) => {
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
if (updated !== prev) saveFeeds(updated);
return updated;
});
const activity = useActivityStore();
return activity.track((async () => {
const feed = getFeed(feedId);
if (!feed) return;
const { episodes, coverUrl } = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
// Fetch failed (null): keep the currently loaded episodes untouched.
if (!episodes) return;
setFeeds((prev) => {
let updated = applyRefreshedEpisodes(prev, feedId, episodes);
if (coverUrl) {
updated = updated.map((f) =>
f.id === feedId && !f.podcast.coverUrl && coverUrl
? { ...f, podcast: { ...f.podcast, coverUrl } }
: f,
);
}
if (updated !== prev) scheduleSaveFeeds();
return updated;
});
// Global auto-download: ensure the N most recent episodes of in-scope
// shows are available offline after every refresh (idempotent).
runAutoDownload();
// Global auto-download: ensure the N most recent episodes of in-scope
// shows are available offline after every refresh (idempotent).
runAutoDownload();
})(), "Refreshing");
};
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
* atomic update. Per-feed incremental setFeeds re-sorted the list once
* per completion (each refresh bumped lastUpdated and the "updated" sort
* re-ran), which showed up as the list order flapping until the batch
* finished. */
/** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY
* in-flight requests), and each feed's refreshed episodes are applied
* AS ITS OWN FETCH LANDS (no Promise.all barrier). Per-feed apply is
* safe because applyRefreshedEpisodes keeps unchanged feeds' object
* identity and lastUpdated (union merge), so each feed's refreshed
* episodes render as its own fetch resolves — the order flapping the
* old atomic barrier existed to hide can no longer happen. */
const refreshAllFeeds = async () => {
setIsLoadingFeeds(true);
try {
const currentFeeds = feeds();
const results = await Promise.all(
currentFeeds.map(async (feed) => [
feed.id,
await fetchEpisodes(
await mapWithConcurrency(
feeds(),
FETCH_CONCURRENCY,
async (feed) => {
const { episodes, coverUrl } = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feed.id,
),
] as const),
);
setFeeds((prev) => {
let updated = prev;
for (const [feedId, episodes] of results) {
);
// A failed fetch (null) leaves that feed untouched.
if (!episodes) continue;
updated = applyRefreshedEpisodes(updated, feedId, episodes);
}
if (updated !== prev) saveFeeds(updated);
return updated;
});
if (!episodes) return;
setFeeds((prev) => {
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
if (coverUrl) {
updated = updated.map((f) =>
f.id === feed.id && !f.podcast.coverUrl && coverUrl
? { ...f, podcast: { ...f.podcast, coverUrl } }
: f,
);
}
if (updated !== prev) scheduleSaveFeeds();
return updated;
});
},
);
// Global auto-download: one idempotent pass after the batch.
runAutoDownload();
// A refresh batch always ends with a persisted write when
// anything changed — never leave the debounce's trailing edge
// pending across a process exit.
flushPendingSave();
} finally {
setIsLoadingFeeds(false);
}
@@ -415,7 +627,11 @@ function createFeedStore() {
const { promise: feedsReady, resolve: resolveFeedsReady } =
Promise.withResolvers<void>();
(async () => {
const loadedFeeds = await loadFeedsFromFile();
const loadedFeeds = await loadFeedsFromFile(
useAppStore().state().preferences.episodeCacheMode === "date"
? Math.max(1, useAppStore().state().preferences.episodeCacheDays)
: undefined,
);
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
resolveFeedsReady();
const loadedSources = await loadSourcesFromFile<PodcastSource>();
@@ -470,13 +686,15 @@ function createFeedStore() {
};
scheduleNextRefresh();
/** 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);
// Unsubscribe intent must not sit in the debounce window if the
// process exits — persist the removal immediately.
scheduleSaveFeeds();
flushPendingSave();
return updated;
});
};
@@ -489,35 +707,35 @@ function createFeedStore() {
episodeLoadCount.delete(feed.id);
setFeeds((prev) => {
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
saveFeeds(updated);
// Unsubscribe intent must not sit in the debounce window if
// the process exits — persist the removal immediately.
scheduleSaveFeeds();
flushPendingSave();
return updated;
});
}
};
/** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
scheduleSaveFeeds();
return updated;
});
};
/** 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);
scheduleSaveFeeds();
return updated;
});
};
/** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
@@ -531,7 +749,6 @@ function createFeedStore() {
return newSource;
};
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
@@ -542,7 +759,6 @@ function createFeedStore() {
});
};
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
@@ -555,7 +771,6 @@ function createFeedStore() {
return true;
};
/** Toggle source enabled status */
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
@@ -566,7 +781,6 @@ function createFeedStore() {
});
};
/** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId);
};
@@ -581,13 +795,17 @@ function createFeedStore() {
return undefined;
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
return id ? getFeed(id) : undefined;
};
/** Check if a feed has more episodes available beyond what's currently loaded */
/** Check if a feed has more episodes available beyond what's currently
* loaded. The full parse cache holds ALL episodes (including beyond the
* cache bound), so fetch-more can always page deeper — the bound limits
* what the Feed/My Shows list shows initially, not what fetch-more can
* reach. When the loaded window reaches the cache length, this flips
* false. */
const hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId);
if (!cached) return false;
@@ -595,7 +813,13 @@ function createFeedStore() {
return loaded < cached.length;
};
/** Load the next chunk of episodes for one feed from the cache.
/** Load the next chunk of episodes for one feed from the full parse
* cache — VOLATILE only: the episodes surfaced beyond the cache bound
* are held in the feed's in-memory episode list (so the user can browse
* them) but are NOT written back to fullEpisodeCache (the cache keeps
* its original bounded shape; these episodes vanish on the next
* refresh or restart). The cache is populated by fetchEpisodes/refresh;
* a cold cache (post-restart) triggers a refetch here.
* No global guard — callers own the `isLoadingMore` flag so batches
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
const loadMoreEpisodesForFeed = async (feedId: string) => {
@@ -604,18 +828,36 @@ function createFeedStore() {
let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed
// If no cache, re-fetch and parse the full feed (cold path after a
// restart). The cache holds the FULL parse — no bound applied here.
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;
try {
const response = await fetch(feed.podcast.feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
// A hung feed must not stall the load-more path forever —
// mirror fetchEpisodes' per-feed timeout.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return;
const xml = await response.text();
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
} catch {
// Failed/hung refetch: leave the feed's loaded episodes
// untouched rather than throwing out of loadMoreEpisodes.
return;
}
// Cold-refetch parse output is unsorted; sort it newest-first.
// Yield before the sync sort (the parse already yielded before
// this point, but the sort of potentially hundreds of episodes
// is its own sync block).
await yieldToUI();
cached = sortEpisodesReverseChronological(cached);
// Same ceiling as fetchEpisodes: the cache (and the paging
// window below) never exceeds MAX_CACHED_EPISODES_PER_FEED.
cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED);
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
@@ -629,14 +871,24 @@ function createFeedStore() {
if (newCount <= currentCount) return; // nothing more to load
// Advance the loaded window — volatile: the episodes beyond the cache
// bound are held in feed.episodes (visible) but the cache itself is
// NOT extended. episodeLoadCount tracks the volatile window size.
episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount);
// Yield a real macrotask turn before the sync state update so the
// renderer paints the spinner and processes keyboard input before the
// (potentially large, per-feed in loadMoreAllFeeds) setFeeds + sort
// runs. Without this, the whole body executes in one microtask batch
// and the UI freezes through every feed in the batch.
await yieldToUI();
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f,
);
saveFeeds(updated);
scheduleSaveFeeds();
return updated;
});
};
@@ -653,7 +905,6 @@ function createFeedStore() {
}
};
/** True if any feed still has cached episodes beyond its loaded window. */
const hasMoreAcrossAll = (): boolean => {
return feeds().some((f) => hasMoreEpisodes(f.id));
};
@@ -673,7 +924,6 @@ function createFeedStore() {
}
};
/** Run the global auto-download pass (see runAutoDownload above). */
const runAutoDownloadNow = (): void => {
runAutoDownload();
};
@@ -713,6 +963,7 @@ function createFeedStore() {
loadMoreEpisodes,
loadMoreAllFeeds,
hasMoreAcrossAll,
flushPendingSave,
addSource,
removeSource,
toggleSource,
@@ -721,7 +972,6 @@ function createFeedStore() {
};
}
/** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() {

View File

@@ -64,16 +64,10 @@ function createProgressStore() {
*/
whenReady: () => progressInit,
/**
* Get progress for a specific episode.
*/
get(episodeId: string): Progress | undefined {
return progressMap()[episodeId];
},
/**
* Get all progress entries.
*/
all(): Record<string, Progress> {
return progressMap();
},
@@ -102,18 +96,12 @@ function createProgressStore() {
persist();
},
/**
* Check if an episode is completed.
*/
isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD;
},
/**
* Get progress percentage (0-100) for an episode.
*/
getPercent(episodeId: string): number {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0;
@@ -151,9 +139,6 @@ function createProgressStore() {
persist();
},
/**
* Clear all progress data.
*/
clear(): void {
setProgressMap({});
persist();

View File

@@ -60,7 +60,6 @@ function saveScope(scope: SearchScope): void {
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
const [query, setQuery] = createSignal("");
@@ -167,7 +166,6 @@ export function createSearchStore() {
}
};
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
const updated = sanitizeHistory([q, ...prev]);
@@ -176,13 +174,11 @@ export function createSearchStore() {
});
};
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveSearchHistoryToFile([]);
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
@@ -191,14 +187,12 @@ export function createSearchStore() {
});
};
/** Clear results */
const clearResults = () => {
setResults([]);
setQuery("");
setError(null);
};
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
@@ -262,7 +256,6 @@ export function createSearchStore() {
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {

View File

@@ -96,6 +96,11 @@ export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */
export type AutoDownloadScope = "all" | "none" | "whitelist";
/** How the episode cache (the Feed / My Shows list + the pagination cache)
* is bounded: by a rolling date window or by a count of most-recent
* episodes (default: date). */
export type EpisodeCacheMode = "date" | "count";
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
@@ -111,6 +116,12 @@ export type UserPreferences = {
fetchMoreMode: FetchMoreMode;
/** Minutes between automatic background feed refreshes (default: 30). */
refreshIntervalMinutes: number;
/** How the episode list cache is bounded — by date or by count (default: date). */
episodeCacheMode: EpisodeCacheMode;
/** Number of most-recent episodes to keep when mode is "count" (default: 25). */
episodeCacheCount: number;
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
episodeCacheDays: number;
};
export type AppState = {

View File

@@ -156,9 +156,6 @@ function init() {
setRegistrations((arr) => arr.filter((x) => x !== results));
});
},
/**
* Get all visible options.
*/
get options() {
return visibleOptions();
},
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
}
/**
* Command palette dialog component.
*/
function CommandDialog(props: {
options: CommandOption[];
suggestedOptions: CommandOption[];

View File

@@ -98,9 +98,6 @@ function init() {
})
return {
/**
* Clear all dialogs from the stack.
*/
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
@@ -113,9 +110,6 @@ function init() {
emit("dialog.close", {})
},
/**
* Replace all dialogs with a new one.
*/
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -130,9 +124,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Push a new dialog onto the stack.
*/
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -143,9 +134,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Pop the top dialog from the stack.
*/
pop() {
if (store.stack.length === 0) return
const current = store.stack.at(-1)!

View File

@@ -49,6 +49,9 @@ const defaultPreferences: UserPreferences = {
autoJumpToPlayer: true,
fetchMoreMode: "auto",
refreshIntervalMinutes: 30,
episodeCacheMode: "date",
episodeCacheCount: 25,
episodeCacheDays: 60,
};
const defaultState: AppState = {
@@ -59,7 +62,6 @@ const defaultState: AppState = {
// ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const cfg = await loadConfig();
@@ -85,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
}
}
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void {
updateConfig({
settings: state.settings,
@@ -106,7 +107,6 @@ interface ProgressEntry {
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
@@ -142,7 +142,6 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
const SEARCH_HISTORY_FILE = "search-history.json";
/** Load search history from JSON file */
export async function loadSearchHistoryFromFile(): Promise<string[]> {
try {
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
@@ -156,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise<string[]> {
}
}
/** Save search history to JSON file (overwrite, no backup) */
export function saveSearchHistoryToFile(history: string[]): void {
(async () => {
try {
@@ -175,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void {
const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));

View File

@@ -23,9 +23,16 @@
* pass over just that region) — earlier segments stay valid, mp3 decode of
* the same file is deterministic so abutting segments agree.
*
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min),
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
* high-cutoff of the visualizer's FFT config.
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
* SLIDING WINDOW around the playback position — the decode pass stops
* once it is maxAheadSec ahead of the cursor and segments entirely older
* than keepBehindSec behind it are dropped (both re-filled/restarted on
* demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of
* audio (~40 MB at the defaults) INDEPENDENT of episode length; the old
* whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on
* long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist
* 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT
* config.
*
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
* old reader; local files skip them (ffmpeg rejects http-only options for
@@ -49,6 +56,24 @@ const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
*/
const CLOSE_IN_PLACE_GAP_SEC = 15;
/**
* Default decode-head budget: the ffmpeg pass pauses once it is this far
* ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND
* the network pull — the old cache decoded the whole episode at 4x, so a
* 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire
* remote file even when only the first 10 minutes were listened to. At 4x
* pacing a refill costs ~150s of background decode, one ffmpeg spawn per
* ~10 min of playback.
*/
const DEFAULT_DECODE_AHEAD_SEC = 600;
/**
* Default retention behind the cursor: decoded audio entirely older than
* this is dropped. Keeps pause/resume and small backward seeks instant
* without letting the window grow with playback time.
*/
const DEFAULT_KEEP_BEHIND_SEC = 300;
/**
* Monotonically increasing generation counter.
* Each startDecode() increments this; the read loop checks it to know
@@ -73,6 +98,10 @@ export interface EpisodePcmCacheOptions {
url: string;
/** Sample rate (default: 22050) */
sampleRate?: number;
/** Decode-head budget in seconds ahead of the cursor (default: 600). */
maxAheadSec?: number;
/** Retention in seconds behind the cursor (default: 300). */
keepBehindSec?: number;
}
export class EpisodePcmCache {
@@ -84,10 +113,15 @@ export class EpisodePcmCache {
private activeSegment: Segment | null = null;
readonly url: string;
readonly sampleRate: number;
/** Sliding-window budgets (see maintainWindow). */
readonly maxAheadSec: number;
readonly keepBehindSec: number;
constructor(options: EpisodePcmCacheOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC;
this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC;
}
/** Whether an ffmpeg decode pass is currently running. */
@@ -238,11 +272,20 @@ export class EpisodePcmCache {
* new segment at `sec` (seek into a hole / resume past cached audio).
*/
ensureDecodeAround(sec: number): void {
// Enforce the sliding-window budget first (head cap, prune, refill)
// so a resume or seek never leaves stale segments behind the cursor.
this.maintainWindow(sec);
// Data already on hand: nothing needed here; only keep the tail
// filling if the decode is idle and the episode is unfinished.
// filling if the decode is idle, the episode is unfinished, AND the
// head is inside its budget. A head-capped cache ("we're maxAheadSec
// ahead, enough decoded") is NOT a stalled decode — restarting it
// here would fight maintainWindow's cap on every resume call.
if (this.covers(sec)) {
if (this._decoding || this.decodeFinished) return;
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
const end = this.coverageEndSec;
if (end >= sec + this.maxAheadSec) return;
this.startDecode(end > sec ? end : sec);
return;
}
@@ -265,6 +308,48 @@ export class EpisodePcmCache {
this.startDecode(Math.max(0, sec));
}
/**
* Sliding-window budget for the in-memory cache, driven by the live
* playback position. Runs on every read (the render loop is the only
* consumer that knows the cursor continuously) and on resume/seek:
* - capHead: the decode pass pauses once it is maxAheadSec ahead of the
* cursor (pauseDecode keeps the decoded data — a plain startDecode
* from the frontier refills it later).
* - prune: segments entirely keepBehindSec behind the cursor are
* dropped. A backward seek past the window restarts a segment there —
* the same mechanism as a seek into an undecoded hole, so no new
* failure mode.
* - topUp: when the cursor has outrun the head, restart the tail decode
* from the frontier (one ffmpeg spawn per maxAheadSec of playback).
* Together these bound memory to (maxAheadSec + keepBehindSec) of audio
* regardless of episode length.
*/
private maintainWindow(atSec: number): void {
const pos = Math.max(0, atSec);
if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) {
this.pauseDecode();
}
const keepFromSec = pos - this.keepBehindSec;
if (
this.segments.some(
(seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec,
)
) {
this.segments = this.segments.filter(
(seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec,
);
}
if (!this._decoding && !this.decodeFinished) {
const end = this.coverageEndSec;
if (end < pos + this.maxAheadSec) {
this.startDecode(Math.max(end, pos));
}
}
}
/**
* Read the PCM window ENDING at `atSec` of playback into `out`
* (Int16 magnitudes widened to f64, the scale cavacore expects).
@@ -275,6 +360,7 @@ export class EpisodePcmCache {
*/
readWindow(out: Float64Array, atSec: number): number {
if (out.length === 0) return 0;
this.maintainWindow(atSec);
const endIdx = Math.round(atSec * this.sampleRate);
const startIdx = endIdx - out.length + 1;
for (const seg of this.segments) {

View File

@@ -29,7 +29,7 @@
import { platform } from "os";
import { existsSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { join } from "path";
import type { Socket, Subprocess } from "bun";
// ── Types ────────────────────────────────────────────────────────────
@@ -60,7 +60,6 @@ export interface AudioBackend {
* (mpv `video-add`). Lets play() start without waiting on art; the
* Now Playing artwork pops in when the download lands.
*/
addCoverArt(path: string): Promise<void>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
@@ -115,53 +114,13 @@ function mpvSocketPath(): string {
// Per-instance, not just per-pid: tests (and backend switching) create
// several MpvBackend objects in ONE bun process — a pid-only path makes
// every daemon bind the same socket, so later daemons unlink the path
// out from under earlier ones and IPC cross-talks between backends.
// out from under each other.
return join(
tmpdir(),
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
);
}
/**
* mpv executable to use. Prefers a sibling `mpv` inside the app bundle
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
* makes macOS attribute its Now Playing session to PodTui — source-app icon
* and name in Control Center — instead of a blank placeholder for an
* unbundled binary.
*
* The bundled copy is verified to actually launch: it links against brew's
* dylibs by absolute path, and a Homebrew ffmpeg major upgrade can break it
* (dylib gone → immediate non-zero exit). If the bundled binary can't run,
* fall back to PATH mpv so audio keeps working — the icon degrades to blank
* rather than playback dying. Probed once per process.
*/
let resolvedMpv: string | null | undefined; // undefined = not yet probed
function mpvLaunches(binary: string): boolean {
try {
const proc = Bun.spawnSync([binary, "--version"], { timeout: 3000 });
return proc.exitCode === 0;
} catch {
return false;
}
}
function resolveMpvBinary(): string | null {
if (resolvedMpv !== undefined) return resolvedMpv;
let resolved: string | null = null;
try {
const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled) && mpvLaunches(bundled)) {
resolved = bundled;
}
} catch {
/* process.execPath unusable — fall through to PATH */
}
if (!resolved) resolved = which("mpv");
resolvedMpv = resolved;
return resolved;
}
// ── mpv JSON IPC connection ─────────────────────────────────────────
//
// One persistent Unix-socket connection to the resident mpv daemon. Lines
@@ -377,7 +336,7 @@ export class MpvBackend implements AudioBackend {
this.proc = Bun.spawn(
[
resolveMpvBinary() ?? "mpv",
"mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
@@ -601,12 +560,6 @@ export class MpvBackend implements AudioBackend {
Math.round((opts?.volume ?? 1) * 100),
]);
await this.send(["set_property", "speed", opts?.speed ?? 1]);
if (opts?.coverArtPath) {
// File is already loaded: cover-art-files only applies at
// load, so add the art as a runtime albumart track instead.
await this.send(["set_property", "cover-art-files", opts.coverArtPath]);
await this.send(["video-add", opts.coverArtPath]);
}
if (opts?.mediaTitle) {
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
}
@@ -631,14 +584,6 @@ export class MpvBackend implements AudioBackend {
});
}
async addCoverArt(path: string): Promise<void> {
if (!this._loadedUrl) return;
// Keep the property pointing at the latest art too, so a subsequent
// loadfile of the same episode carries it.
await this.send(["set_property", "cover-art-files", path]);
await this.send(["video-add", path]);
}
async pause(): Promise<void> {
await this.send(["set_property", "pause", true]);
this._intentPlaying = false;
@@ -756,7 +701,6 @@ class NoopBackend implements AudioBackend {
readonly name: BackendName = "none";
async play(): Promise<void> {}
async preload(): Promise<void> {}
async addCoverArt(): Promise<void> {}
async pause(): Promise<void> {}
async resume(): Promise<void> {}
async stop(): Promise<void> {}
@@ -799,7 +743,7 @@ export interface DetectedPlayer {
export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = [];
const mpvPath = resolveMpvBinary();
const mpvPath = which("mpv");
if (mpvPath) {
players.push({
name: "mpv",
@@ -833,13 +777,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend;
}
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
return which("mpv") ? new MpvBackend() : new NoopBackend();
}
function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) {
case "mpv":
return resolveMpvBinary() ? new MpvBackend() : null;
return which("mpv") ? new MpvBackend() : null;
case "none":
return new NoopBackend();
}

View File

@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = {
symbols: Record<string, (...args: any[]) => any>;
close(): void;
@@ -102,7 +101,6 @@ export class CavaCore {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars;
}

View File

@@ -93,6 +93,13 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
});
}
/** Resolve once every queued config write has flushed. Tests await this to
* observe the serialized result of pending saveFeedsToFile/updateConfig
* calls before asserting on config.json. */
export function whenConfigIdle(): Promise<void> {
return writeChain;
}
/** Guards so migration runs exactly once per process. */
let migrationDone = false;
let migrationPromise: Promise<void> | null = null;

View File

@@ -0,0 +1,30 @@
import type { Episode } from "../types/episode";
/** Sort key for an episode's pubDate — missing/invalid dates sort as NEWEST
* (Infinity) so undated episodes float to the top instead of dropping into
* the oldest slot. */
const ts = (ep: Episode): number => {
const t = ep.pubDate?.getTime()
return t === undefined || Number.isNaN(t) ? Infinity : t
}
/**
* Union of two episode lists keyed by id — on collision the fetched copy
* wins (fresh metadata). Result is sorted newest-first by pubDate and pruned
* by the supplied `keep` predicate: episodes outside the configured cache
* bound (date window or count) are dropped. Never mutates either input.
*
* The caller supplies `keep` so this module stays free of the preference
* types — the feed store passes a closure bound to the user's mode/count/days.
*/
export function mergeEpisodesBounded(
existing: Episode[],
fetched: Episode[],
keep: (ep: Episode, index: number) => boolean,
): Episode[] {
const byId = new Map<string, Episode>()
for (const ep of existing) byId.set(ep.id, ep)
for (const ep of fetched) byId.set(ep.id, ep)
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a))
return sorted.filter((ep, i) => keep(ep, i))
}

View File

@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler);
};

View File

@@ -4,9 +4,70 @@
*/
import { loadConfig, updateConfig } from "./config";
import { getConfigFilePath } from "./config-dir";
import { DownloadStatus } from "../types/episode";
import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import type { PodcastSource } from "../types/source";
/** Default episode lifecycle window in days — used when no preference is
* configured (legacy configs, first launch). The actual bound is the user's
* episodeCacheDays preference; this is just the fail-safe default. */
export const DEFAULT_EPISODE_WINDOW_DAYS = 60;
/** True when an episode falls inside a rolling date window of `days` days.
* A missing/invalid pubDate is ALWAYS kept (fail-safe: never drop an
* undatable episode) — the volatile cache must agree with
* episodeIsPersistable so an episode the persistence layer retains can
* never be silently pruned from the list. */
export function episodeInWindow(
ep: Episode,
now: Date,
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
): boolean {
const t = ep.pubDate?.getTime();
if (!t || Number.isNaN(t)) return true;
return t >= now.getTime() - days * 24 * 3600 * 1000;
}
/** True when an episode may be persisted: a completed download, or it falls
* inside the lifecycle window (undatable episodes always kept). */
export function episodeIsPersistable(
ep: Episode,
downloadedIds: Set<string>,
now: Date,
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
): boolean {
return downloadedIds.has(ep.id) || episodeInWindow(ep, now, days);
}
/** Episode ids of completed downloads, read from downloads.json. In-flight
* downloads are NOT exempted from the retention window — a just-completed
* download is re-included by the next save because the in-memory
* feed.episodes still holds it. Missing/unreadable/invalid file → empty set. */
async function readDownloadedEpisodeIds(): Promise<Set<string>> {
try {
const file = Bun.file(getConfigFilePath("downloads.json"));
if (!(await file.exists())) return new Set();
const raw = await file.json();
if (!Array.isArray(raw)) return new Set();
const ids = new Set<string>();
for (const rec of raw) {
if (
rec &&
typeof rec === "object" &&
rec.status === DownloadStatus.COMPLETED &&
typeof rec.episodeId === "string"
) {
ids.add(rec.episodeId);
}
}
return ids;
} catch {
return new Set();
}
}
/** Deserialize date strings back to Date objects in feed data */
function reviveDates(feed: Feed): Feed {
return {
@@ -22,24 +83,57 @@ function reviveDates(feed: Feed): Feed {
})),
};
}
/** Load feeds from config.json */
export async function loadFeedsFromFile(): Promise<Feed[]> {
/** Load feeds from config.json, pruning episodes outside the retention
* window (completed downloads always kept). When anything was pruned, the
* pruned list is rewritten to config.json (startup cleanup for legacy
* configs). The read path is awaited so the returned value is deterministic. */
export async function loadFeedsFromFile(
windowDays?: number,
): Promise<Feed[]> {
try {
const cfg = await loadConfig();
if (!Array.isArray(cfg.feeds)) return [];
return cfg.feeds.map(reviveDates);
const feeds = cfg.feeds.map(reviveDates);
const downloadedIds = await readDownloadedEpisodeIds();
const now = new Date();
let prunedAny = false;
const pruned = feeds.map((f) => {
const kept = f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, now, windowDays),
);
if (kept.length !== f.episodes.length) prunedAny = true;
return { ...f, episodes: kept };
});
if (prunedAny) {
// Fire-and-forget cleanup rewrite of the legacy config.
saveFeedsToFile(pruned, windowDays);
}
return pruned;
} catch {
return [];
}
}
/** Save feeds to config.json */
export function saveFeedsToFile(feeds: Feed[]): void {
updateConfig({ feeds });
/** Save feeds to config.json, pruning episodes outside the retention window
* (completed downloads always kept). Fire-and-forget: the prune reads
* downloads.json asynchronously, then enqueues the write. On any error the
* UNPRUNED feeds are saved instead, so data is never lost. */
export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
(async () => {
try {
const downloadedIds = await readDownloadedEpisodeIds();
const pruned = feeds.map((f) => ({
...f,
episodes: f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
),
}));
updateConfig({ feeds: pruned });
} catch {
updateConfig({ feeds }); /* never lose data on an error path */
}
})().catch(() => {});
}
/** Load sources from config.json */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try {
const cfg = await loadConfig();
@@ -49,8 +143,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
return null;
}
}
/** Save sources to config.json */
export function saveSourcesToFile<T>(sources: T[]): void {
updateConfig({ sources: sources as unknown as PodcastSource[] });
}

View File

@@ -5,9 +5,6 @@
* and multi-line comments, which is useful for configuration files.
*/
/**
* Remove JSONC comments from a string
*/
function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
@@ -23,9 +20,6 @@ function stripComments(jsonString: string): string {
return result;
}
/**
* Parse JSONC string into a JavaScript object
*/
export function parseJSONC(jsonString: string): unknown {
const stripped = stripComments(jsonString);
return JSON.parse(stripped);

View File

@@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);

View File

@@ -9,23 +9,14 @@
import { emit } from "./event-bus"
/**
* Emit a theme reload event.
*/
function emitThemeReload(): void {
emit("theme.reload", {})
}
/**
* Emit a theme changed event.
*/
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
emit("theme.changed", { theme, mode })
}
/**
* Emit a theme mode changed event.
*/
export function emitThemeModeChanged(mode: "dark" | "light"): void {
emit("theme.mode.changed", { mode })
}

View File

@@ -1,28 +1,13 @@
/**
* Theme CSS Variable Manager
* Handles dynamic theme switching by updating CSS custom properties
* Terminal Theme Resolver
* Resolves the active theme (built-in, custom, or system-derived) to colors.
*/
import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
/**
* Apply CSS variable data-theme attribute
*/
export function setThemeAttribute(themeName: string) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
}
export async function loadThemes() {
return await getCustomThemes();
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
if (name === "system" && system) {
return resolveThemeJson(generateSystemTheme(system, mode), mode);
}
const theme = themes[name] ?? themes.catppuccin;
if (!theme) {
return resolveThemeJson(THEME_JSON.catppuccin, mode);
}
return resolveThemeJson(theme, mode);
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
}

View File

@@ -0,0 +1,81 @@
# 01. Persist only a 30-day episode window, keep downloaded episodes, clean up stale data
meta:
id: bounded-feed-lifecycle-01
feature: bounded-feed-lifecycle
priority: P1
depends_on: []
tags: [implementation, tests-required]
objective:
- Bound what the app writes to `config.json`: each persisted feed keeps only episodes published within the last 30 days, plus any episode whose download is completed — everything older lives in volatile memory only (wired up in task 02). Loading an over-window legacy config must prune it automatically (cleanup on first launch).
background (read this before touching code):
- Feeds persist through `src/utils/feeds-persistence.ts`. `saveFeedsToFile(feeds)` is a fire-and-forget wrapper around `updateConfig({ feeds })` in `src/utils/config.ts`, which read-modify-writes the whole `config.json` behind a serialized promise chain (`writeChain`).
- Today `saveFeedsToFile` writes every loaded episode, so `config.json` grows forever (the Feed page's "Fetch More" keeps expanding `feed.episodes` and saving).
- Downloads persist separately in `downloads.json` (same config dir, see `src/utils/config-dir.ts` `getConfigFilePath("downloads.json")`). Each record has `episodeId`, `status`, `feedId`, etc. The `DownloadStatus` enum lives in `src/types/episode.ts` — read it there for the completed member's string value; do NOT hardcode a guessed string.
- `src/stores/feed.ts` calls `saveFeedsToFile` from a module-scope `saveFeeds()` helper. Callers must not change in this task.
- Style: match the file you edit. `feeds-persistence.ts` and `config.ts` are tab-indented WITH semicolons (some other repo files aren't — don't "fix" that anywhere).
deliverables:
- `src/utils/feeds-persistence.ts`:
- New exported constant `EPISODE_WINDOW_DAYS = 30` — the lifecycle window: bounds BOTH persistence (here) and the volatile episode list/cache (task 02).
- New exported pure function `episodeInWindow(ep: Episode, now: Date): boolean` — returns `true` when `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode) OR `ep.pubDate.getTime() >= now.getTime() - EPISODE_WINDOW_DAYS * 24 * 3600 * 1000`.
- New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set<string>, now: Date): boolean` — returns `true` when `downloadedIds.has(ep.id)` OR `episodeInWindow(ep, now)`.
- New (module-private) async helper `readDownloadedEpisodeIds(): Promise<Set<string>>` — reads `getConfigFilePath("downloads.json")` with `Bun.file`, returns the `episodeId`s of records whose `status` equals `DownloadStatus.COMPLETED`; returns an empty set on any error or missing file. Note: an episode whose download is merely in-flight is NOT exempted; it will be re-included by the next save after completion, since the in-memory `feed.episodes` still holds it — document this in the function comment.
- `saveFeedsToFile(feeds: Feed[])` — before calling `updateConfig`, map each feed to `{ ...feed, episodes: feed.episodes.filter(ep => episodeIsPersistable(ep, downloadedIds, new Date())) }`. The downloaded-ids lookup is async, so wrap the whole body in a fire-and-forget async IIFE (`.catch(() => {})`) that preserves the existing sync/fire-and-forget signature; on any lookup failure, save the feeds unpruned (never lose data on an error path).
- `loadFeedsFromFile()` — after `reviveDates`, apply the same prune to the loaded feeds; if the prune removed at least one episode, call `saveFeedsToFile(pruned)` to rewrite `config.json` (this is the startup cleanup for legacy configs). `await` the prune path deterministically (the function is already async).
- `src/utils/config.ts`:
- New exported `whenConfigIdle(): Promise<void>` returning the module-internal `writeChain` promise. Tests need a way to await pending serialized writes; today `updateConfig` hides the chain and tests cannot observe when a write lands.
- `tests/feed-retention.test.ts` (new) — see tests section.
steps:
1. Read `src/types/episode.ts` to confirm `DownloadStatus.COMPLETED`'s runtime value and the `Episode` shape (`id`, `pubDate`).
2. Read `src/utils/feeds-persistence.ts` and `src/utils/config.ts` fully (they are short).
3. Add `whenConfigIdle()` to `config.ts` next to `updateConfig`.
4. In `feeds-persistence.ts`: add imports (`getConfigFilePath` from `./config-dir`, `DownloadStatus` and `type Episode` from `../types/episode`), the constant, `episodeIsPersistable`, `readDownloadedEpisodeIds`, then rework `saveFeedsToFile` and `loadFeedsFromFile` per deliverables. Keep `reviveDates` untouched.
5. Ensure `saveFeeds` in `src/stores/feed.ts` still compiles unchanged (signature-compatible).
6. Write `tests/feed-retention.test.ts`, run it, then run the full suite and lint.
tests:
- Conventions (copy them): `tests/feed-refresh.test.ts` shows the harness — `mkdtempSync` into `process.env.XDG_CONFIG_HOME` **before** importing anything under test (module-level init reads the config dir), `rmSync` in `afterAll`, tabs/no-semicolon style not required but match repo.
- New `tests/feed-retention.test.ts`:
- Unit (ArrangeActAssert) for `episodeIsPersistable`:
- episode 40 days old, not downloaded → `false`.
- episode 40 days old, id in `downloadedIds``true`.
- episode 5 days old → `true`.
- episode with `pubDate: new Date(NaN)``true` (fail-safe).
- Save-path integration:
- Arrange: write a `downloads.json` in the temp config dir containing one `completed` record for `old-downloaded-id` (include all fields the loader reads in `src/stores/download.ts`'s `DownloadRecord`: at minimum `episodeId`, `feedId`, `status`, `filePath: null`, `downloadedAt: null`, `fileSize: 0`, `error: null`, `audioUrl: ""`, `episodeTitle: ""`).
- Act: call `saveFeedsToFile([feed])` where the feed has three episodes — recent, old-not-downloaded (`id: "old-plain-id"`), old-downloaded (`id: "old-downloaded-id"`). Await `whenConfigIdle()` (plus one more microtask/`await Promise.resolve()` round if the async IIFE resolves after the chain call — flush both).
- Assert: parse `config.json` raw; the feed's persisted `episodes` contain the recent and `old-downloaded-id` episodes and NOT `old-plain-id`.
- Load-path cleanup:
- Arrange: seed `config.json` (write it directly with `Bun.write`) with one feed holding only over-window episodes; no `downloads.json`.
- Act: `await loadFeedsFromFile()`, then `await whenConfigIdle()`.
- Assert: returned feed has zero episodes AND re-reading `config.json` shows the episodes pruned (cleanup rewrite happened).
acceptance_criteria:
- `saveFeedsToFile` never writes an episode older than 30 days unless its id is a completed download in `downloads.json`.
- `loadFeedsFromFile` prunes over-window episodes from legacy configs and rewrites `config.json` when it pruned anything.
- Undatable episodes (`pubDate` missing/invalid) are always persisted.
- No call site of `saveFeedsToFile`/`loadFeedsFromFile` needed to change (compatible signatures).
- `bun test tests/feed-retention.test.ts` passes; the existing `bun test` suite passes; `bun run lint` is clean.
validation:
- `bun test tests/feed-retention.test.ts`
- `bun test` (full suite — watch `feed-refresh`/`feed-pagination` for regressions)
- `bun run lint`
- Manual smoke (optional): `bun start`, subscribe to any feed, quit, then `cat ~/.config/podtui/config.json | python3 -c "import sys,json; print(max(e['pubDate'] for f in json.load(sys.stdin)['feeds'] for e in f['episodes']))"` and confirm no persisted episode is older than 30 days.
notes:
- `updateConfig` captures the patched data eagerly at call time (`JSON.parse(JSON.stringify(patch))`), so pruning in `saveFeedsToFile` before the `updateConfig` call is exactly where the filter must live — filtering later would be silently ineffective for already-queued writes.
- This task intentionally does NOT change in-memory behavior, refresh merging, or cache bounds — that is task 02. If both are worked on in parallel, 02 imports nothing from 01 except the documented window semantics; the module-level contract above is the seam.
- `downloads.json` is written by `src/stores/download.ts` (`saveDownloads`); reading it directly here avoids a store→module import cycle (download.ts already imports the feed store).

View File

@@ -0,0 +1,85 @@
# 02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache
meta:
id: bounded-feed-lifecycle-02
feature: bounded-feed-lifecycle
priority: P2
depends_on: [bounded-feed-lifecycle-01]
tags: [implementation, tests-required]
objective:
- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention by the SAME date window persistence uses (`EPISODE_WINDOW_DAYS`, 30 days) instead of an episode count: the visible list and the pagination cache hold every episode from the last 30 days, and episodes older than that age out of the list on the next refresh (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched).
background (read this before touching code):
- All work lands in `src/stores/feed.ts` plus one new pure-utils module. Current behavior to change:
- `fetchEpisodes(feedUrl, limit, feedId?)` parses the whole feed, stores ALL episodes in the module-level `fullEpisodeCache` Map, returns the first `limit`.
- `refreshFeed` / `refreshAllFeeds` pass the fetched window through `applyRefreshedEpisodes`, which REPLACES `feed.episodes` when ids differ (`sameEpisodes` id-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned by `tests/feed-refresh.test.ts` and must keep passing).
- `loadMoreEpisodesForFeed` grows the displayed window from `fullEpisodeCache` (fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress in `episodeLoadCount`.
- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: fetched refreshes merge (never replace), and the volatile list + cache are bounded by the SAME 30-day window persistence uses — what can be browsed is exactly what can be persisted, and episodes older than the window age out on refresh.
- Style: `feed.ts` is tab-indented WITH semicolons. New utils file: match `src/api/rss-parser.ts` style (2-space, no semicolons).
deliverables:
- `src/utils/feeds-persistence.ts` (the canonical window owner):
- Rename the retention constant to `EPISODE_WINDOW_DAYS = 30` — it now bounds the volatile cache/list as well as persistence.
- New exported `episodeInWindow(ep: Episode, now: Date): boolean``pubDate >= now - EPISODE_WINDOW_DAYS`; missing/invalid pubDates are ALWAYS kept (fail-safe mirror of the persistence rule, so cache and disk can never disagree about an undatable episode). `episodeIsPersistable` becomes `downloadedIds.has(ep.id) || episodeInWindow(ep, now)`.
- Rework `src/utils/episode-merge.ts` (pure, store-free, unit-testable):
- `mergeEpisodesInWindow(existing: Episode[], fetched: Episode[], now: Date): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; pruned to the lifecycle window via `episodeInWindow` (out-of-window episodes dropped, undated kept). No count cap — the bound is the date.
- Invariants: never mutates inputs; stable output for `existing=[]`; entries with invalid `pubDate` sort as newest (use `getTime()`, treat `NaN` as `+Infinity` with a small `ts()` helper).
- `src/stores/feed.ts`:
- Delete `MAX_EPISODES_IN_MEMORY` — no episode-count bound anywhere.
- `fetchEpisodes`: window-filter the parsed feed (`allEpisodes.filter(ep => episodeInWindow(ep, new Date()))`) BEFORE caching and returning: `fullEpisodeCache.set(feedId, windowed)` and `episodes: windowed.slice(0, limit)`. The limit is a page size; the window is the bound.
- `applyRefreshedEpisodes(prev, feedId, episodes)`: replace the `sameEpisodes` replace-with-fetched logic with merge semantics:
- Compute `merged = mergeEpisodesInWindow(f.episodes, episodes, new Date())`.
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache.
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
steps:
1. Read `src/stores/feed.ts` fully and `tests/feed-refresh.test.ts` + `tests/feed-pagination.test.ts` (they pin the contracts you must not break; reuse their harness).
2. Rework `src/utils/episode-merge.ts` to `mergeEpisodesInWindow`; add `episodeInWindow` (and rename `PERSISTED_WINDOW_DAYS``EPISODE_WINDOW_DAYS`) in `feeds-persistence.ts`.
3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodesInWindow` in `applyRefreshedEpisodes`; window-filter `fullEpisodeCache` writes and the returned window in `fetchEpisodes` and `loadMoreEpisodesForFeed`; delete `MAX_EPISODES_IN_MEMORY`.
4. Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact).
5. Write the new tests, run, then full suite + lint.
tests:
- New `tests/feed-volatile-merge.test.ts`:
- Pure unit (ArrangeActAssert) for `mergeEpisodesInWindow(existing, fetched, now)`:
- dedupe on collision, fetched copy wins (mutate title in the fetched twin, assert the merged entry shows the new title).
- union of disjoint lists sorted by `pubDate` desc.
- window prune drops out-of-window episodes from BOTH inputs and keeps undated (NaN pubDate) episodes.
- input arrays not mutated.
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
- Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached.
- Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there.
- No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
- Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
- Existing suites that must keep passing: `tests/feed-refresh.test.ts`, `tests/feed-pagination.test.ts`, `tests/feed-refresh-spinner.test.tsx`.
acceptance_criteria:
- A refresh never removes an episode that was visible before the refresh during the same session — except episodes that aged past the window, which drop out on refresh (the date bound).
- An unchanged refresh does not bump `lastUpdated` (object identity of the feed is preserved).
- Per-feed cached/parsed episodes are exactly the in-window set: nothing outside the last `EPISODE_WINDOW_DAYS` days is cached or loadable, and everything inside is (no count ceiling).
- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and applies the same window to the cache.
- `bun test` full suite passes; `bun run lint` clean.
validation:
- `bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts`
- `bun test`
- `bun run lint`
- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the in-window pages stay; quit and relaunch — the list holds only the 30-day window, and fetch-more re-parses the feed with the same window applied.
notes:
- Depends on task 01 only conceptually: without the persisted-window prune, this merge is still correct but harder to observe. If 01 isn't merged yet, the store tests still pass; the "restart keeps only 30 days" manual check requires 01.
- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.ts` — the window filter belongs at the two write sites named in deliverables, not in a wrapper.
- Do not touch persistence writes in this task; debounced save behavior is task 03. Keep calling the module-scope `saveFeeds(updated)` helper exactly as today.

View File

@@ -0,0 +1,84 @@
# 03. Make refresh/fetch-more/persistence nonblocking — bounded fetch concurrency, incremental per-feed apply, debounced saves
meta:
id: bounded-feed-lifecycle-03
feature: bounded-feed-lifecycle
priority: P1
depends_on: [bounded-feed-lifecycle-01, bounded-feed-lifecycle-02]
tags: [implementation, tests-required]
objective:
- Feed loading must never block or stall the UI: refresh results render as each feed lands instead of after a `Promise.all` barrier, fetch concurrency is capped so 50 subscriptions don't fire 50 simultaneous requests, and `config.json` writes (full file read-modify-write on every change today) collapse into one debounced trailing write per settle window.
background (read this before touching code):
- Work lands in `src/stores/feed.ts` only (plus its tests). Current posture:
- `refreshAllFeeds()` fires `fetchEpisodes` for every feed at once via `Promise.all` and applies results in ONE `setFeeds` at the end — the user sees nothing until the slowest feed resolves or hits `FETCH_TIMEOUT_MS` (20s).
- `parseEpisodesIncremental` already chunks XML parsing and yields to the event loop via MessageChannel — keep that mechanism untouched; the blocking/stall risk today is the fetch barrier and the save path.
- `loadMoreEpisodesForFeed`'s cold-cache refetch has NO timeout (copy the `AbortSignal.timeout(FETCH_TIMEOUT_MS)` pattern from `fetchEpisodes`).
- Persistence: `saveFeeds(updated)``saveFeedsToFile``updateConfig`, a serialized full-file read-`JSON.parse`-stringify-`Bun.write` chain in `src/utils/config.ts`. Called from `refreshFeed`, `refreshAllFeeds`, `loadMoreEpisodesForFeed`, `addFeed`, `removeFeed*`, `updateFeed`, `togglePinned`.
- The boot IIFE calls `refreshAllFeeds()` right after `loadFeedsFromFile()` — this is the cold-start refresh users currently feel; first paint already happens because module init is async, but nothing renders per-feed until the barrier resolves.
- Single feed `refreshFeed` applies its own `setFeeds` immediately — reuse exactly that shape (fetch → apply-if-changed → mark save dirty) for the incremental batch path.
- Tasks 01+02 must be merged first: this task debounces the pruned save path (01) and applies per-feed results through `applyRefreshedEpisodes`/`mergeEpisodes` (02).
- Style: tab-indented WITH semicolons, JSDoc comments on non-obvious functions, section dividers `// ── Name ──…` per repo convention.
- Tests here use `vi.useFakeTimers()``setTimeout`-based debounce must therefore be advanced with `vi.advanceTimersByTime` in tests; don't use `queueMicrotask`-style scheduling for the debounce.
deliverables:
- `src/stores/feed.ts`:
- New constant `FETCH_CONCURRENCY = 4` (comment: bounds simultaneous RSS requests; a hung feed burns at most one slot for `FETCH_TIMEOUT_MS`).
- New module-level async helper `mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>` — classic worker-pool: `limit` workers pulling indexes from a shared counter, results in input order. Pure and generic enough to unit-test.
- Rewritten `refreshAllFeeds()`:
- `setIsLoadingFeeds(true)``finally setIsLoadingFeeds(false)` as today.
- Process feeds through `mapWithConcurrency(feeds(), FETCH_CONCURRENCY, async (feed) => ...)`.
- Inside the per-feed callback: `fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id)`; if non-null, immediately `setFeeds(prev => { const updated = applyRefreshedEpisodes(prev, feed.id, episodes); if (updated !== prev) scheduleSaveFeeds(); return updated; })`. Failed feeds (null) stay untouched, as today.
- After all workers settle: ONE `runAutoDownload()` (as today), and `flushPendingSave()` (below) so a refresh batch always ends with a persisted write when anything changed.
- Debounced save plumbing (module scope, replacing direct calls):
- `let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null; const SAVE_DEBOUNCE_MS = 250;`
- `scheduleSaveFeeds()` — after a state-changing update, mark dirty: set a `savePending = true` flag and (re)arm the trailing timer to fire `flushPendingSave()`.
- `flushPendingSave()` — if `savePending`, snapshot `feeds()`, call `saveFeeds(snapshot)`, clear flag/timer. Export it on the store's returned object (tests need it; also lets task 04/a future quit hook force a write).
- Convert ALL direct `saveFeeds(updated)` / `saveFeeds(newList)` call sites inside `setFeeds` callbacks to `scheduleSaveFeeds()` EXCEPT `removeFeed`/`removeFeedByUrl`, which must call both `scheduleSaveFeeds()` AND `flushPendingSave()` (an unsubscribe intent should not sit unsaved through the debounce window if the process exits). Keep the change mechanical: same call sites, new indirection.
- `loadMoreEpisodesForFeed`: add `signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)` to the cold refetch and return early on non-OK/throw (wrap in try/catch mirroring `fetchEpisodes`).
- `tests/feed-nonblocking.test.ts` (new) — see tests section.
steps:
1. Read `src/stores/feed.ts` and confirm tasks 01/02 are merged (`episodeIsPersistable` in `src/utils/feeds-persistence.ts`, `mergeEpisodes` in `src/utils/episode-merge.ts`).
2. Add `FETCH_CONCURRENCY`, `mapWithConcurrency`, and the debounce plumbing.
3. Rewrite `refreshAllFeeds` per deliverables; convert the save call sites.
4. Add the fetch timeout to `loadMoreEpisodesForFeed`'s cold refetch.
5. Export `flushPendingSave` from the store's return object (Actions section).
6. Write `tests/feed-nonblocking.test.ts`; run new + existing feed tests; full suite; lint.
tests:
- Harness conventions: copy `tests/feed-refresh.test.ts` (temp `XDG_CONFIG_HOME` BEFORE store imports; `Bun.serve` port 0; `vi.useFakeTimers()` in `beforeEach`). Note `vi.advanceTimersByTime(...)` also drives the debounce timer and the MessageChannel yields used by the parser are real task-queue turns (safe under fake timers per the comment on `yieldToUI`).
- New `tests/feed-nonblocking.test.ts`:
- Concurrency bound: server records concurrent in-flight requests (increment on entry, `await new Promise(r => setTimeout(r, 50_000))` under fake-timer awareness: use a gate promise the test controls instead of real sleeps — release gates with `vi.advanceTimersByTime` after asserting). Register 10 feeds; start `refreshAllFeeds()` (don't await); assert the server's max-concurrent counter never exceeded 4; release all gates and await completion.
- Incremental apply: 2 feeds — one served instantly, one gated. Start refresh; resolve the fast gate only; assert the fast feed's `lastUpdated`/episodes already updated in `feeds()` BEFORE the slow feed resolves (this is the acceptance proof the `Promise.all` barrier is gone). Then release the slow gate and assert both applied.
- Debounce: mock-observe writes by seeding the temp config dir and spawning two rapid refreshes whose content changed; `await` both, then `vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)`; read raw `config.json` ONCE — assert both new episodes are present in a single coherent write. (Counting writes precisely is brittle against `updateConfig`'s chain; asserting final content + that the pre-debounce file lacks the episodes is the binary check: before advancing the debounce, `config.json` must NOT yet contain the new episodes; after, it must.)
- `flushPendingSave`: refresh with changed content, call `store.flushPendingSave()` without advancing timers, assert `config.json` already contains the new episode.
- Existing suites must pass unchanged: `feed-refresh.test.ts`, `feed-pagination.test.ts`, `feed-volatile-merge.test.ts`, `feed-refresh-spinner.test.tsx`, `restore-session.test.ts`.
acceptance_criteria:
- During a refresh batch, no more than `FETCH_CONCURRENCY` HTTP requests are ever in flight.
- Each feed's refreshed episodes are visible in `feeds()` as soon as its own fetch resolves — no waiting for the slowest feed.
- Writes to `config.json` are trailing-edge debounced: rapid successive updates produce one final write after the settle window, and `flushPendingSave()` persists immediately.
- `loadMoreEpisodesForFeed`'s refetch aborts at `FETCH_TIMEOUT_MS` instead of hanging forever.
- `bun test` full suite passes; `bun run lint` clean.
validation:
- `bun test tests/feed-nonblocking.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts tests/feed-volatile-merge.test.ts`
- `bun test`
- `bun run lint`
- Manual smoke: `bun start` with several subscriptions; hold `j` during the startup refresh — selection moves smoothly and per-feed results appear as they land; quit/relaunch and confirm the last refresh's episodes persisted.
notes:
- The background refresh timer (`scheduleNextRefresh`) already skips ticks while `isLoadingFeeds()` is true — unchanged.
- Do not introduce a real "sleep" anywhere in tests; gates + fake timers only, matching existing suites.
- `mapWithConcurrency` is generic; keep it module-private in `feed.ts` (no premature new util file).
- `updateConfig` snapshots its patch at call time (`JSON.parse(JSON.stringify(patch))`), so debouncing by delaying the `saveFeeds` CALL is correct — a pending write always serializes the latest feeds it was handed.

View File

@@ -0,0 +1,77 @@
# 04. Add a shared activity store and global top-right loading indicator
meta:
id: bounded-feed-lifecycle-04
feature: bounded-feed-lifecycle
priority: P2
depends_on: [bounded-feed-lifecycle-03]
tags: [implementation, tests-required]
objective:
- One global indicator, always in the top-right corner of the app, visible whenever ANYTHING is being loaded or downloaded: feed refreshes (all-feeds and single-feed), fetch-more, subscribe fetches, searches, and episode downloads. Per-page spinners stay as-is; this adds the global signal that activity is happening anywhere.
background (read this before touching code):
- `src/components/Shell.tsx` renders the whole chrome: one full-width content row (`LayerGraph[nav.activeTab()]()` / `PaneRow`) plus a bottom status/command bar. There is no header row — the top-right corner belongs to whatever page is active, so the indicator must be an ABSOLUTE-POSITIONED overlay drawn after the content so it paints on top (opentui `box` supports `position="absolute"`, `top`, `right`).
- Existing activity signals (read them, don't recreate per-store bookkeeping): `useFeedStore().isLoadingFeeds()` / `.isLoadingMore()`; `useSearchStore().isSearching()` (`src/stores/search.ts`); `useDownloadStore().getActiveCount()` and `.getQueue().length` (`src/stores/download.ts`). Gaps these don't cover: single `refreshFeed`, `addFeed`'s subscribe fetch, iTunes feed resolution inside `addFeed` — hence the activity store.
- `src/components/LoadingIndicator.tsx` is the braille spinner (prop `label?: string`); reuse it inside the overlay.
- Activity tracking must be leak-proof: every `begin` paired with an `end` via a token, PLUS a `track(promise, label)` helper that auto-ends on settle so callers can't strand the counter.
- Task 03 added the incremental per-feed apply inside `refreshAllFeeds`; wire activity around the whole batch ( `isLoadingFeeds` already brackets it — prefer reusing the signal, adding explicit `begin/end` ONLY where no signal exists).
- Style: Solid + `@opentui/solid` JSX (no `className`; props like `fg`, `paddingRight`, `position`); store files tab-indented with semicolons; components match `LoadingIndicator.tsx` conventions. Style imports use `@/` alias in components, relative paths in stores.
deliverables:
- New `src/stores/activity.ts`:
- Signals: `count` (number), `labels` (string[]).
- Actions: `beginActivity(label: string): () => void` (returns the matching end function; each call adds the label, ending removes that exact instance — duplicates allowed), `track<T>(p: Promise<T>, label: string): Promise<T>` (begins, ends in `finally`, re-throws).
- Computed: `isActive(): boolean` (`count() > 0`).
- Singleton + `useActivityStore()` accessor, mirroring `src/stores/download.ts`'s module pattern.
- Wire the gaps in `src/stores/feed.ts` (only where no existing signal covers the operation):
- `refreshFeed`: `await activity.track(...)` around the fetch+apply, label `"Refreshing"`.
- `addFeed`: wrap the directory-resolve + `fetchEpisodes` stretch, label `"Subscribing"`.
- Do NOT wrap `refreshAllFeeds`/`loadMoreEpisodes*``isLoadingFeeds`/`isLoadingMore` already cover them (double-counting just lengthens the spinner's on-time cosmetically; the point is no visual gap).
- New `src/components/GlobalActivityIndicator.tsx`:
- Computes active state from: `feedStore.isLoadingFeeds() || feedStore.isLoadingMore() || searchStore.isSearching() || downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || activity.isActive()`.
- Label selection: downloads in flight → `Downloading N` (+`M queued` when queue non-empty); else the activity store's latest label + `…` (e.g. `Refreshing…`); else `Loading…`.
- Renders `<LoadingIndicator label={…} />` inside `<box position="absolute" top={0} right={0} paddingRight={1}>`; renders nothing (returns `null`) when inactive so it never eats layout when idle.
- `src/components/Shell.tsx`: mount `<GlobalActivityIndicator />` as the LAST child of the root `<box flexDirection="column" …>` (after the content row, bottom bar, and help overlay so it paints on top).
- `tests/global-activity-indicator.test.tsx` (new) — see tests section.
steps:
1. Read `src/stores/download.ts`, `src/stores/search.ts`, `src/components/LoadingIndicator.tsx`, and the render JSX of `src/components/Shell.tsx`.
2. Write `src/stores/activity.ts` (small; ~60 lines).
3. Wire `refreshFeed`/`addFeed` in `src/stores/feed.ts` via `useActivityStore().track(...)`. Import cycle note: `activity.ts` must import NOTHING from other stores (pure counter) so `feed.ts` importing it is safe.
4. Write `src/components/GlobalActivityIndicator.tsx`; mount it in `Shell.tsx` last (paints on top).
5. Write tests; run new tests, full suite, lint; manual smoke per validation.
tests:
- New `tests/global-activity-indicator.test.tsx` (component-test conventions: copy the render harness from `tests/feed-refresh-spinner.test.tsx` — temp `XDG_CONFIG_HOME` before imports; if a jsdom-like setup is used there, reuse it as-is):
- Activity store unit asserts: two `begin`s → `isActive()` true; ending one → still true; ending both → false. `track(failingPromise)` still decrements (rejects propagate, counter returns to baseline).
- Component asserts: render `<GlobalActivityIndicator />` in isolation —
- idle → no text rendered;
- `useActivityStore().beginActivity("Refreshing")` → spinner/label present in rendered output; matching end → gone;
- with the download store: enqueue via `downloadStore.startDownload`-equivalent the way `tests/download-unsubscribed.test.ts` does (assert indicator renders while `getActiveCount() + queue > 0`); skip actual network by following that test's existing mocking pattern.
- Existing suites must pass: `feed-refresh-spinner.test.tsx` (per-page spinners unchanged), full `bun test`.
acceptance_criteria:
- Indicator visible in the top-right overlay while any of: all-feeds refresh, single-feed refresh, fetch-more, subscribe fetch, search, active/queued download — and hidden when none are active.
- Counter never strands: every completed/failed tracked operation returns `isActive()` to its prior value (proven by the `track` rejection test).
- Idle UI unchanged: when inactive the overlay renders nothing and occupies zero layout.
- `bun test` full suite passes; `bun run lint` clean.
validation:
- `bun test tests/global-activity-indicator.test.tsx tests/feed-refresh-spinner.test.tsx`
- `bun test`
- `bun run lint`
- Manual smoke: `bun start`; (a) on cold boot with subscriptions, the top-right spinner appears during startup refresh and disappears when done; (b) press `r` on Feed — spinner appears; (c) download an episode from Search — `Downloading` label shows while the transfer runs; (d) leave idle — top-right is empty.
notes:
- Depends on 03 only for ordering cleanliness — the activity wiring hooks onto the restructured refresh paths; nothing in 03's API is required beyond the store exporting the same signals.
- The overlay intentionally does NOT replace per-pane spinners (`Refreshing…` in Feed/MyShows/Discover/Search stay) — removing those is out of scope.
- If `position="absolute"` proves unavailable for text-draw ordering in `@opentui/solid`, the fallback is a dedicated 1-row header (`height={1}`) above the content row with the indicator right-aligned — only take this path with evidence (broken render), and note the tradeoff (loses one row of content height) in the commit message.

View File

@@ -0,0 +1,27 @@
# Bounded Feed Lifecycle
Objective: Bound feed episode storage to a rolling 30-day persisted window (older episodes volatile-only unless downloaded), keep feed loading nonblocking, and surface all load/download activity in a global top-right indicator.
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [x] 01 — persisted-retention-window → `01-persisted-retention-window.md`
- [x] 02 — volatile-episode-merge → `02-volatile-episode-merge.md`
- [x] 03 — nonblocking-feed-refresh → `03-nonblocking-feed-refresh.md`
- [x] 04 — global-activity-indicator → `04-global-activity-indicator.md`
Dependencies
- 02 depends on 01 (the volatile merge preserves exactly what 01 drops from disk)
- 03 depends on 01 (debounced persistence layers onto the pruning save path)
- 03 depends on 02 (incremental per-feed apply consumes the merge helper from 02)
- 04 depends on 03 (the indicator subscribes to the activity wiring added across refresh/load-more paths in 03)
Exit criteria
- After any refresh + save, `config.json` `feeds[*].episodes` contains only episodes with `pubDate` within the last 30 days or episodes marked `completed` in `downloads.json`; loading a legacy config prunes stale episodes on first launch.
- The volatile episode list and pagination cache are bounded by the same 30-day window as persistence: only in-window episodes are cached/loadable, and everything in-window is (no episode-count ceiling).
- A refresh batch never exceeds a fixed fetch concurrency, applies each feed's result as it lands (no `Promise.all` barrier), and persistence writes are debounced; keyboard input stays responsive throughout.
- The top-right indicator is visible iff at least one feed refresh, fetch-more, subscribe fetch, search, or episode download is in flight, hidden otherwise.
- `bun test` and `bun run lint` pass.

View File

@@ -278,3 +278,87 @@ test.skipIf(!hasFfmpeg)(
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
cache.startDecode(0);
// The 8s initial burst delivers the front of the file instantly.
await waitForCoverage(cache, 5);
// Park the cursor at 0 and drive the cap (the render loop reads
// every frame; the cap applies on the first read past the head).
const out = new Float64Array(512);
for (let i = 0; i < 30 && cache.decoding; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
// Paused at the head budget (4s) + one 8s burst of slack — NOT
// decoded to the 30s EOF.
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4);
expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1);
expect(cache.decodeFinished).toBe(false);
// A parked cursor keeps the cap: more reads must not restart
// the pass or grow the cache.
const cappedAt = cache.coverageEndSec;
for (let i = 0; i < 10; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"the window prunes segments behind the cursor as playback advances",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
// Two segments: the back half [10, ~18] and, after the seek,
// the front [2, ~10].
cache.startDecode(10);
await waitForCoverage(cache, 11);
cache.ensureDecodeAround(2);
await waitForCoverage(cache, 2.2);
expect(cache.covers(2.5)).toBe(true);
expect(cache.covers(10.5)).toBe(true);
// Cursor advances past the front segment's end + keepBehind:
// the front must fall out of the window, the back must survive.
const out = new Float64Array(512);
for (let i = 0; i < 40 && cache.covers(2.5); i++) {
cache.readWindow(out, 13);
await Bun.sleep(25);
}
expect(cache.covers(2.5)).toBe(false);
expect(cache.covers(10.5)).toBe(true);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);

View File

@@ -0,0 +1,323 @@
/**
* Non-blocking feed refresh tests — task 03 of the bounded-feed-lifecycle
* feature.
*
* Pins the contracts that make a refresh batch feel non-blocking:
*
* 1. refreshAllFeeds never holds more than FETCH_CONCURRENCY (4) RSS
* requests in flight — a worker pool bounds the batch instead of
* Promise.all firing every feed at once.
* 2. Each feed's refreshed episodes are applied AS ITS OWN FETCH LANDS —
* the old Promise.all barrier is gone, so a slow feed no longer hides
* the fast feeds' fresh episodes.
* 3. config.json writes are trailing-edge debounced (rapid changes
* collapse into one final write) and flushPendingSave() persists
* immediately, without waiting out the debounce window.
*
* Polling note (why the polls below use setImmediate, not microtasks):
* vi's fake timers trap setTimeout/setInterval/Date/Bun.sleep, so the
* debounce is driven with vi.advanceTimersByTime. But a poll loop of pure
* microtask turns (`await Promise.resolve()`) can NEVER observe an
* in-flight refresh: it keeps the microtask queue non-empty, the event
* loop's poll phase is never reached, and Bun.serve never even receives
* the fetch (verified empirically). setImmediate is a real macrotask that
* fake timers do NOT trap, and it lets the socket I/O progress — each
* `tick()` below is one bounded event-loop turn. No real sleeps anywhere.
*/
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-nonblocking-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
import { whenConfigIdle } from "../src/utils/config";
interface ServedEpisode {
title: string;
date: string;
}
let server: ReturnType<typeof Bun.serve> | null = null;
let servedEpisodes: ServedEpisode[] = [];
/** Per-pathname request gates: while a path has an unresolved gate, the
* server parks that request until the test resolves it. */
let gates = new Map<string, { gate: Promise<void>; resolve: () => void }>();
/** Requests currently inside the fetch handler (entered, not yet answered). */
let inFlight = 0;
/** High-water mark of `inFlight` — the concurrency-bound assertion source. */
let maxConcurrent = 0;
// Bun runs test files in ONE process, so the store singleton is shared with
// other test files. Track the feeds we add and remove them in afterAll so
// whichever file runs next sees a pristine store.
const addedFeedIds: string[] = [];
/** Feed created by the debounce test, reused by the flushPendingSave test. */
let debounceFeedId = "";
/** XML for the current served episode list (episode ids = feedUrl#index). */
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
(ep, i) => `<item>
<title>${ep.title}</title>
<pubDate>${ep.date}</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Non-Blocking Test Show</title>
<description>Non-blocking refresh test feed</description>
${items}
</channel></rss>`;
}
const makePodcast = (feedUrl: string): Podcast => ({
id: feedUrl,
title: "Non-Blocking Test Show",
description: "Non-blocking refresh test feed",
author: "tester",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
});
/** Park a request path behind an unresolved gate. */
function setGate(path: string): void {
const { promise, resolve } = Promise.withResolvers<void>();
gates.set(path, { gate: promise, resolve });
}
/** Resolve every gate currently set. */
function releaseAllGates(): void {
for (const { resolve } of gates.values()) resolve();
gates.clear();
}
/** One real macrotask turn — see the polling note in the header. */
const tick = (): Promise<void> => {
const { promise, resolve } = Promise.withResolvers<void>();
setImmediate(resolve);
return promise;
};
/** Poll `cond` across up to `iterations` event-loop turns (one setImmediate
* each). Returns whether the condition held by the deadline. */
async function pollUntil(
cond: () => boolean,
iterations = 500,
): Promise<boolean> {
for (let i = 0; i < iterations; i++) {
if (cond()) return true;
await tick();
}
return cond();
}
/** Raw config.json text ("" when the file does not exist yet). */
const readConfigRaw = (): Promise<string> =>
Bun.file(join(process.env.XDG_CONFIG_HOME!, "podtui", "config.json"))
.text()
.catch(() => "");
beforeAll(() => {
server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
inFlight++;
if (inFlight > maxConcurrent) maxConcurrent = inFlight;
try {
const gate = gates.get(url.pathname);
if (gate) await gate.gate;
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(servedEpisodes, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
return new Response("not found", { status: 404 });
} finally {
inFlight--;
}
},
});
});
beforeEach(() => {
vi.useFakeTimers();
gates.clear();
inFlight = 0;
maxConcurrent = 0;
});
afterAll(() => {
vi.useRealTimers();
const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
test("refreshAllFeeds never exceeds FETCH_CONCURRENCY in-flight requests", async () => {
const store = useFeedStore();
servedEpisodes = [{ title: "Bound Ep 0", date: "2026-08-10T00:00:00Z" }];
const urls = Array.from(
{ length: 10 },
(_, n) => `http://127.0.0.1:${server!.port}/bound-${n}.xml`,
);
const ids: string[] = [];
for (const url of urls) {
const feed = await store.addFeed(makePodcast(url), "test-source");
expect(feed).not.toBeNull();
ids.push(feed!.id);
addedFeedIds.push(feed!.id);
}
// Gate every path so the batch's requests pile up at the server. addFeed
// ran sequentially above (its own fetches never exceed 1 in flight), so
// the counter below measures the batch alone.
for (const url of urls) setGate(new URL(url).pathname);
inFlight = 0;
maxConcurrent = 0;
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
const sawBound = await pollUntil(() => maxConcurrent >= 4);
expect(sawBound).toBe(true);
// The worker pool caps the batch at 4 — exactly 4 gated requests are
// parked (nothing has been released, so nothing completed yet), and
// nothing may exceed the bound, now or as the batch drains.
expect(maxConcurrent).toBe(4);
expect(maxConcurrent).toBeLessThanOrEqual(4);
releaseAllGates();
await refreshPromise;
expect(maxConcurrent).toBeLessThanOrEqual(4);
for (const id of ids) {
expect(store.getFeed(id)!.episodes.length).toBe(1);
}
});
test("refreshAllFeeds applies each feed as its own fetch lands (no barrier)", async () => {
const store = useFeedStore();
servedEpisodes = [{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" }];
const aUrl = `http://127.0.0.1:${server!.port}/incr-a.xml`;
const bUrl = `http://127.0.0.1:${server!.port}/incr-b.xml`;
const a = await store.addFeed(makePodcast(aUrl), "test-source");
const b = await store.addFeed(makePodcast(bUrl), "test-source");
expect(a).not.toBeNull();
expect(b).not.toBeNull();
const aId = a!.id;
const bId = b!.id;
addedFeedIds.push(aId, bId);
// A new episode appears for both feeds; B's fetch is parked at the
// server, A's is not.
servedEpisodes = [
{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Incr Ep 1", date: "2026-08-09T00:00:00Z" },
];
setGate(new URL(bUrl).pathname);
const beforeA = store.getFeed(aId)!.lastUpdated.getTime();
const beforeB = store.getFeed(bId)!.lastUpdated.getTime();
// Advance the (mocked) clock so the refresh's `new Date()` lastUpdated
// bump is observably greater than beforeA (the fake clock otherwise
// never moves — same pattern as feed-refresh.test.ts).
vi.advanceTimersByTime(60_000);
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
const applied = await pollUntil(
() => store.getFeed(aId)!.lastUpdated.getTime() > beforeA,
);
expect(applied).toBe(true);
// A's refreshed window is visible in feeds() while B is STILL gated —
// the proof that per-feed results apply as they land.
expect(store.getFeed(aId)!.episodes.length).toBe(2);
expect(store.getFeed(bId)!.episodes.length).toBe(1);
expect(store.getFeed(bId)!.lastUpdated.getTime()).toBe(beforeB);
releaseAllGates();
await refreshPromise;
expect(store.getFeed(aId)!.episodes.length).toBe(2);
expect(store.getFeed(bId)!.episodes.length).toBe(2);
});
test("config.json writes are trailing-edge debounced (two refreshes, one save)", async () => {
const store = useFeedStore();
servedEpisodes = [{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }];
const url = `http://127.0.0.1:${server!.port}/debounce.xml`;
const feed = await store.addFeed(makePodcast(url), "test-source");
expect(feed).not.toBeNull();
debounceFeedId = feed!.id;
addedFeedIds.push(debounceFeedId);
servedEpisodes = [
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
];
await store.refreshFeed(debounceFeedId);
servedEpisodes = [
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
];
await store.refreshFeed(debounceFeedId);
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(3);
// No timer advanced: the debounced saves have NOT fired — the refreshed
// episodes exist only in memory (await whenConfigIdle first so a
// straggler write from an earlier test cannot race this read).
await whenConfigIdle();
const before = await readConfigRaw();
expect(before).not.toContain("Deb Ep 1");
expect(before).not.toContain("Deb Ep 2");
// SAVE_DEBOUNCE_MS = 250 (module-private in feed.ts — hardcoded here).
vi.advanceTimersByTime(250);
await whenConfigIdle();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await whenConfigIdle();
// One read, both refreshed episodes: the two refreshes collapsed into a
// single trailing-edge write.
const after = await readConfigRaw();
expect(after).toContain("Deb Ep 1");
expect(after).toContain("Deb Ep 2");
});
test("flushPendingSave persists immediately, without waiting out the debounce", async () => {
const store = useFeedStore();
// Same feed as the debounce test (still in the singleton): serve a 4th
// episode and refresh — the save is scheduled, then flushed by hand.
servedEpisodes = [
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
{ title: "Deb Ep 3", date: "2026-08-07T00:00:00Z" },
];
await store.refreshFeed(debounceFeedId);
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(4);
// No advanceTimersByTime: flushPendingSave must write right now.
store.flushPendingSave();
await whenConfigIdle();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await whenConfigIdle();
const raw = await readConfigRaw();
expect(raw).toContain("Deb Ep 3");
});

View File

@@ -3,11 +3,12 @@
* row in a drilled show's episode list (My Shows depth 1) and the Feed
* page's row.
*
* addFeed caches the FULL parsed feed while exposing only the first
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
* pins:
* addFeed caches every episode inside the lifecycle window (the last
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
* reports when the cache holds more than the loaded window;
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
* chunks until it is exhausted. This pins:
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
* needed) and hasMoreEpisodes flips false once the window reaches the end.
@@ -27,6 +28,8 @@ process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode {
title: string;
date: string;
@@ -94,10 +97,12 @@ afterAll(() => {
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
const store = useFeedStore();
// 60 episodes: 20 shown at subscribe, 40 held back in the cache.
// 60 episodes: 20 shown at subscribe, 40 held back in the cache. All
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
// cacheable — the cache bound is the date window, not a count.
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
title: `Ep ${60 - i}`,
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
@@ -123,9 +128,10 @@ test("loadMoreEpisodes advances one feed's window from the cache, then no-ops",
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
const store = useFeedStore();
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
// All inside the lifecycle window (5h apart = 25 days).
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
title: `Ep ${120 - i}`,
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
date: new Date(Date.now() - (120 - i) * 5 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");

View File

@@ -0,0 +1,162 @@
/**
* FeedPage refresh spinner — while feeds are being fetched (manual `r` and
* the background refresh timer both route through refreshAllFeeds →
* isLoadingFeeds), a braille spinner renders at the BOTTOM of the episode
* list, horizontally centered in the current pane.
*
* The refresh is left in flight on purpose (the test server delays its
* response) so the loading state is visible in the captured frame.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import type { Server } from "bun";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it) and silence the audio backend.
const configHome = mkdtempSync(join(tmpdir(), "podtui-spinner-"));
process.env.XDG_CONFIG_HOME = configHome;
process.env.PODTUI_AUDIO_BACKEND = "none";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { NavigationProvider } from "../src/context/NavigationContext";
import { FeedPage } from "../src/pages/Feed/FeedPage";
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
// The LoadingIndicator glyph cycle.
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
const frameLines = (f: Frame): string[] =>
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
let server: Server<undefined> | null = null;
/** Response delay (ms) for the next fetch — 0 during setup, >0 while the
* refresh is in flight so the loading state is observable. */
let delayMs = 0;
let feedUrl = "";
let feedId = "";
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them.
* Dated inside the lifecycle window (13 days ago) so all three render. */
function feedXml(origin: string): string {
const items = Array.from({ length: 3 }, (_, i) => `<item>
<title>Spin Ep ${3 - i}</title>
<pubDate>${new Date(Date.now() - (3 - i) * 24 * 3600 * 1000).toISOString()}</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Spinner Show</title>
<description>spinner test feed</description>
${items}
</channel></rss>`;
}
beforeAll(async () => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (!url.pathname.endsWith(".xml")) {
return new Response("not found", { status: 404 });
}
const { promise, resolve } = Promise.withResolvers<Response>();
setTimeout(
() =>
resolve(
new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
}),
),
delayMs,
);
return promise;
},
});
const podcast: Podcast = {
id: "",
title: "Spinner Show",
description: "spinner test feed",
author: "tester",
feedUrl: "",
lastUpdated: new Date(),
isSubscribed: true,
};
feedUrl = `http://127.0.0.1:${server.port}/spinner.xml`;
const store = useFeedStore();
const feed = await store.addFeed(
{ ...podcast, feedUrl },
"test-source",
);
feedId = feed!.id;
});
afterAll(async () => {
const store = useFeedStore();
store.removeFeed(feedId);
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
test("refresh spinner renders at the bottom of the list, centered in the current pane", async () => {
const store = useFeedStore();
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<NavigationProvider>
<FeedPage />
</NavigationProvider>
</ThemeProvider>
),
{ width: 100, height: 30, useThread: false },
);
// Settle until the episode list is mounted.
let lines: string[] | null = null;
for (let i = 0; i < 40 && !lines; i++) {
await setup.renderOnce();
const ls = frameLines(setup.captureSpans() as unknown as Frame);
if (ls.some((l) => l.includes("Spin Ep 3"))) lines = ls;
else await new Promise((r) => setTimeout(r, 50));
}
if (!lines) throw new Error("FeedPage did not render episodes before timeout");
// Kick off a refresh and leave it in flight: isLoadingFeeds flips true
// synchronously, so the very next frame shows the spinner.
delayMs = 400;
const refreshing = store.refreshAllFeeds();
await setup.renderOnce();
const loading = frameLines(setup.captureSpans() as unknown as Frame);
// Locate the spinner row and the current pane's borders ("│" columns;
// only the current pane is bordered in PaneRow).
const spinnerRow = loading.findIndex((l) => SPINNER_RE.test(l));
expect(spinnerRow).toBeGreaterThan(-1);
const spinnerCol = loading[spinnerRow].search(SPINNER_RE);
const borderCols = loading
.map((l, i) => (i <= spinnerRow ? [...l].map((ch, x) => (ch === "│" ? x : -1)) : []))
.flat()
.filter((x) => x >= 0);
const paneLeft = Math.min(...borderCols);
const paneRight = Math.max(...borderCols);
const paneCenter = (paneLeft + paneRight) / 2;
expect(paneLeft).toBeGreaterThan(0); // borders actually found
// Bottom of the list: below the last episode row.
const lastEpRow = loading.findLastIndex((l) => l.includes("Spin Ep"));
expect(spinnerRow).toBeGreaterThan(lastEpRow);
// Horizontally centered in the current pane (not left-padded).
expect(Math.abs(spinnerCol - paneCenter)).toBeLessThanOrEqual(8);
// Let the refresh finish so teardown is clean.
delayMs = 0;
await refreshing;
setup.renderer.destroy();
});

View File

@@ -29,6 +29,8 @@ process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
interface ServedEpisode {
title: string;
date: string;
@@ -137,6 +139,7 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
test("a failed refresh does not wipe the feed's episodes", async () => {
const store = useFeedStore();
const savedEpisodes = servedEpisodes;
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
@@ -158,6 +161,11 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
failPath = null;
store.removeFeed(feedId);
// Restore the shared served content: with union merge semantics (volatile
// episodes survive refreshes) this feed keeps its larger in-memory window,
// so later tests must serve the same episodes they added — a shrink here
// would make the next test's "unchanged" refresh genuinely different.
servedEpisodes = savedEpisodes;
});
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
@@ -183,3 +191,46 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
}
});
test("refresh parses in bounded chunks, yielding to the event loop between them", async () => {
const store = useFeedStore();
// 60 episodes: a chunked parse (25/chunk) must yield between chunks; a
// monolithic parse would complete without yielding at all. All dated
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
// cacheable and the window assertions below hold.
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
title: `Ep ${60 - i}`,
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
const feedId = feed!.id;
expect(store.getFeed(feedId)!.episodes.length).toBe(20); // subscribe window
// Count event-loop yields during the refresh: each parse-chunk boundary
// posts through a MessageChannel (the yield primitive in feed.ts — the
// one macrotask turn bun's fake timers do not trap, which also pins that
// the yield works under fake timers). This runs under fake timers like
// the other tests; a setTimeout-based yield would deadlock here.
const OriginalMessageChannel = globalThis.MessageChannel;
let posts = 0;
globalThis.MessageChannel = class extends OriginalMessageChannel {
constructor() {
super();
posts++;
}
};
try {
vi.advanceTimersByTime(60_000);
await store.refreshFeed(feedId);
} finally {
globalThis.MessageChannel = OriginalMessageChannel;
}
expect(posts).toBeGreaterThan(0);
expect(store.getFeed(feedId)!.episodes.length).toBe(50); // refresh window
// Leave the shared singleton as we found it (see the addedFeedIds note
// in feed-pagination.test.ts — bun runs test files in one process).
store.removeFeed(feedId);
});

View File

@@ -0,0 +1,286 @@
/**
* Bounded-feed-lifecycle persistence tests — task 01 (retention window).
*
* Pins the persistence contract:
* 1. saveFeedsToFile never writes an episode older than DEFAULT_EPISODE_WINDOW_DAYS
* unless its id is a completed download in downloads.json.
* 2. loadFeedsFromFile prunes over-window episodes from legacy configs and
* rewrites config.json when it pruned anything.
* 3. Undatable episodes (missing/invalid pubDate) are always persisted.
*
* The async saveFeedsToFile IIFE reads downloads.json then enqueues an
* updateConfig write on the serialized write chain, so assertions wait via
* whenConfigIdle() + a short real-timer settle (no fake timers here — see
* settleWrites).
*/
import { test, expect, afterAll } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing anything
// under test (config-dir reads XDG_CONFIG_HOME lazily, but stay consistent
// with the store test harness). Do NOT import the feed store — its module
// boot IIFE would hit the network.
const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-"));
process.env.XDG_CONFIG_HOME = configHome;
import {
DEFAULT_EPISODE_WINDOW_DAYS,
episodeIsPersistable,
loadFeedsFromFile,
saveFeedsToFile,
} from "../src/utils/feeds-persistence";
import { whenConfigIdle } from "../src/utils/config";
import { FeedVisibility } from "../src/types/feed";
import type { Feed } from "../src/types/feed";
import type { Episode } from "../src/types/episode";
const configJsonPath = join(configHome, "podtui", "config.json");
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
/** Milliseconds in one day — mirrors the window math in feeds-persistence. */
const DAY = 24 * 3600 * 1000;
function makeEpisode(partial: Partial<Episode> & { id: string }): Episode {
return {
podcastId: "feed-1",
title: partial.id,
description: "",
audioUrl: `https://example.com/audio/${partial.id}.mp3`,
duration: 600,
pubDate: new Date(),
...partial,
};
}
function makeFeed(episodes: Episode[]): Feed {
return {
id: "feed-1",
podcast: {
id: "feed-1",
title: "Retention Show",
description: "Retention test feed",
author: "tester",
feedUrl: "https://example.com/feed.xml",
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "source-1",
lastUpdated: new Date(),
isPinned: false,
};
}
function delay(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}
/**
* Wait for the fire-and-forget save chain to drain. saveFeedsToFile's IIFE is
* not awaitable: it reads downloads.json first and only THEN enqueues its
* write on the serialized chain, so the first whenConfigIdle() may observe
* the chain BEFORE the write is queued. A real-timer settle is the only way
* to let the IIFE's async read land without fake timers (which would stall
* the Bun.file I/O and the write chain itself); the poll fallback in the
* assertions absorbs any residual scheduling skew on a loaded machine.
*/
async function settleWrites(): Promise<void> {
await whenConfigIdle();
await delay(20);
await whenConfigIdle();
}
/** Episode ids of the first feed in config.json, or null when absent. */
async function readPersistedEpisodeIds(): Promise<string[] | null> {
const raw = await Bun.file(configJsonPath).json().catch(() => null);
if (!raw || typeof raw !== "object" || !("feeds" in raw)) return null;
const feeds = raw.feeds;
if (!Array.isArray(feeds) || feeds.length === 0) return null;
const first = feeds[0];
if (!first || typeof first !== "object" || !("episodes" in first)) return null;
const episodes = first.episodes;
if (!Array.isArray(episodes)) return null;
return episodes.map((ep) => {
if (ep && typeof ep === "object" && "id" in ep) return String(ep.id);
return "";
});
}
/** Wait (up to ~1s) until config.json's first feed has exactly these ids. */
async function pollConfigFor(ids: string[]): Promise<void> {
const expected = [...ids].sort().join(",");
const deadline = Date.now() + 1000;
for (;;) {
const actual = (await readPersistedEpisodeIds())?.sort().join(",");
if (actual === expected) return;
if (Date.now() > deadline) {
throw new Error(
`config.json never reached expected episode ids [${ids.join(", ")}]`,
);
}
await delay(20);
}
}
afterAll(() => {
rmSync(configHome, { recursive: true, force: true });
});
// ── Unit: episodeIsPersistable ──────────────────────────────────────────────
test("episodeIsPersistable drops a 70-day-old episode that is not downloaded", () => {
const ep = makeEpisode({
id: "old-plain-id",
pubDate: new Date(Date.now() - 70 * DAY),
});
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false);
});
test("episodeIsPersistable keeps a 70-day-old episode whose id is a completed download", () => {
const ep = makeEpisode({
id: "old-downloaded-id",
pubDate: new Date(Date.now() - 70 * DAY),
});
expect(
episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()),
).toBe(true);
});
test("episodeIsPersistable keeps a 5-day-old episode", () => {
const ep = makeEpisode({
id: "recent-id",
pubDate: new Date(Date.now() - 5 * DAY),
});
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
});
test("episodeIsPersistable keeps an episode with an invalid pubDate", () => {
const ep = makeEpisode({ id: "undatable-id", pubDate: new Date(NaN) });
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
});
test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
});
// ── Save path: retention window applied with completed-download exemption ──
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
// Arrange: downloads.json lists one completed download.
mkdirSync(join(configHome, "podtui"), { recursive: true });
await Bun.write(
downloadsJsonPath,
JSON.stringify([
{
episodeId: "old-downloaded-id",
feedId: "feed-1",
status: "completed",
filePath: null,
downloadedAt: null,
fileSize: 0,
error: null,
audioUrl: "",
episodeTitle: "",
},
]),
);
// Act: recent episode, old plain episode, old downloaded episode.
const feed = makeFeed([
makeEpisode({
id: "recent-id",
pubDate: new Date(Date.now() - 5 * DAY),
}),
makeEpisode({
id: "old-plain-id",
pubDate: new Date(Date.now() - 70 * DAY),
}),
makeEpisode({
id: "old-downloaded-id",
pubDate: new Date(Date.now() - 70 * DAY),
}),
]);
saveFeedsToFile([feed]);
await settleWrites();
// The IIFE's downloads.json read can land after the first settle; poll
// briefly in case the write chain drained before that read resolved.
await pollConfigFor(["old-downloaded-id", "recent-id"]);
// Assert: persisted episodes keep recent + downloaded, drop old-plain.
const persisted = await readPersistedEpisodeIds();
expect(persisted).toContain("recent-id");
expect(persisted).toContain("old-downloaded-id");
expect(persisted).not.toContain("old-plain-id");
});
// ── Load path: legacy config cleanup rewrite ───────────────────────────────
test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", async () => {
// Arrange: seed config.json directly with a feed whose episodes are ALL
// older than the window; no downloads.json present.
mkdirSync(join(configHome, "podtui"), { recursive: true });
await Bun.write(
configJsonPath,
JSON.stringify(
{
feeds: [
{
id: "feed-1",
podcast: {
id: "feed-1",
title: "Legacy Show",
description: "",
author: "tester",
feedUrl: "https://example.com/legacy.xml",
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
isSubscribed: true,
},
episodes: [
{
id: "old-a",
podcastId: "feed-1",
title: "Old A",
description: "",
audioUrl: "https://example.com/audio/old-a.mp3",
duration: 60,
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
},
{
id: "old-b",
podcastId: "feed-1",
title: "Old B",
description: "",
audioUrl: "https://example.com/audio/old-b.mp3",
duration: 60,
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
},
],
visibility: "public",
sourceId: "source-1",
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
isPinned: false,
},
],
},
null,
2,
),
);
// Act.
const feeds = await loadFeedsFromFile();
await settleWrites();
// Assert: returned feed has zero episodes AND config.json was rewritten
// (the cleanup save is fire-and-forget — poll for the rewritten file).
expect(feeds).toHaveLength(1);
expect(feeds[0].episodes).toHaveLength(0);
await pollConfigFor([]);
expect(await readPersistedEpisodeIds()).toEqual([]);
});

View File

@@ -0,0 +1,331 @@
/**
* Configurable episode cache + volatile merge tests.
*
* The episode list cache (what the Feed and My Shows pages show) is bounded by
* the user's preference: a date window (default 60 days) or a count (default
* 25). The full parse cache holds ALL episodes; fetch-more pages beyond the
* bound from that cache (volatile — never written back). These tests pin:
* 1. mergeEpisodesBounded unions refreshed episodes with what's in memory
* (fetched copy wins on id collision) and prunes by the supplied keep
* predicate (count or date). Undated episodes are always kept.
* 2. The store bounds the visible list by the configured mode, but the
* full parse cache survives — fetch-more pages beyond the bound.
* 3. Refresh merge never shrinks the in-memory list except via the bound.
*
* Clock constraint: these tests run under vi.useFakeTimers, and a LARGE
* vi.advanceTimersByTime (past ~5 days of fake time) makes every subsequent
* network fetch hang in Bun 1.3.8's fake-timer implementation. The date
* boundary is pinned with relative pubDates, never by moving the clock.
*/
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
import { episodeInWindow } from "../src/utils/feeds-persistence";
import { useAppStore } from "../src/stores/app";
import type { Episode } from "../src/types/episode";
import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000;
const DAY = 24 * HOUR;
interface ServedEpisode {
title: string;
date: string;
}
let server: ReturnType<typeof Bun.serve> | null = null;
let servedEpisodes: ServedEpisode[] = [];
const addedFeedIds: string[] = [];
function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes
.map(
(ep, i) => `<item>
<title>${ep.title}</title>
<pubDate>${ep.date}</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Volatile Show</title>
<description>Volatile merge test feed</description>
${items}
</channel></rss>`;
}
const makePodcast = (feedUrl: string): Podcast => ({
id: feedUrl,
title: "Volatile Show",
description: "Volatile merge test feed",
author: "tester",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
});
const makeEpisode = (id: string, title: string, pubDate: Date): Episode => ({
id,
podcastId: "pod",
title,
description: "",
audioUrl: `https://example.com/${id}.mp3`,
duration: 100,
pubDate,
});
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(servedEpisodes, url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
return new Response("not found", { status: 404 });
},
});
});
beforeEach(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
// ── mergeEpisodesBounded unit tests ──────────────────────────────────────
const NOW = new Date("2026-08-10T00:00:00Z");
test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", () => {
const existing = [
makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")),
makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")),
];
const fetched = [
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged).toHaveLength(2);
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
});
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
const existing = [
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
];
const fetched = [
makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")),
makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")),
];
const keepAll = () => true;
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
});
test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => {
const existing = [
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
];
const fetched = [
makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")),
makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")),
];
const keepCount2 = (_ep: Episode, i: number) => i < 2;
const merged = mergeEpisodesBounded(existing, fetched, keepCount2);
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
});
test("mergeEpisodesBounded with date keep drops out-of-window and keeps undated", () => {
const existing = [
makeEpisode("fresh", "Fresh", new Date("2026-08-09T00:00:00Z")),
makeEpisode("stale", "Stale", new Date("2026-06-01T00:00:00Z")),
makeEpisode("undated", "Undated", new Date(NaN)),
];
const fetched = [
makeEpisode("newStale", "New Stale", new Date("2026-05-01T00:00:00Z")),
makeEpisode("newFresh", "New Fresh", new Date("2026-08-08T00:00:00Z")),
];
// 30-day window from NOW (2026-08-10)
const keepDate = (ep: Episode) => episodeInWindow(ep, NOW, 30);
const merged = mergeEpisodesBounded(existing, fetched, keepDate);
expect(merged.map((e) => e.id)).toEqual(["undated", "fresh", "newFresh"]);
});
test("mergeEpisodesBounded never mutates its inputs", () => {
const existing = [
makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")),
makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")),
];
const fetched = [
makeEpisode("a", "A (fetched)", new Date("2026-08-01T00:00:00Z")),
makeEpisode("c", "C", new Date("2026-08-03T00:00:00Z")),
];
const existingIds = existing.map((e) => e.id);
const existingTitles = existing.map((e) => e.title);
const keepAll = () => true;
mergeEpisodesBounded(existing, fetched, keepAll);
expect(existing.map((e) => e.id)).toEqual(existingIds);
expect(existing.map((e) => e.title)).toEqual(existingTitles);
});
// ── store integration (default date mode, 60-day window) ─────────────────
test("refresh merges new episodes without removing the volatile window", async () => {
const store = useFeedStore();
servedEpisodes = [
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
];
const feedUrl = `http://127.0.0.1:${server!.port}/volatile.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(3);
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
servedEpisodes = [
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
{ title: "Ep 5", date: "2026-08-05T00:00:00Z" },
{ title: "Ep 4", date: "2026-08-04T00:00:00Z" },
];
vi.advanceTimersByTime(60_000);
await store.refreshFeed(id);
const afterFirst = store.getFeed(id)!;
expect(afterFirst.episodes.length).toBe(5);
expect(afterFirst.lastUpdated.getTime()).toBeGreaterThan(beforeUpdated);
// Identical second refresh: no lastUpdated bump, object identity kept.
vi.advanceTimersByTime(60_000);
await store.refreshFeed(id);
const afterSecond = store.getFeed(id)!;
expect(afterSecond).toBe(afterFirst);
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
});
test("date mode: episodes outside the 60-day window never enter the list", async () => {
const store = useFeedStore();
const now = Date.now();
// 600 episodes at 2h spacing span ~50 days — all inside the 60-day default
// window, so all 600 are cached and loadable (no count ceiling).
servedEpisodes = Array.from({ length: 600 }, (_, i) => ({
title: `Ep ${600 - i}`,
date: new Date(now - i * 2 * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/date-all.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
// Subscribe window (20) with more cached.
expect(store.getFeed(id)!.episodes.length).toBe(20);
// Load everything — the cache holds all 600 (date mode keeps them all).
let iterations = 0;
while (store.hasMoreEpisodes(id) && iterations < 20) {
await store.loadMoreEpisodes(id);
iterations++;
}
expect(store.hasMoreEpisodes(id)).toBe(false);
expect(store.getFeed(id)!.episodes.length).toBe(600);
});
test("date mode boundary: 25 days in, 70 days out", async () => {
const store = useFeedStore();
const now = Date.now();
servedEpisodes = [
{ title: "In Window", date: new Date(now - 25 * DAY).toISOString() },
{ title: "Out Window", date: new Date(now - 70 * DAY).toISOString() },
];
const feedUrl = `http://127.0.0.1:${server!.port}/date-boundary.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
]);
// The full cache holds both, but the visible list only shows the in-window
// one — fetch-more surfaces the out-of-window one (volatile).
expect(store.hasMoreEpisodes(id)).toBe(true);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
"Out Window",
]);
});
// ── count mode ────────────────────────────────────────────────────────────
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
const store = useFeedStore();
const app = useAppStore();
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
const now = Date.now();
// 50 episodes at 1h spacing — all recent, but count mode caps at 25.
servedEpisodes = Array.from({ length: 50 }, (_, i) => ({
title: `Ep ${50 - i}`,
date: new Date(now - i * HOUR).toISOString(),
}));
const feedUrl = `http://127.0.0.1:${server!.port}/count.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull();
const id = feed!.id;
addedFeedIds.push(id);
// Subscribe window (20), but the cache holds all 50 — count mode only
// bounds the visible list (25), but the full parse cache is unbounded.
// The subscribe window returns min(20, 25) = 20.
expect(store.getFeed(id)!.episodes.length).toBe(20);
expect(store.hasMoreEpisodes(id)).toBe(true);
// Fetch more: the visible list grows beyond the count bound — these
// episodes are volatile (held in feed.episodes, not extending the cache).
while (store.hasMoreEpisodes(id)) {
await store.loadMoreEpisodes(id);
}
expect(store.getFeed(id)!.episodes.length).toBe(50);
// Reset to date mode for subsequent tests.
app.updatePreferences({ episodeCacheMode: "date" });
});

View File

@@ -0,0 +1,199 @@
/**
* Global activity indicator — the shared leak-proof activity store
* (begin/end counter + track helper) and the global top-right overlay that
* surfaces feed refresh, fetch-more, subscribe fetch, search, and download
* activity. The download transfer is left in flight on purpose (the test
* server delays its response) so the "Downloading" state is observable in
* the captured frame.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config/data dirs at throwaway directories BEFORE importing the
// stores (their module-level init reads them) and silence the audio backend.
const configHome = mkdtempSync(join(tmpdir(), "podtui-activity-"));
process.env.XDG_CONFIG_HOME = configHome;
const dataHome = mkdtempSync(join(tmpdir(), "podtui-activity-data-"));
process.env.XDG_DATA_HOME = dataHome;
process.env.PODTUI_AUDIO_BACKEND = "none";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { GlobalActivityIndicator } from "../src/components/GlobalActivityIndicator";
import { useActivityStore } from "../src/stores/activity";
import { useDownloadStore } from "../src/stores/download";
import type { Episode } from "../src/types/episode";
// The LoadingIndicator glyph cycle.
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
const frameLines = (f: Frame): string[] =>
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
function sleep(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}
/** Render the indicator in isolation under the dark theme. The ThemeProvider
* gates children on async init (capabilities + palette detection, up to
* ~1.5s under tmux), so settle frames until the indicator is mounted. */
async function renderIndicator() {
const setup = await testRender(
() => (
<ThemeProvider mode="dark">
<GlobalActivityIndicator />
</ThemeProvider>
),
{ width: 60, height: 10, useThread: false },
);
await setup.renderOnce();
for (let i = 0; i < 40; i++) {
await setup.renderOnce();
await sleep(50);
}
return setup;
}
const frameText = (setup: { captureSpans: () => unknown }): string =>
frameLines(setup.captureSpans() as unknown as Frame).join("\n");
let server: ReturnType<typeof Bun.serve> | null = null;
let audioUrl = "";
/** Response delay (ms) for the next audio request — keeps the transfer in
* flight while the "Downloading" state is asserted. */
let audioDelayMs = 0;
/** Episode ids this file started downloads for (shared singleton cleanup). */
const downloadedEpisodeIds: string[] = [];
const makeEpisode = (id: string, title: string): Episode => ({
id,
podcastId: "pod",
title,
description: "",
audioUrl,
duration: 0,
pubDate: new Date("2026-08-01T00:00:00Z"),
});
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch() {
const { promise, resolve } = Promise.withResolvers<Response>();
setTimeout(
() =>
resolve(
new Response("audio bytes", {
headers: { "Content-Type": "audio/mpeg" },
}),
),
audioDelayMs,
);
return promise;
},
});
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
});
afterAll(async () => {
const dl = useDownloadStore();
for (const id of downloadedEpisodeIds) {
dl.cancelDownload(id);
await dl.removeDownload(id);
}
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
rmSync(dataHome, { recursive: true, force: true });
});
test("beginActivity/end pairs compose: ending one keeps the other active", () => {
const activity = useActivityStore();
expect(activity.isActive()).toBe(false);
expect(activity.labels()).toEqual([]);
const endFirst = activity.beginActivity("Refreshing");
const endSecond = activity.beginActivity("Refreshing");
expect(activity.isActive()).toBe(true);
expect(activity.labels()).toEqual(["Refreshing", "Refreshing"]);
endFirst();
expect(activity.isActive()).toBe(true);
expect(activity.labels()).toEqual(["Refreshing"]);
endSecond();
expect(activity.isActive()).toBe(false);
expect(activity.labels()).toEqual([]);
});
test("track re-throws rejection and returns isActive() to its prior value", async () => {
const activity = useActivityStore();
const prior = activity.isActive();
await expect(
activity.track(Promise.reject(new Error("boom")), "Refreshing"),
).rejects.toThrow("boom");
expect(activity.isActive()).toBe(prior);
expect(activity.labels()).toEqual([]);
});
test("idle: renders nothing, no spinner", async () => {
const setup = await renderIndicator();
const text = frameText(setup);
expect(text).not.toMatch(SPINNER_RE);
expect(text.trim()).toBe("");
setup.renderer.destroy();
});
test("tracked activity: spinner appears while active, vanishes on end", async () => {
const activity = useActivityStore();
const setup = await renderIndicator();
expect(frameText(setup)).not.toMatch(SPINNER_RE);
const end = activity.beginActivity("Refreshing");
await setup.renderOnce();
const active = frameText(setup);
expect(active).toMatch(SPINNER_RE);
end();
await setup.renderOnce();
const done = frameText(setup);
expect(done).not.toMatch(SPINNER_RE);
expect(done.trim()).toBe("");
setup.renderer.destroy();
});
test("active download: spinner appears and disappears", async () => {
const dl = useDownloadStore();
const setup = await renderIndicator();
// Keep the transfer in flight while asserting; the response only lands
// after audioDelayMs, so the download stays DOWNLOADING across renders.
audioDelayMs = 400;
const episode = makeEpisode("activity-dl-ep", "DL Ep");
downloadedEpisodeIds.push(episode.id);
dl.startDownload(episode, "activity-test-feed");
await setup.renderOnce();
const during = frameText(setup);
expect(during).toMatch(SPINNER_RE);
// Cancel: the abort settles the fetch and activeCount returns to 0.
dl.cancelDownload(episode.id);
for (let i = 0; i < 40; i++) {
if (dl.getActiveCount() + dl.getQueue().length === 0) break;
await sleep(25);
}
await dl.removeDownload(episode.id);
await setup.renderOnce();
const after = frameText(setup);
expect(dl.getActiveCount() + dl.getQueue().length).toBe(0);
expect(after).not.toMatch(SPINNER_RE);
audioDelayMs = 0;
setup.renderer.destroy();
});

View File

@@ -66,6 +66,7 @@ type TestPaneProps = {
current?: (() => unknown) | unknown;
preview?: unknown;
focused?: unknown;
currentBorder?: unknown;
width?: number;
height?: number;
};
@@ -83,6 +84,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
preview={props.preview as any}
currentLabel="List"
focused={props.focused as any}
currentBorder={props.currentBorder as any}
/>
</ThemeProvider>
),
@@ -232,4 +234,17 @@ describe("PaneRow current-pane borders", () => {
expect(borderColumns(spans)).toEqual([20, 69]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
});
test("currentBorder=['left'] removes the right edge (left only)", async () => {
const { spans, destroy } = await renderPaneRow({
parent: null,
current: () => <text>ITEM</text>,
preview: null,
currentBorder: ["left"],
});
cleanups.push(destroy);
// Only the left border glyph at column 20 — no right edge at 69.
expect(borderColumns(spans)).toEqual([20]);
expect(frameText(spans)).not.toMatch(boxGlyphs);
});
});