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).
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.
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.
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.